fix: log silent errors, harden proxy auth, optimize db and render pipeline

Signed-off-by: Hadi <112569860+anotherhadi@users.noreply.github.com>
This commit is contained in:
Hadi
2026-05-20 10:19:37 +02:00
parent af872afbe8
commit 67fe8eb911
16 changed files with 90 additions and 74 deletions
+2 -1
View File
@@ -41,6 +41,7 @@ type Config struct {
DefaultInterceptEnabled bool `mapstructure:"default_intercept_enabled"` DefaultInterceptEnabled bool `mapstructure:"default_intercept_enabled"`
DefaultCaptureResponse bool `mapstructure:"default_capture_response"` DefaultCaptureResponse bool `mapstructure:"default_capture_response"`
AutoForwardRegex []string `mapstructure:"auto_forward_regex"` AutoForwardRegex []string `mapstructure:"auto_forward_regex"`
QueueSize int `mapstructure:"queue_size"`
} `mapstructure:"intercept"` } `mapstructure:"intercept"`
Replay struct { Replay struct {
@@ -82,7 +83,7 @@ func WriteDefaultConfig(path string) error {
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
return fmt.Errorf("create config dir: %w", err) return fmt.Errorf("create config dir: %w", err)
} }
if err := os.WriteFile(path, defaultConfig, 0o644); err != nil { if err := os.WriteFile(path, defaultConfig, 0o600); err != nil {
return fmt.Errorf("write config: %w", err) return fmt.Errorf("write config: %w", err)
} }
return nil return nil
+2 -1
View File
@@ -5,13 +5,14 @@ app:
project_dir: ~/.local/share/spilltea project_dir: ~/.local/share/spilltea
plugins_dir: ~/.config/spilltea/plugins plugins_dir: ~/.config/spilltea/plugins
upstream_proxy: "" # e.g. http://corporate-proxy:8888 or http://user:pass@host:8888 upstream_proxy: "" # e.g. http://corporate-proxy:8888 or http://user:pass@host:8888
proxy_auth: "" # require basic auth to use the proxy, format: user:pass (empty = disabled) proxy_auth: "" # require basic auth to use the proxy, format: user:pass (empty = disabled). Also run: chmod 600 ~/.config/spilltea/config.yaml
max_body_size_mb: 50 # max response body size read into memory for large streamed responses (MB) max_body_size_mb: 50 # max response body size read into memory for large streamed responses (MB)
external_editor: "" # override $EDITOR for external editing (e.g. nvim, code --wait) external_editor: "" # override $EDITOR for external editing (e.g. nvim, code --wait)
intercept: intercept:
default_intercept_enabled: true default_intercept_enabled: true
default_capture_response: false default_capture_response: false
queue_size: 64 # max pending intercepted requests/responses before the proxy blocks
auto_forward_regex: auto_forward_regex:
- '\.(js|css|png|gif|ico|woff2?|ttf|svg)(\?.*)?$' - '\.(js|css|png|gif|ico|woff2?|ttf|svg)(\?.*)?$'
+13
View File
@@ -9,6 +9,7 @@ import (
type DB struct { type DB struct {
conn *sql.DB conn *sql.DB
roConn *sql.DB
path string path string
dedupMu sync.Mutex dedupMu sync.Mutex
} }
@@ -27,6 +28,17 @@ func Open(path string) (*DB, error) {
conn.Close() conn.Close()
return nil, err return nil, err
} }
roConn, err := sql.Open("sqlite", path)
if err != nil {
conn.Close()
return nil, err
}
if _, err := roConn.Exec("PRAGMA query_only=ON"); err != nil {
conn.Close()
roConn.Close()
return nil, err
}
d.roConn = roConn
return d, nil return d, nil
} }
@@ -94,6 +106,7 @@ func (d *DB) Close() error {
if d == nil { if d == nil {
return nil return nil
} }
_ = d.roConn.Close()
return d.conn.Close() return d.conn.Close()
} }
+9 -12
View File
@@ -4,6 +4,7 @@ import (
"crypto/sha256" "crypto/sha256"
"database/sql" "database/sql"
"fmt" "fmt"
"log"
"strings" "strings"
"time" "time"
) )
@@ -63,7 +64,11 @@ func (d *DB) InsertEntry(e Entry, body string) (Entry, error) {
if err != nil { if err != nil {
return e, err return e, err
} }
e.ID, _ = res.LastInsertId() var idErr error
e.ID, idErr = res.LastInsertId()
if idErr != nil {
log.Printf("db: LastInsertId: %v", idErr)
}
return e, nil return e, nil
} }
@@ -113,19 +118,11 @@ func (d *DB) SearchEntries(term string) ([]Entry, error) {
// QueryEntries runs a WHERE expression supplied by the user against the entries // QueryEntries runs a WHERE expression supplied by the user against the entries
// table (e.g. "status_code = 404" or "host LIKE '%example.com%'"). // table (e.g. "status_code = 404" or "host LIKE '%example.com%'").
// It opens a dedicated read-only connection so that any DML or DDL in the // Uses the persistent read-only connection (PRAGMA query_only=ON) so that any
// user-supplied expression is rejected by SQLite before it can execute. // DML or DDL in the user-supplied expression is rejected by SQLite before it executes.
func (d *DB) QueryEntries(where string) ([]Entry, error) { func (d *DB) QueryEntries(where string) ([]Entry, error) {
roConn, err := sql.Open("sqlite", d.path)
if err != nil {
return nil, err
}
defer roConn.Close()
if _, err := roConn.Exec("PRAGMA query_only=ON"); err != nil {
return nil, err
}
q := "SELECT id, timestamp, method, host, path, status_code, request_raw, response_raw, flagged FROM entries WHERE " + strings.TrimSpace(where) q := "SELECT id, timestamp, method, host, path, status_code, request_raw, response_raw, flagged FROM entries WHERE " + strings.TrimSpace(where)
rows, err := roConn.Query(q) rows, err := d.roConn.Query(q)
if err != nil { if err != nil {
return nil, err return nil, err
} }
+6 -2
View File
@@ -58,9 +58,13 @@ func (b *Broker) SetOnNewEntry(cb func(db.Entry)) {
} }
func NewBroker() *Broker { func NewBroker() *Broker {
size := config.Global.Intercept.QueueSize
if size <= 0 {
size = 64
}
b := &Broker{ b := &Broker{
Incoming: make(chan *PendingRequest, 64), Incoming: make(chan *PendingRequest, size),
IncomingResponse: make(chan *PendingResponse, 64), IncomingResponse: make(chan *PendingResponse, size),
} }
b.SetAutoForwardRegex(config.Global.Intercept.AutoForwardRegex) b.SetAutoForwardRegex(config.Global.Intercept.AutoForwardRegex)
return b return b
+12 -4
View File
@@ -182,7 +182,9 @@ func (m *Manager) TogglePlugin(name string) {
configText := found.ConfigText configText := found.ConfigText
found.mu.Unlock() found.mu.Unlock()
if m.db != nil { if m.db != nil {
_ = m.db.SavePluginState(name, enabled, configText) if err := m.db.SavePluginState(name, enabled, configText); err != nil {
log.Printf("plugin %s: save state: %v", name, err)
}
} }
if !enabled { if !enabled {
return return
@@ -195,7 +197,9 @@ func (m *Manager) TogglePlugin(name string) {
if ret == lua.LFalse { if ret == lua.LFalse {
p.Enabled = false p.Enabled = false
if m.db != nil { if m.db != nil {
_ = m.db.SavePluginState(p.Name, false, p.ConfigText) if err := m.db.SavePluginState(p.Name, false, p.ConfigText); err != nil {
log.Printf("plugin %s: save state: %v", p.Name, err)
}
} }
} }
} }
@@ -241,7 +245,9 @@ func (m *Manager) SaveConfig(name, configText string) {
_, hasOnConfig := found.hooks["on_config"] _, hasOnConfig := found.hooks["on_config"]
found.mu.Unlock() found.mu.Unlock()
if m.db != nil { if m.db != nil {
_ = m.db.SavePluginState(name, enabled, configText) if err := m.db.SavePluginState(name, enabled, configText); err != nil {
log.Printf("plugin %s: save state: %v", name, err)
}
} }
if !hasOnConfig { if !hasOnConfig {
return return
@@ -282,7 +288,9 @@ func (m *Manager) RunOnStart() {
if ret == lua.LFalse { if ret == lua.LFalse {
p.Enabled = false p.Enabled = false
if m.db != nil { if m.db != nil {
_ = m.db.SavePluginState(p.Name, false, p.ConfigText) if err := m.db.SavePluginState(p.Name, false, p.ConfigText); err != nil {
log.Printf("plugin %s: save state: %v", p.Name, err)
}
} }
} }
} }
+4 -2
View File
@@ -1,6 +1,7 @@
package proxy package proxy
import ( import (
"crypto/subtle"
"encoding/base64" "encoding/base64"
"fmt" "fmt"
"io" "io"
@@ -46,7 +47,6 @@ func (a *interceptAddon) Request(f *goproxy.Flow) {
switch a.plugins.RunSyncOnRequest(f) { switch a.plugins.RunSyncOnRequest(f) {
case intercept.Drop: case intercept.Drop:
f.Response = dropResponse() f.Response = dropResponse()
go a.plugins.RunAsyncOnRequest(f)
return return
case intercept.Forward: case intercept.Forward:
go a.plugins.RunAsyncOnRequest(f) go a.plugins.RunAsyncOnRequest(f)
@@ -133,7 +133,9 @@ func Start(broker *intercept.Broker, mgr *plugins.Manager) error {
wantUser, wantPass := parts[0], parts[1] wantUser, wantPass := parts[0], parts[1]
p.SetAuthProxy(func(res http.ResponseWriter, req *http.Request) (bool, error) { p.SetAuthProxy(func(res http.ResponseWriter, req *http.Request) (bool, error) {
user, pass, ok := parseBasicProxyAuth(req.Header.Get("Proxy-Authorization")) user, pass, ok := parseBasicProxyAuth(req.Header.Get("Proxy-Authorization"))
if !ok || user != wantUser || pass != wantPass { userOK := subtle.ConstantTimeCompare([]byte(user), []byte(wantUser))
passOK := subtle.ConstantTimeCompare([]byte(pass), []byte(wantPass))
if !ok || userOK&passOK != 1 {
res.Header().Set("Proxy-Authenticate", `Basic realm="spilltea"`) res.Header().Set("Proxy-Authenticate", `Basic realm="spilltea"`)
return false, fmt.Errorf("invalid credentials") return false, fmt.Errorf("invalid credentials")
} }
+18 -6
View File
@@ -28,6 +28,12 @@ type Styles struct {
PagerDotActive string PagerDotActive string
PagerDotInactive string PagerDotInactive string
methodGet lipgloss.Style
methodPost lipgloss.Style
methodPutPatch lipgloss.Style
methodDelete lipgloss.Style
methodDefault lipgloss.Style
} }
var S *Styles var S *Styles
@@ -46,6 +52,7 @@ func Init(cfg *config.Config) {
primary := lipgloss.Color("#" + c.Base0D) // Accent: primary primary := lipgloss.Color("#" + c.Base0D) // Accent: primary
purple := lipgloss.Color("#" + c.Base0E) // Purple: editing purple := lipgloss.Color("#" + c.Base0E) // Purple: editing
methodBase := lipgloss.NewStyle().Bold(true).Width(7)
S = &Styles{ S = &Styles{
Primary: primary, Primary: primary,
Success: success, Success: success,
@@ -74,6 +81,12 @@ func Init(cfg *config.Config) {
PagerDotActive: lipgloss.NewStyle().Foreground(primary).SetString("•").String(), PagerDotActive: lipgloss.NewStyle().Foreground(primary).SetString("•").String(),
PagerDotInactive: lipgloss.NewStyle().Foreground(subtle).SetString("•").String(), PagerDotInactive: lipgloss.NewStyle().Foreground(subtle).SetString("•").String(),
methodGet: methodBase.Foreground(success),
methodPost: methodBase.Foreground(warning),
methodPutPatch: methodBase.Foreground(primary),
methodDelete: methodBase.Foreground(errCol),
methodDefault: methodBase.Foreground(text),
} }
} }
@@ -90,17 +103,16 @@ func NewHelp() help.Model {
} }
func (s *Styles) Method(method string) lipgloss.Style { func (s *Styles) Method(method string) lipgloss.Style {
base := lipgloss.NewStyle().Bold(true).Width(7)
switch method { switch method {
case "GET": case "GET":
return base.Foreground(s.Success) return s.methodGet
case "POST": case "POST":
return base.Foreground(s.Warning) return s.methodPost
case "PUT", "PATCH": case "PUT", "PATCH":
return base.Foreground(s.Primary) return s.methodPutPatch
case "DELETE": case "DELETE":
return base.Foreground(s.Error) return s.methodDelete
default: default:
return base.Foreground(s.Text) return s.methodDefault
} }
} }
+1 -7
View File
@@ -58,13 +58,7 @@ func (m *Model) renderList() string {
) )
} }
start, end := m.pager.GetSliceBounds(len(m.findings)) start, end := util.PageBounds(m.pager, len(m.findings))
if start < 0 {
start = 0
}
if end < start {
end = start
}
var sb strings.Builder var sb strings.Builder
for i, f := range m.findings[start:end] { for i, f := range m.findings[start:end] {
+1 -7
View File
@@ -96,13 +96,7 @@ func (m *Model) renderList() string {
) )
} }
start, end := m.pager.GetSliceBounds(len(m.entries)) start, end := util.PageBounds(m.pager, len(m.entries))
if start < 0 {
start = 0
}
if end < start {
end = start
}
var sb strings.Builder var sb strings.Builder
for i, e := range m.entries[start:end] { for i, e := range m.entries[start:end] {
+2 -14
View File
@@ -109,13 +109,7 @@ func (m *Model) renderList() string {
} }
s := style.S s := style.S
start, end := m.pager.GetSliceBounds(len(m.queue)) start, end := util.PageBounds(m.pager, len(m.queue))
if start < 0 {
start = 0
}
if end < start {
end = start
}
var sb strings.Builder var sb strings.Builder
for i, req := range m.queue[start:end] { for i, req := range m.queue[start:end] {
@@ -165,13 +159,7 @@ func (m *Model) renderResponseList() string {
} }
s := style.S s := style.S
start, end := m.responsePager.GetSliceBounds(len(m.responseQueue)) start, end := util.PageBounds(m.responsePager, len(m.responseQueue))
if start < 0 {
start = 0
}
if end < start {
end = start
}
var sb strings.Builder var sb strings.Builder
for i, resp := range m.responseQueue[start:end] { for i, resp := range m.responseQueue[start:end] {
+1 -7
View File
@@ -143,13 +143,7 @@ func (m *Model) renderList() string {
) )
} }
start, end := m.pager.GetSliceBounds(len(m.filtered)) start, end := util.PageBounds(m.pager, len(m.filtered))
if start < 0 {
start = 0
}
if end < start {
end = start
}
var sb strings.Builder var sb strings.Builder
for i, p := range m.filtered[start:end] { for i, p := range m.filtered[start:end] {
+1 -7
View File
@@ -88,13 +88,7 @@ func (m *Model) renderList() string {
} }
s := style.S s := style.S
start, end := m.pager.GetSliceBounds(len(m.entries)) start, end := util.PageBounds(m.pager, len(m.entries))
if start < 0 {
start = 0
}
if end < start {
end = start
}
var sb strings.Builder var sb strings.Builder
for i, e := range m.entries[start:end] { for i, e := range m.entries[start:end] {
+4 -1
View File
@@ -1,6 +1,7 @@
package util package util
import ( import (
"log"
"os" "os"
"os/exec" "os/exec"
@@ -27,7 +28,9 @@ func OpenExternalEditor(content string) tea.Cmd {
return nil return nil
} }
tmpPath := f.Name() tmpPath := f.Name()
_, _ = f.WriteString(content) if _, err := f.WriteString(content); err != nil {
log.Printf("editor: writing temp file: %v", err)
}
f.Close() f.Close()
return tea.ExecProcess(exec.Command(editor, tmpPath), func(err error) tea.Msg { return tea.ExecProcess(exec.Command(editor, tmpPath), func(err error) tea.Msg {
defer os.Remove(tmpPath) defer os.Remove(tmpPath)
+13
View File
@@ -3,6 +3,7 @@ package util
import ( import (
"strings" "strings"
"charm.land/bubbles/v2/paginator"
"charm.land/lipgloss/v2" "charm.land/lipgloss/v2"
) )
@@ -28,6 +29,18 @@ func CenterLines(lines ...string) string {
return strings.Join(centered, "\n") return strings.Join(centered, "\n")
} }
// PageBounds returns clamped start/end indices for rendering a paginated list.
func PageBounds(p paginator.Model, total int) (start, end int) {
start, end = p.GetSliceBounds(total)
if start < 0 {
start = 0
}
if end < start {
end = start
}
return
}
// InferScheme returns "http" for port 80, "https" otherwise. // InferScheme returns "http" for port 80, "https" otherwise.
func InferScheme(host string) string { func InferScheme(host string) string {
if strings.HasSuffix(host, ":80") { if strings.HasSuffix(host, ":80") {
+1 -3
View File
@@ -15,9 +15,7 @@ Findings are deduplicated per host+path+body content so repeated requests do not
} }
function on_start() function on_start()
local handle = io.popen("command -v trufflehog 2>/dev/null") local result, _ = shell_pipe("command -v trufflehog 2>/dev/null")
local result = handle and handle:read("*a") or ""
if handle then handle:close() end
if not result or result:match("^%s*$") then if not result or result:match("^%s*$") then
log("trufflehog is not installed or not in PATH") log("trufflehog is not installed or not in PATH")
notif("TruffleHog", "trufflehog is not installed or not in PATH, plugin disabled", "error") notif("TruffleHog", "trufflehog is not installed or not in PATH, plugin disabled", "error")