5 Commits

16 changed files with 206 additions and 19 deletions
+23
View File
@@ -123,6 +123,26 @@ else
log("output: " .. out) log("output: " .. out)
end end
-- Send an HTTP request directly (bypasses the proxy pipeline and other plugins).
-- method: HTTP method ("GET", "POST", etc.)
-- url: full URL to request
-- headers: table of request headers (optional, pass {} if unused)
-- body: request body string (optional, pass "" if unused)
-- options: optional table of options:
-- insecure = true skip TLS certificate verification
-- Returns: response table, error string (nil on success).
local res, err = send_request("POST", "https://example.com/api", {
["Authorization"] = "Bearer " .. token,
["Content-Type"] = "application/json",
}, '{"key":"value"}', { insecure = true })
if err then
log("request failed: " .. err)
else
log(tostring(res.status_code))
log(res.body)
log(res.headers["Content-Type"] or "")
end
-- Return the plugin's config section as a Lua table (parsed from YAML). -- Return the plugin's config section as a Lua table (parsed from YAML).
-- Returns an empty table if no config is set. -- Returns an empty table if no config is set.
local cfg = get_config() local cfg = get_config()
@@ -154,6 +174,9 @@ plugins:
The config block is edited from the **Plugins** page in the TUI. The config block is edited from the **Plugins** page in the TUI.
Inside a plugin, call `get_config()` to retrieve the config as a Lua table. Inside a plugin, call `get_config()` to retrieve the config as a Lua table.
Global defaults for any plugin can be set in `~/.config/spilltea/config.yaml` under a `plugins` key with the same structure as `plugins.yaml`.
These defaults are applied the first time a plugin is loaded in a project; once the plugin has an entry in the project's `plugins.yaml`, the project config takes full precedence and the global defaults are ignored.
`on_config()` is called once at startup (before `on_start`) and again every time the user saves the config in the TUI. `on_config()` is called once at startup (before `on_start`) and again every time the user saves the config in the TUI.
It is the right place to read `get_config()` and populate local variables. It is the right place to read `get_config()` and populate local variables.
+7
View File
@@ -54,6 +54,13 @@ type Config struct {
} `mapstructure:"history"` } `mapstructure:"history"`
Keybindings Keybindings `mapstructure:"keybindings"` Keybindings Keybindings `mapstructure:"keybindings"`
Plugins map[string]GlobalPlugin `mapstructure:"plugins"`
}
type GlobalPlugin struct {
Enable *bool `mapstructure:"enable"`
Config interface{} `mapstructure:"config"`
} }
var Global *Config var Global *Config
+15
View File
@@ -144,3 +144,18 @@ func (d *DB) DeleteAllEntries() error {
_, err := d.conn.Exec(`DELETE FROM entries`) _, err := d.conn.Exec(`DELETE FROM entries`)
return err return err
} }
// DeleteAllExceptFlagged deletes all unflagged entries. If there are no
// unflagged entries (only flagged ones remain), it deletes everything.
func (d *DB) DeleteAllExceptFlagged() error {
var count int
if err := d.conn.QueryRow(`SELECT COUNT(*) FROM entries WHERE flagged = 0`).Scan(&count); err != nil {
return err
}
if count > 0 {
_, err := d.conn.Exec(`DELETE FROM entries WHERE flagged = 0`)
return err
}
_, err := d.conn.Exec(`DELETE FROM entries`)
return err
}
+76
View File
@@ -3,12 +3,16 @@ package plugins
import ( import (
"bytes" "bytes"
"context" "context"
"crypto/tls"
"io"
"log" "log"
"net/http"
"net/url" "net/url"
"os/exec" "os/exec"
"strings" "strings"
"time" "time"
"github.com/anotherhadi/spilltea/internal/config"
"github.com/anotherhadi/spilltea/internal/db" "github.com/anotherhadi/spilltea/internal/db"
goproxy "github.com/lqqyt2423/go-mitmproxy/proxy" goproxy "github.com/lqqyt2423/go-mitmproxy/proxy"
lua "github.com/yuin/gopher-lua" lua "github.com/yuin/gopher-lua"
@@ -198,6 +202,78 @@ func registerUtilities(L *lua.LState, mgr *Manager, p *Plugin) {
return 1 return 1
})) }))
L.SetGlobal("send_request", L.NewFunction(func(L *lua.LState) int {
method := L.CheckString(1)
rawURL := L.CheckString(2)
hdrs := L.OptTable(3, L.NewTable())
body := L.OptString(4, "")
opts := L.OptTable(5, L.NewTable())
insecure := false
if v, ok := L.GetField(opts, "insecure").(lua.LBool); ok {
insecure = bool(v)
}
transport := &http.Transport{
TLSClientConfig: &tls.Config{InsecureSkipVerify: insecure},
}
if up := config.Global.App.UpstreamProxy; up != "" {
if proxyURL, err := url.Parse(up); err == nil {
transport.Proxy = http.ProxyURL(proxyURL)
}
}
client := &http.Client{
Transport: transport,
Timeout: 30 * time.Second,
}
var bodyReader io.Reader
if body != "" {
bodyReader = strings.NewReader(body)
}
req, err := http.NewRequest(method, rawURL, bodyReader)
if err != nil {
L.Push(lua.LNil)
L.Push(lua.LString(err.Error()))
return 2
}
hdrs.ForEach(func(k, v lua.LValue) {
ks, kok := k.(lua.LString)
vs, vok := v.(lua.LString)
if kok && vok {
req.Header.Set(string(ks), string(vs))
}
})
resp, err := client.Do(req)
if err != nil {
L.Push(lua.LNil)
L.Push(lua.LString(err.Error()))
return 2
}
defer resp.Body.Close()
respBody, err := io.ReadAll(resp.Body)
if err != nil {
L.Push(lua.LNil)
L.Push(lua.LString(err.Error()))
return 2
}
result := L.NewTable()
L.SetField(result, "status_code", lua.LNumber(resp.StatusCode))
L.SetField(result, "body", lua.LString(string(respBody)))
respHeaders := L.NewTable()
for k, vals := range resp.Header {
L.SetField(respHeaders, k, lua.LString(strings.Join(vals, ", ")))
}
L.SetField(result, "headers", respHeaders)
L.Push(result)
L.Push(lua.LNil)
return 2
}))
L.SetGlobal("shell_pipe", L.NewFunction(func(L *lua.LState) int { L.SetGlobal("shell_pipe", L.NewFunction(func(L *lua.LState) int {
cmd := L.CheckString(1) cmd := L.CheckString(1)
input := L.OptString(2, "") input := L.OptString(2, "")
+26 -4
View File
@@ -13,15 +13,23 @@ import (
"github.com/anotherhadi/spilltea/internal/intercept" "github.com/anotherhadi/spilltea/internal/intercept"
goproxy "github.com/lqqyt2423/go-mitmproxy/proxy" goproxy "github.com/lqqyt2423/go-mitmproxy/proxy"
lua "github.com/yuin/gopher-lua" lua "github.com/yuin/gopher-lua"
"gopkg.in/yaml.v3"
) )
// GlobalDefault holds global defaults for a single plugin, read from config.yaml.
type GlobalDefault struct {
Enable *bool
Config interface{}
}
type Manager struct { type Manager struct {
mu sync.RWMutex mu sync.RWMutex
plugins []*Plugin plugins []*Plugin
db *db.DB db *db.DB
pluginsFile *PluginsFile pluginsFile *PluginsFile
broker *intercept.Broker broker *intercept.Broker
globalDefaults map[string]GlobalDefault
Notifs chan PluginNotifMsg Notifs chan PluginNotifMsg
Quit chan string Quit chan string
@@ -48,6 +56,10 @@ func (m *Manager) SetPluginsFile(pf *PluginsFile) {
m.pluginsFile = pf m.pluginsFile = pf
} }
func (m *Manager) SetGlobalDefaults(defaults map[string]GlobalDefault) {
m.globalDefaults = defaults
}
func (m *Manager) LoadFromDir(dir string) error { func (m *Manager) LoadFromDir(dir string) error {
entries, err := os.ReadDir(dir) entries, err := os.ReadDir(dir)
if os.IsNotExist(err) { if os.IsNotExist(err) {
@@ -72,7 +84,17 @@ func (m *Manager) LoadFromDir(dir string) error {
p.Enabled = enabled p.Enabled = enabled
p.ConfigText = configText p.ConfigText = configText
} else { } else {
m.pluginsFile.register(p.ID, p.Enabled) if def, ok := m.globalDefaults[p.ID]; ok {
if def.Enable != nil {
p.Enabled = *def.Enable
}
if def.Config != nil {
if raw, err := yaml.Marshal(def.Config); err == nil {
p.ConfigText = string(raw)
}
}
}
m.pluginsFile.register(p.ID, p.Enabled, p.ConfigText)
} }
} }
m.mu.Lock() m.mu.Lock()
+9 -2
View File
@@ -66,9 +66,16 @@ func (pf *PluginsFile) get(id string) (enabled bool, config string, found bool)
return e.Enable, string(raw), true return e.Enable, string(raw), true
} }
func (pf *PluginsFile) register(id string, defaultEnabled bool) { func (pf *PluginsFile) register(id string, defaultEnabled bool, configText string) {
if _, ok := pf.data.Plugins[id]; !ok { if _, ok := pf.data.Plugins[id]; !ok {
pf.data.Plugins[id] = pluginFileEntry{Enable: defaultEnabled} entry := pluginFileEntry{Enable: defaultEnabled}
if configText != "" {
var parsed interface{}
if err := yaml.Unmarshal([]byte(configText), &parsed); err == nil {
entry.Config = parsed
}
}
pf.data.Plugins[id] = entry
_ = pf.save() _ = pf.save()
} }
} }
+8
View File
@@ -112,6 +112,14 @@ func New(broker *intercept.Broker, name, path string) Model {
} }
mgr.SetPluginsFile(pf) mgr.SetPluginsFile(pf)
if len(cfg.Plugins) > 0 {
defaults := make(map[string]plugins.GlobalDefault, len(cfg.Plugins))
for id, gp := range cfg.Plugins {
defaults[id] = plugins.GlobalDefault{Enable: gp.Enable, Config: gp.Config}
}
mgr.SetGlobalDefaults(defaults)
}
pluginsDir := config.ExpandPath(cfg.App.PluginsDir) pluginsDir := config.ExpandPath(cfg.App.PluginsDir)
if err := mgr.LoadFromDir(pluginsDir); err != nil { if err := mgr.LoadFromDir(pluginsDir); err != nil {
log.Printf("plugins: %v", err) log.Printf("plugins: %v", err)
+11 -1
View File
@@ -249,11 +249,21 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
case key.Matches(msg, h.DeleteAll): case key.Matches(msg, h.DeleteAll):
if m.database != nil { if m.database != nil {
if m.searchKind != searchKindOff { if m.searchKind != searchKindOff {
hasUnflagged := false
for _, e := range m.entries { for _, e := range m.entries {
if !e.Flagged {
hasUnflagged = true
break
}
}
for _, e := range m.entries {
if hasUnflagged && e.Flagged {
continue
}
m.database.DeleteEntry(e.ID) m.database.DeleteEntry(e.ID)
} }
} else { } else {
m.database.DeleteAllEntries() m.database.DeleteAllExceptFlagged()
} }
} }
return m, m.clearSearch() return m, m.clearSearch()
+3
View File
@@ -181,6 +181,9 @@ func (m Model) updateNormalMode(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) {
case key.Matches(msg, r.EditExt): case key.Matches(msg, r.EditExt):
if len(m.entries) > 0 { if len(m.entries) > 0 {
if m.focusedPanel == panelResponse {
return m, util.OpenExternalEditorView(m.entries[m.cursor].ResponseRaw)
}
return m, util.OpenExternalEditor(m.entries[m.cursor].RequestRaw) return m, util.OpenExternalEditor(m.entries[m.cursor].RequestRaw)
} }
+18 -2
View File
@@ -15,7 +15,7 @@ type EditorFinishedMsg struct {
Err error Err error
} }
func OpenExternalEditor(content string) tea.Cmd { func resolveEditor() string {
editor := config.Global.App.ExternalEditor editor := config.Global.App.ExternalEditor
if editor == "" { if editor == "" {
editor = os.Getenv("EDITOR") editor = os.Getenv("EDITOR")
@@ -23,6 +23,10 @@ func OpenExternalEditor(content string) tea.Cmd {
if editor == "" { if editor == "" {
editor = "vi" editor = "vi"
} }
return editor
}
func openWithEditor(content string, callback func(string, error) tea.Msg) tea.Cmd {
f, err := os.CreateTemp("", "spilltea-*.http") f, err := os.CreateTemp("", "spilltea-*.http")
if err != nil { if err != nil {
return nil return nil
@@ -32,8 +36,14 @@ func OpenExternalEditor(content string) tea.Cmd {
log.Printf("editor: writing temp file: %v", err) 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(resolveEditor(), tmpPath), func(err error) tea.Msg {
defer os.Remove(tmpPath) defer os.Remove(tmpPath)
return callback(tmpPath, err)
})
}
func OpenExternalEditor(content string) tea.Cmd {
return openWithEditor(content, func(tmpPath string, err error) tea.Msg {
if err != nil { if err != nil {
return EditorFinishedMsg{Err: err} return EditorFinishedMsg{Err: err}
} }
@@ -44,3 +54,9 @@ func OpenExternalEditor(content string) tea.Cmd {
return EditorFinishedMsg{Content: string(data)} return EditorFinishedMsg{Content: string(data)}
}) })
} }
func OpenExternalEditorView(content string) tea.Cmd {
return openWithEditor(content, func(_ string, _ error) tea.Msg {
return nil
})
}
+1 -1
View File
@@ -3,7 +3,7 @@ Plugin = {
description = [[ description = [[
Inject custom headers into every intercepted request. Inject custom headers into every intercepted request.
**Config** (YAML): **Config**:
```yaml ```yaml
headers: headers:
- "X-My-Header: myvalue" - "X-My-Header: myvalue"
+2 -1
View File
@@ -3,12 +3,13 @@ Plugin = {
description = [[ description = [[
Checks that the proxy's outbound IP is in an allowed list on startup. Checks that the proxy's outbound IP is in an allowed list on startup.
**Config** (YAML): **Config**:
```yaml ```yaml
ips: ips:
- "1.2.3.4" # whitelist entry - "1.2.3.4" # whitelist entry
- "!5.6.7.8" # blacklist entry (blocked) - "!5.6.7.8" # blacklist entry (blocked)
``` ```
- If no IPs are configured, the check is skipped. - If no IPs are configured, the check is skipped.
]], ]],
on_start = { sync = false }, on_start = { sync = false },
+1 -1
View File
@@ -3,7 +3,7 @@ Plugin = {
description = [[ description = [[
Automatically find and replace content in requests and responses. Automatically find and replace content in requests and responses.
**Config** (YAML): **Config**:
```yaml ```yaml
rules: rules:
- on: "request" # "request", "response", or "both" (default: "both") - on: "request" # "request", "response", or "both" (default: "both")
+1 -1
View File
@@ -3,7 +3,7 @@ Plugin = {
description = [[ description = [[
Auto-forward requests and exclude them from history based on patterns. Auto-forward requests and exclude them from history based on patterns.
**Config** (YAML): **Config**:
```yaml ```yaml
patterns: patterns:
- "pattern" # whitelist: only intercept matching requests/responses and history - "pattern" # whitelist: only intercept matching requests/responses and history
+4 -2
View File
@@ -185,9 +185,11 @@ local function scan(label, ct, body, host, path)
title = "Potential secret in " .. label .. " (" .. host .. ")", title = "Potential secret in " .. label .. " (" .. host .. ")",
description = "**Host:** `" description = "**Host:** `"
.. host .. host
.. "` \n**Path:** `" .. "`\n"
.. "\n**Path:** `"
.. path .. path
.. "`\n\n**Match:** `" .. "`\n"
.. "\n**Match:** `"
.. display .. display
.. "`\n\n" .. "`\n\n"
.. ctx, .. ctx,
+1 -4
View File
@@ -4,9 +4,6 @@ Plugin = {
Scans request and response bodies for secrets using [TruffleHog](https://github.com/trufflesecurity/trufflehog). Scans request and response bodies for secrets using [TruffleHog](https://github.com/trufflesecurity/trufflehog).
Requires `trufflehog` v3+ to be installed and available in PATH. Requires `trufflehog` v3+ to be installed and available in PATH.
Each finding is stored on the **Findings** page with the matched detector output.
Findings are deduplicated per host+path+body content so repeated requests do not create duplicates.
]], ]],
on_start = { sync = false }, on_start = { sync = false },
on_request = { sync = false }, on_request = { sync = false },
@@ -56,7 +53,7 @@ local function scan(label, content, host, path)
for _, block in ipairs(blocks) do for _, block in ipairs(blocks) do
create_finding({ create_finding({
title = "Secret detected in " .. label .. " (" .. host .. ")", title = "Secret detected in " .. label .. " (" .. host .. ")",
description = "**Host:** `" .. host .. "` \n**Path:** `" .. path .. "`\n\n```\n" .. block .. "\n```", description = "**Host:** `" .. host .. "`\n\n**Path:** `" .. path .. "`\n\n```\n" .. block .. "\n```",
key = host .. "|" .. path .. "|" .. label .. "|" .. block, key = host .. "|" .. path .. "|" .. label .. "|" .. block,
severity = "high", severity = "high",
}) })