mirror of
https://github.com/anotherhadi/spilltea.git
synced 2026-05-20 17:52:33 +02:00
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:
@@ -41,6 +41,7 @@ type Config struct {
|
||||
DefaultInterceptEnabled bool `mapstructure:"default_intercept_enabled"`
|
||||
DefaultCaptureResponse bool `mapstructure:"default_capture_response"`
|
||||
AutoForwardRegex []string `mapstructure:"auto_forward_regex"`
|
||||
QueueSize int `mapstructure:"queue_size"`
|
||||
} `mapstructure:"intercept"`
|
||||
|
||||
Replay struct {
|
||||
@@ -82,7 +83,7 @@ func WriteDefaultConfig(path string) error {
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
|
||||
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 nil
|
||||
|
||||
@@ -5,13 +5,14 @@ app:
|
||||
project_dir: ~/.local/share/spilltea
|
||||
plugins_dir: ~/.config/spilltea/plugins
|
||||
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)
|
||||
external_editor: "" # override $EDITOR for external editing (e.g. nvim, code --wait)
|
||||
|
||||
intercept:
|
||||
default_intercept_enabled: true
|
||||
default_capture_response: false
|
||||
queue_size: 64 # max pending intercepted requests/responses before the proxy blocks
|
||||
auto_forward_regex:
|
||||
- '\.(js|css|png|gif|ico|woff2?|ttf|svg)(\?.*)?$'
|
||||
|
||||
|
||||
@@ -9,6 +9,7 @@ import (
|
||||
|
||||
type DB struct {
|
||||
conn *sql.DB
|
||||
roConn *sql.DB
|
||||
path string
|
||||
dedupMu sync.Mutex
|
||||
}
|
||||
@@ -27,6 +28,17 @@ func Open(path string) (*DB, error) {
|
||||
conn.Close()
|
||||
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
|
||||
}
|
||||
|
||||
@@ -94,6 +106,7 @@ func (d *DB) Close() error {
|
||||
if d == nil {
|
||||
return nil
|
||||
}
|
||||
_ = d.roConn.Close()
|
||||
return d.conn.Close()
|
||||
}
|
||||
|
||||
|
||||
+9
-12
@@ -4,6 +4,7 @@ import (
|
||||
"crypto/sha256"
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"log"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
@@ -63,7 +64,11 @@ func (d *DB) InsertEntry(e Entry, body string) (Entry, error) {
|
||||
if err != nil {
|
||||
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
|
||||
}
|
||||
|
||||
@@ -113,19 +118,11 @@ func (d *DB) SearchEntries(term string) ([]Entry, error) {
|
||||
|
||||
// QueryEntries runs a WHERE expression supplied by the user against the entries
|
||||
// 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
|
||||
// user-supplied expression is rejected by SQLite before it can execute.
|
||||
// Uses the persistent read-only connection (PRAGMA query_only=ON) so that any
|
||||
// DML or DDL in the user-supplied expression is rejected by SQLite before it executes.
|
||||
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)
|
||||
rows, err := roConn.Query(q)
|
||||
rows, err := d.roConn.Query(q)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -58,9 +58,13 @@ func (b *Broker) SetOnNewEntry(cb func(db.Entry)) {
|
||||
}
|
||||
|
||||
func NewBroker() *Broker {
|
||||
size := config.Global.Intercept.QueueSize
|
||||
if size <= 0 {
|
||||
size = 64
|
||||
}
|
||||
b := &Broker{
|
||||
Incoming: make(chan *PendingRequest, 64),
|
||||
IncomingResponse: make(chan *PendingResponse, 64),
|
||||
Incoming: make(chan *PendingRequest, size),
|
||||
IncomingResponse: make(chan *PendingResponse, size),
|
||||
}
|
||||
b.SetAutoForwardRegex(config.Global.Intercept.AutoForwardRegex)
|
||||
return b
|
||||
|
||||
@@ -182,7 +182,9 @@ func (m *Manager) TogglePlugin(name string) {
|
||||
configText := found.ConfigText
|
||||
found.mu.Unlock()
|
||||
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 {
|
||||
return
|
||||
@@ -195,7 +197,9 @@ func (m *Manager) TogglePlugin(name string) {
|
||||
if ret == lua.LFalse {
|
||||
p.Enabled = false
|
||||
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"]
|
||||
found.mu.Unlock()
|
||||
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 {
|
||||
return
|
||||
@@ -282,7 +288,9 @@ func (m *Manager) RunOnStart() {
|
||||
if ret == lua.LFalse {
|
||||
p.Enabled = false
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package proxy
|
||||
|
||||
import (
|
||||
"crypto/subtle"
|
||||
"encoding/base64"
|
||||
"fmt"
|
||||
"io"
|
||||
@@ -46,7 +47,6 @@ func (a *interceptAddon) Request(f *goproxy.Flow) {
|
||||
switch a.plugins.RunSyncOnRequest(f) {
|
||||
case intercept.Drop:
|
||||
f.Response = dropResponse()
|
||||
go a.plugins.RunAsyncOnRequest(f)
|
||||
return
|
||||
case intercept.Forward:
|
||||
go a.plugins.RunAsyncOnRequest(f)
|
||||
@@ -133,7 +133,9 @@ func Start(broker *intercept.Broker, mgr *plugins.Manager) error {
|
||||
wantUser, wantPass := parts[0], parts[1]
|
||||
p.SetAuthProxy(func(res http.ResponseWriter, req *http.Request) (bool, error) {
|
||||
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"`)
|
||||
return false, fmt.Errorf("invalid credentials")
|
||||
}
|
||||
|
||||
+18
-6
@@ -28,6 +28,12 @@ type Styles struct {
|
||||
|
||||
PagerDotActive string
|
||||
PagerDotInactive string
|
||||
|
||||
methodGet lipgloss.Style
|
||||
methodPost lipgloss.Style
|
||||
methodPutPatch lipgloss.Style
|
||||
methodDelete lipgloss.Style
|
||||
methodDefault lipgloss.Style
|
||||
}
|
||||
|
||||
var S *Styles
|
||||
@@ -46,6 +52,7 @@ func Init(cfg *config.Config) {
|
||||
primary := lipgloss.Color("#" + c.Base0D) // Accent: primary
|
||||
purple := lipgloss.Color("#" + c.Base0E) // Purple: editing
|
||||
|
||||
methodBase := lipgloss.NewStyle().Bold(true).Width(7)
|
||||
S = &Styles{
|
||||
Primary: primary,
|
||||
Success: success,
|
||||
@@ -74,6 +81,12 @@ func Init(cfg *config.Config) {
|
||||
|
||||
PagerDotActive: lipgloss.NewStyle().Foreground(primary).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 {
|
||||
base := lipgloss.NewStyle().Bold(true).Width(7)
|
||||
switch method {
|
||||
case "GET":
|
||||
return base.Foreground(s.Success)
|
||||
return s.methodGet
|
||||
case "POST":
|
||||
return base.Foreground(s.Warning)
|
||||
return s.methodPost
|
||||
case "PUT", "PATCH":
|
||||
return base.Foreground(s.Primary)
|
||||
return s.methodPutPatch
|
||||
case "DELETE":
|
||||
return base.Foreground(s.Error)
|
||||
return s.methodDelete
|
||||
default:
|
||||
return base.Foreground(s.Text)
|
||||
return s.methodDefault
|
||||
}
|
||||
}
|
||||
|
||||
@@ -58,13 +58,7 @@ func (m *Model) renderList() string {
|
||||
)
|
||||
}
|
||||
|
||||
start, end := m.pager.GetSliceBounds(len(m.findings))
|
||||
if start < 0 {
|
||||
start = 0
|
||||
}
|
||||
if end < start {
|
||||
end = start
|
||||
}
|
||||
start, end := util.PageBounds(m.pager, len(m.findings))
|
||||
|
||||
var sb strings.Builder
|
||||
for i, f := range m.findings[start:end] {
|
||||
|
||||
@@ -96,13 +96,7 @@ func (m *Model) renderList() string {
|
||||
)
|
||||
}
|
||||
|
||||
start, end := m.pager.GetSliceBounds(len(m.entries))
|
||||
if start < 0 {
|
||||
start = 0
|
||||
}
|
||||
if end < start {
|
||||
end = start
|
||||
}
|
||||
start, end := util.PageBounds(m.pager, len(m.entries))
|
||||
|
||||
var sb strings.Builder
|
||||
for i, e := range m.entries[start:end] {
|
||||
|
||||
@@ -109,13 +109,7 @@ func (m *Model) renderList() string {
|
||||
}
|
||||
|
||||
s := style.S
|
||||
start, end := m.pager.GetSliceBounds(len(m.queue))
|
||||
if start < 0 {
|
||||
start = 0
|
||||
}
|
||||
if end < start {
|
||||
end = start
|
||||
}
|
||||
start, end := util.PageBounds(m.pager, len(m.queue))
|
||||
|
||||
var sb strings.Builder
|
||||
for i, req := range m.queue[start:end] {
|
||||
@@ -165,13 +159,7 @@ func (m *Model) renderResponseList() string {
|
||||
}
|
||||
|
||||
s := style.S
|
||||
start, end := m.responsePager.GetSliceBounds(len(m.responseQueue))
|
||||
if start < 0 {
|
||||
start = 0
|
||||
}
|
||||
if end < start {
|
||||
end = start
|
||||
}
|
||||
start, end := util.PageBounds(m.responsePager, len(m.responseQueue))
|
||||
|
||||
var sb strings.Builder
|
||||
for i, resp := range m.responseQueue[start:end] {
|
||||
|
||||
@@ -143,13 +143,7 @@ func (m *Model) renderList() string {
|
||||
)
|
||||
}
|
||||
|
||||
start, end := m.pager.GetSliceBounds(len(m.filtered))
|
||||
if start < 0 {
|
||||
start = 0
|
||||
}
|
||||
if end < start {
|
||||
end = start
|
||||
}
|
||||
start, end := util.PageBounds(m.pager, len(m.filtered))
|
||||
|
||||
var sb strings.Builder
|
||||
for i, p := range m.filtered[start:end] {
|
||||
|
||||
@@ -88,13 +88,7 @@ func (m *Model) renderList() string {
|
||||
}
|
||||
|
||||
s := style.S
|
||||
start, end := m.pager.GetSliceBounds(len(m.entries))
|
||||
if start < 0 {
|
||||
start = 0
|
||||
}
|
||||
if end < start {
|
||||
end = start
|
||||
}
|
||||
start, end := util.PageBounds(m.pager, len(m.entries))
|
||||
|
||||
var sb strings.Builder
|
||||
for i, e := range m.entries[start:end] {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package util
|
||||
|
||||
import (
|
||||
"log"
|
||||
"os"
|
||||
"os/exec"
|
||||
|
||||
@@ -27,7 +28,9 @@ func OpenExternalEditor(content string) tea.Cmd {
|
||||
return nil
|
||||
}
|
||||
tmpPath := f.Name()
|
||||
_, _ = f.WriteString(content)
|
||||
if _, err := f.WriteString(content); err != nil {
|
||||
log.Printf("editor: writing temp file: %v", err)
|
||||
}
|
||||
f.Close()
|
||||
return tea.ExecProcess(exec.Command(editor, tmpPath), func(err error) tea.Msg {
|
||||
defer os.Remove(tmpPath)
|
||||
|
||||
@@ -3,6 +3,7 @@ package util
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"charm.land/bubbles/v2/paginator"
|
||||
"charm.land/lipgloss/v2"
|
||||
)
|
||||
|
||||
@@ -28,6 +29,18 @@ func CenterLines(lines ...string) string {
|
||||
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.
|
||||
func InferScheme(host string) string {
|
||||
if strings.HasSuffix(host, ":80") {
|
||||
|
||||
@@ -15,9 +15,7 @@ Findings are deduplicated per host+path+body content so repeated requests do not
|
||||
}
|
||||
|
||||
function on_start()
|
||||
local handle = io.popen("command -v trufflehog 2>/dev/null")
|
||||
local result = handle and handle:read("*a") or ""
|
||||
if handle then handle:close() end
|
||||
local result, _ = shell_pipe("command -v trufflehog 2>/dev/null")
|
||||
if not result or result:match("^%s*$") then
|
||||
log("trufflehog is not installed or not in PATH")
|
||||
notif("TruffleHog", "trufflehog is not installed or not in PATH, plugin disabled", "error")
|
||||
|
||||
Reference in New Issue
Block a user