mirror of
https://github.com/anotherhadi/spilltea.git
synced 2026-07-06 20:42:33 +02:00
Compare commits
5 Commits
6af1388652
...
5841ed0a95
| Author | SHA1 | Date | |
|---|---|---|---|
| 5841ed0a95 | |||
| 9cabe81771 | |||
| 021090f52c | |||
| 0b9e1a1cf0 | |||
| f60cdf2033 |
@@ -123,6 +123,26 @@ else
|
||||
log("output: " .. out)
|
||||
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).
|
||||
-- Returns an empty table if no config is set.
|
||||
local cfg = get_config()
|
||||
@@ -154,6 +174,9 @@ plugins:
|
||||
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.
|
||||
|
||||
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.
|
||||
It is the right place to read `get_config()` and populate local variables.
|
||||
|
||||
|
||||
@@ -54,6 +54,13 @@ type Config struct {
|
||||
} `mapstructure:"history"`
|
||||
|
||||
Keybindings Keybindings `mapstructure:"keybindings"`
|
||||
|
||||
Plugins map[string]GlobalPlugin `mapstructure:"plugins"`
|
||||
}
|
||||
|
||||
type GlobalPlugin struct {
|
||||
Enable *bool `mapstructure:"enable"`
|
||||
Config interface{} `mapstructure:"config"`
|
||||
}
|
||||
|
||||
var Global *Config
|
||||
|
||||
@@ -144,3 +144,18 @@ func (d *DB) DeleteAllEntries() error {
|
||||
_, err := d.conn.Exec(`DELETE FROM entries`)
|
||||
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
|
||||
}
|
||||
|
||||
@@ -3,12 +3,16 @@ package plugins
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/tls"
|
||||
"io"
|
||||
"log"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os/exec"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/anotherhadi/spilltea/internal/config"
|
||||
"github.com/anotherhadi/spilltea/internal/db"
|
||||
goproxy "github.com/lqqyt2423/go-mitmproxy/proxy"
|
||||
lua "github.com/yuin/gopher-lua"
|
||||
@@ -198,6 +202,78 @@ func registerUtilities(L *lua.LState, mgr *Manager, p *Plugin) {
|
||||
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 {
|
||||
cmd := L.CheckString(1)
|
||||
input := L.OptString(2, "")
|
||||
|
||||
@@ -13,15 +13,23 @@ import (
|
||||
"github.com/anotherhadi/spilltea/internal/intercept"
|
||||
goproxy "github.com/lqqyt2423/go-mitmproxy/proxy"
|
||||
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 {
|
||||
mu sync.RWMutex
|
||||
plugins []*Plugin
|
||||
|
||||
db *db.DB
|
||||
pluginsFile *PluginsFile
|
||||
broker *intercept.Broker
|
||||
db *db.DB
|
||||
pluginsFile *PluginsFile
|
||||
broker *intercept.Broker
|
||||
globalDefaults map[string]GlobalDefault
|
||||
|
||||
Notifs chan PluginNotifMsg
|
||||
Quit chan string
|
||||
@@ -48,6 +56,10 @@ func (m *Manager) SetPluginsFile(pf *PluginsFile) {
|
||||
m.pluginsFile = pf
|
||||
}
|
||||
|
||||
func (m *Manager) SetGlobalDefaults(defaults map[string]GlobalDefault) {
|
||||
m.globalDefaults = defaults
|
||||
}
|
||||
|
||||
func (m *Manager) LoadFromDir(dir string) error {
|
||||
entries, err := os.ReadDir(dir)
|
||||
if os.IsNotExist(err) {
|
||||
@@ -72,7 +84,17 @@ func (m *Manager) LoadFromDir(dir string) error {
|
||||
p.Enabled = enabled
|
||||
p.ConfigText = configText
|
||||
} 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()
|
||||
|
||||
@@ -66,9 +66,16 @@ func (pf *PluginsFile) get(id string) (enabled bool, config string, found bool)
|
||||
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 {
|
||||
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()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -112,6 +112,14 @@ func New(broker *intercept.Broker, name, path string) Model {
|
||||
}
|
||||
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)
|
||||
if err := mgr.LoadFromDir(pluginsDir); err != nil {
|
||||
log.Printf("plugins: %v", err)
|
||||
|
||||
@@ -249,11 +249,21 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
case key.Matches(msg, h.DeleteAll):
|
||||
if m.database != nil {
|
||||
if m.searchKind != searchKindOff {
|
||||
hasUnflagged := false
|
||||
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)
|
||||
}
|
||||
} else {
|
||||
m.database.DeleteAllEntries()
|
||||
m.database.DeleteAllExceptFlagged()
|
||||
}
|
||||
}
|
||||
return m, m.clearSearch()
|
||||
|
||||
@@ -181,6 +181,9 @@ func (m Model) updateNormalMode(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) {
|
||||
|
||||
case key.Matches(msg, r.EditExt):
|
||||
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)
|
||||
}
|
||||
|
||||
|
||||
+18
-2
@@ -15,7 +15,7 @@ type EditorFinishedMsg struct {
|
||||
Err error
|
||||
}
|
||||
|
||||
func OpenExternalEditor(content string) tea.Cmd {
|
||||
func resolveEditor() string {
|
||||
editor := config.Global.App.ExternalEditor
|
||||
if editor == "" {
|
||||
editor = os.Getenv("EDITOR")
|
||||
@@ -23,6 +23,10 @@ func OpenExternalEditor(content string) tea.Cmd {
|
||||
if editor == "" {
|
||||
editor = "vi"
|
||||
}
|
||||
return editor
|
||||
}
|
||||
|
||||
func openWithEditor(content string, callback func(string, error) tea.Msg) tea.Cmd {
|
||||
f, err := os.CreateTemp("", "spilltea-*.http")
|
||||
if err != nil {
|
||||
return nil
|
||||
@@ -32,8 +36,14 @@ func OpenExternalEditor(content string) tea.Cmd {
|
||||
log.Printf("editor: writing temp file: %v", err)
|
||||
}
|
||||
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)
|
||||
return callback(tmpPath, err)
|
||||
})
|
||||
}
|
||||
|
||||
func OpenExternalEditor(content string) tea.Cmd {
|
||||
return openWithEditor(content, func(tmpPath string, err error) tea.Msg {
|
||||
if err != nil {
|
||||
return EditorFinishedMsg{Err: err}
|
||||
}
|
||||
@@ -44,3 +54,9 @@ func OpenExternalEditor(content string) tea.Cmd {
|
||||
return EditorFinishedMsg{Content: string(data)}
|
||||
})
|
||||
}
|
||||
|
||||
func OpenExternalEditorView(content string) tea.Cmd {
|
||||
return openWithEditor(content, func(_ string, _ error) tea.Msg {
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@ Plugin = {
|
||||
description = [[
|
||||
Inject custom headers into every intercepted request.
|
||||
|
||||
**Config** (YAML):
|
||||
**Config**:
|
||||
```yaml
|
||||
headers:
|
||||
- "X-My-Header: myvalue"
|
||||
|
||||
@@ -3,12 +3,13 @@ Plugin = {
|
||||
description = [[
|
||||
Checks that the proxy's outbound IP is in an allowed list on startup.
|
||||
|
||||
**Config** (YAML):
|
||||
**Config**:
|
||||
```yaml
|
||||
ips:
|
||||
- "1.2.3.4" # whitelist entry
|
||||
- "!5.6.7.8" # blacklist entry (blocked)
|
||||
```
|
||||
|
||||
- If no IPs are configured, the check is skipped.
|
||||
]],
|
||||
on_start = { sync = false },
|
||||
|
||||
@@ -3,7 +3,7 @@ Plugin = {
|
||||
description = [[
|
||||
Automatically find and replace content in requests and responses.
|
||||
|
||||
**Config** (YAML):
|
||||
**Config**:
|
||||
```yaml
|
||||
rules:
|
||||
- on: "request" # "request", "response", or "both" (default: "both")
|
||||
|
||||
+1
-1
@@ -3,7 +3,7 @@ Plugin = {
|
||||
description = [[
|
||||
Auto-forward requests and exclude them from history based on patterns.
|
||||
|
||||
**Config** (YAML):
|
||||
**Config**:
|
||||
```yaml
|
||||
patterns:
|
||||
- "pattern" # whitelist: only intercept matching requests/responses and history
|
||||
|
||||
@@ -185,9 +185,11 @@ local function scan(label, ct, body, host, path)
|
||||
title = "Potential secret in " .. label .. " (" .. host .. ")",
|
||||
description = "**Host:** `"
|
||||
.. host
|
||||
.. "` \n**Path:** `"
|
||||
.. "`\n"
|
||||
.. "\n**Path:** `"
|
||||
.. path
|
||||
.. "`\n\n**Match:** `"
|
||||
.. "`\n"
|
||||
.. "\n**Match:** `"
|
||||
.. display
|
||||
.. "`\n\n"
|
||||
.. ctx,
|
||||
|
||||
@@ -4,9 +4,6 @@ Plugin = {
|
||||
Scans request and response bodies for secrets using [TruffleHog](https://github.com/trufflesecurity/trufflehog).
|
||||
|
||||
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_request = { sync = false },
|
||||
@@ -56,7 +53,7 @@ local function scan(label, content, host, path)
|
||||
for _, block in ipairs(blocks) do
|
||||
create_finding({
|
||||
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,
|
||||
severity = "high",
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user