8 Commits

Author SHA1 Message Date
Hadi 5841ed0a95 Allow user to "edit" response in replay: allow vim navigation & filters
Signed-off-by: Hadi <112569860+anotherhadi@users.noreply.github.com>
2026-05-21 14:41:17 +02:00
Hadi 9cabe81771 Edit descriptions & create_findings
Signed-off-by: Hadi <112569860+anotherhadi@users.noreply.github.com>
2026-05-21 10:28:52 +02:00
Hadi 021090f52c init send_request func for plugins
Signed-off-by: Hadi <112569860+anotherhadi@users.noreply.github.com>
2026-05-21 10:21:37 +02:00
Hadi 0b9e1a1cf0 Change default plugin's config in global config
Signed-off-by: Hadi <112569860+anotherhadi@users.noreply.github.com>
2026-05-21 09:54:57 +02:00
Hadi f60cdf2033 Delete all except flagged entries
Signed-off-by: Hadi <112569860+anotherhadi@users.noreply.github.com>
2026-05-21 09:34:52 +02:00
Hadi 6af1388652 init match & replace plugin
Signed-off-by: Hadi <112569860+anotherhadi@users.noreply.github.com>
2026-05-20 20:25:01 +02:00
Hadi a708830309 give the set_path et set_url fonction to plugins
Signed-off-by: Hadi <112569860+anotherhadi@users.noreply.github.com>
2026-05-20 19:31:44 +02:00
Hadi 44b3c67a37 +stylua
Signed-off-by: Hadi <112569860+anotherhadi@users.noreply.github.com>
2026-05-20 19:31:31 +02:00
17 changed files with 692 additions and 285 deletions
+23
View File
@@ -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.
+7
View File
@@ -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
+15
View File
@@ -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
}
+92
View File
@@ -3,11 +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"
@@ -197,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, "")
@@ -292,6 +369,21 @@ func pushRequest(L *lua.LState, f *goproxy.Flow) *lua.LTable {
return 0
}))
L.SetField(t, "set_path", L.NewFunction(func(L *lua.LState) int {
r.URL.Path = L.CheckString(2)
return 0
}))
L.SetField(t, "set_url", L.NewFunction(func(L *lua.LState) int {
parsed, err := url.Parse(L.CheckString(2))
if err != nil {
log.Printf("[plugin] set_url: %v", err)
return 0
}
r.URL = parsed
return 0
}))
return t
}
+26 -4
View File
@@ -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()
+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
}
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()
}
}
+8
View File
@@ -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)
+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):
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()
+3
View File
@@ -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
View File
@@ -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
})
}
+2
View File
@@ -8,6 +8,7 @@
hooks = {
gofmt.enable = true;
govet.enable = true;
stylua.enable = true;
gomod2nix = {
enable = true;
@@ -53,6 +54,7 @@ in
go
python3
doctoc
stylua
trufflehog
gomod2nixPkgs.gomod2nix
]
+17 -17
View File
@@ -1,34 +1,34 @@
Plugin = {
name = "Inject Header",
description = [[
name = "Inject Header",
description = [[
Inject custom headers into every intercepted request.
**Config** (YAML):
**Config**:
```yaml
headers:
- "X-My-Header: myvalue"
```
]],
on_request = { sync = true },
on_request = { sync = true },
}
local headers = {}
function on_config()
headers = {}
local cfg = get_config()
if cfg and cfg.headers then
for _, line in ipairs(cfg.headers) do
local name, value = line:match("^([^:]+):%s*(.+)$")
if name and value then
table.insert(headers, { name = name, value = value })
end
end
end
headers = {}
local cfg = get_config()
if cfg and cfg.headers then
for _, line in ipairs(cfg.headers) do
local name, value = line:match("^([^:]+):%s*(.+)$")
if name and value then
table.insert(headers, { name = name, value = value })
end
end
end
end
function on_request(req)
for _, h in ipairs(headers) do
req:set_header(h.name, h.value)
end
for _, h in ipairs(headers) do
req:set_header(h.name, h.value)
end
end
+48 -45
View File
@@ -1,71 +1,74 @@
Plugin = {
name = "IP Filter (Whitelist/Blacklist)",
description = [[
name = "IP Filter (Whitelist/Blacklist)",
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 },
disable_by_default = true,
on_start = { sync = false },
disable_by_default = true,
}
local whitelist = {}
local blacklist = {}
function on_config()
whitelist, blacklist = {}, {}
local cfg = get_config()
if cfg and cfg.ips then
for _, entry in ipairs(cfg.ips) do
local trimmed = entry:match("^%s*(.-)%s*$")
if trimmed ~= "" then
if trimmed:sub(1, 1) == "!" then
local ip = trimmed:sub(2):match("^%s*(.-)%s*$")
if ip ~= "" then table.insert(blacklist, ip) end
else
table.insert(whitelist, trimmed)
end
end
end
end
whitelist, blacklist = {}, {}
local cfg = get_config()
if cfg and cfg.ips then
for _, entry in ipairs(cfg.ips) do
local trimmed = entry:match("^%s*(.-)%s*$")
if trimmed ~= "" then
if trimmed:sub(1, 1) == "!" then
local ip = trimmed:sub(2):match("^%s*(.-)%s*$")
if ip ~= "" then
table.insert(blacklist, ip)
end
else
table.insert(whitelist, trimmed)
end
end
end
end
end
function on_start()
if #whitelist == 0 and #blacklist == 0 then
return
end
if #whitelist == 0 and #blacklist == 0 then
return
end
local result, err = shell_pipe("curl -sf https://api.ipify.org 2>/dev/null")
result = result and result:match("^%s*(.-)%s*$") or nil
local result, err = shell_pipe("curl -sf https://api.ipify.org 2>/dev/null")
result = result and result:match("^%s*(.-)%s*$") or nil
if err or not result or result == "" then
log("could not determine outbound IP, skipping check")
notif("IP Filter", "Could not determine outbound IP, skipping check", "warning")
return
end
if err or not result or result == "" then
log("could not determine outbound IP, skipping check")
notif("IP Filter", "Could not determine outbound IP, skipping check", "warning")
return
end
for _, ip in ipairs(blacklist) do
if result == ip then
notif("IP Filter", "Outbound IP " .. result .. " is blacklisted!", "error")
return
end
end
for _, ip in ipairs(blacklist) do
if result == ip then
notif("IP Filter", "Outbound IP " .. result .. " is blacklisted!", "error")
return
end
end
if #whitelist == 0 then
return
end
if #whitelist == 0 then
return
end
for _, ip in ipairs(whitelist) do
if result == ip then
return
end
end
for _, ip in ipairs(whitelist) do
if result == ip then
return
end
end
notif("IP Filter", "Outbound IP " .. result .. " is not in the whitelist!", "error")
notif("IP Filter", "Outbound IP " .. result .. " is not in the whitelist!", "error")
end
+125
View File
@@ -0,0 +1,125 @@
Plugin = {
name = "Match & Replace",
description = [[
Automatically find and replace content in requests and responses.
**Config**:
```yaml
rules:
- on: "request" # "request", "response", or "both" (default: "both")
url: "example%.com" # optional: Lua pattern to filter by URL
target: "body" # "body", "path", "url", or "header:Name"
find: "old_string" # Lua pattern to search
replace: "new_string" # replacement string (supports %1, %2 for captures)
```
Example (inject a debug flag in JSON bodies):
```yaml
rules:
- on: "both"
url: "api%.example%.com"
target: "body"
find: '"debug":false'
replace: '"debug":true'
```
Example (replace an Authorization header):
```yaml
rules:
- on: "request"
target: "header:Authorization"
find: "Bearer .*"
replace: "Bearer MY_NEW_TOKEN"
```
Example (rewrite a path prefix):
```yaml
rules:
- on: "request"
url: "staging%.example%.com"
target: "path"
find: "^/v1/"
replace: "/v2/"
```
]],
priority = 50,
on_request = { sync = true },
on_response = { sync = true },
}
local rules = {}
function on_config()
rules = {}
local cfg = get_config()
if not cfg or not cfg.rules then
return
end
for _, r in ipairs(cfg.rules) do
if r.find and r.find ~= "" then
table.insert(rules, {
on = r.on or "both",
url = r.url,
target = r.target or "body",
find = r.find,
replace = r.replace or "",
})
end
end
end
local function should_apply(rule, req, context)
if rule.on ~= "both" and rule.on ~= context then
return false
end
if rule.url and not req.url:match(rule.url) then
return false
end
return true
end
local function apply(rule, req, res, context)
local target = rule.target
if target == "body" then
local obj = (context == "response") and res or req
local body = obj:get_body()
local new_body = body:gsub(rule.find, rule.replace)
if new_body ~= body then
obj:set_body(new_body)
end
elseif target == "path" then
local new_path = req.path:gsub(rule.find, rule.replace)
if new_path ~= req.path then
req:set_path(new_path)
end
elseif target == "url" then
local new_url = req.url:gsub(rule.find, rule.replace)
if new_url ~= req.url then
req:set_url(new_url)
end
elseif target:sub(1, 7) == "header:" then
local name = target:sub(8)
local obj = (context == "response") and res or req
local val = obj.headers[name] or ""
local new_val = val:gsub(rule.find, rule.replace)
if new_val ~= val then
obj:set_header(name, new_val)
end
end
end
function on_request(req)
for _, rule in ipairs(rules) do
if should_apply(rule, req, "request") then
apply(rule, req, nil, "request")
end
end
end
function on_response(req, res)
for _, rule in ipairs(rules) do
if should_apply(rule, req, "response") then
apply(rule, req, res, "response")
end
end
end
+69 -51
View File
@@ -1,9 +1,9 @@
Plugin = {
name = "Scopes",
description = [[
name = "Scopes",
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
@@ -34,70 +34,88 @@ patterns:
- "h:^$"
```
]],
priority = 100,
on_request = { sync = true },
on_response = { sync = true },
on_history_entry = { sync = true },
priority = 100,
on_request = { sync = true },
on_response = { sync = true },
on_history_entry = { sync = true },
}
local blacklist = {}
local whitelist = {}
local blacklist_req = {}
local whitelist_req = {}
local blacklist = {}
local whitelist = {}
local blacklist_req = {}
local whitelist_req = {}
local blacklist_hist = {}
local whitelist_hist = {}
function on_config()
blacklist, whitelist = {}, {}
blacklist_req, whitelist_req = {}, {}
blacklist_hist, whitelist_hist = {}, {}
local cfg = get_config()
if not cfg or not cfg.patterns then return end
for _, line in ipairs(cfg.patterns) do
local trimmed = line:match("^%s*(.-)%s*$")
if trimmed ~= "" and trimmed:sub(1, 1) ~= "#" then
local scope = trimmed:match("^([rh]):")
local rest = scope and trimmed:sub(3) or trimmed
local is_bl = rest:sub(1, 1) == "!"
local pattern = is_bl and rest:sub(2) or rest
if scope == "r" then
table.insert(is_bl and blacklist_req or whitelist_req, pattern)
elseif scope == "h" then
table.insert(is_bl and blacklist_hist or whitelist_hist, pattern)
else
table.insert(is_bl and blacklist or whitelist, pattern)
end
end
end
blacklist, whitelist = {}, {}
blacklist_req, whitelist_req = {}, {}
blacklist_hist, whitelist_hist = {}, {}
local cfg = get_config()
if not cfg or not cfg.patterns then
return
end
for _, line in ipairs(cfg.patterns) do
local trimmed = line:match("^%s*(.-)%s*$")
if trimmed ~= "" and trimmed:sub(1, 1) ~= "#" then
local scope = trimmed:match("^([rh]):")
local rest = scope and trimmed:sub(3) or trimmed
local is_bl = rest:sub(1, 1) == "!"
local pattern = is_bl and rest:sub(2) or rest
if scope == "r" then
table.insert(is_bl and blacklist_req or whitelist_req, pattern)
elseif scope == "h" then
table.insert(is_bl and blacklist_hist or whitelist_hist, pattern)
else
table.insert(is_bl and blacklist or whitelist, pattern)
end
end
end
end
local function check_skip(url, bl_extra, wl_extra)
for _, p in ipairs(blacklist) do
if url:match(p) then return true end
end
for _, p in ipairs(bl_extra) do
if url:match(p) then return true end
end
local wl = {}
for _, p in ipairs(whitelist) do wl[#wl + 1] = p end
for _, p in ipairs(wl_extra) do wl[#wl + 1] = p end
if #wl > 0 then
for _, p in ipairs(wl) do
if url:match(p) then return false end
end
return true
end
return false
for _, p in ipairs(blacklist) do
if url:match(p) then
return true
end
end
for _, p in ipairs(bl_extra) do
if url:match(p) then
return true
end
end
local wl = {}
for _, p in ipairs(whitelist) do
wl[#wl + 1] = p
end
for _, p in ipairs(wl_extra) do
wl[#wl + 1] = p
end
if #wl > 0 then
for _, p in ipairs(wl) do
if url:match(p) then
return false
end
end
return true
end
return false
end
function on_request(req)
if check_skip(req.url, blacklist_req, whitelist_req) then return "forward" end
if check_skip(req.url, blacklist_req, whitelist_req) then
return "forward"
end
end
function on_response(req)
if check_skip(req.url, blacklist_req, whitelist_req) then return "forward" end
if check_skip(req.url, blacklist_req, whitelist_req) then
return "forward"
end
end
function on_history_entry(entry)
if check_skip(entry.host .. entry.path, blacklist_hist, whitelist_hist) then return "skip" end
if check_skip(entry.host .. entry.path, blacklist_hist, whitelist_hist) then
return "skip"
end
end
+168 -120
View File
@@ -1,70 +1,91 @@
Plugin = {
name = "Secret Scan",
description = [[
name = "Secret Scan",
description = [[
Scans HTML, JavaScript and JSON content (requests and responses) for hardcoded
secrets by matching common secret key names followed by a non-trivial value.
Uses `grep -E` (available on all Unix systems, no extra dependencies).
]],
on_request = { sync = false },
on_response = { sync = false },
disable_by_default = true,
on_request = { sync = false },
on_response = { sync = false },
disable_by_default = true,
}
local CONTENT_TYPES = {
"text/html",
"text/javascript",
"application/javascript",
"application/json",
"text/html",
"text/javascript",
"application/javascript",
"application/json",
}
-- Key name alternation (case-insensitive via grep -i)
-- Suffixes are required (no bare generic keyword alone).
local KEYS = {
"access(_key|_token)", "accessid_secret", "account(_key|_sid)",
"admin_pass(word)?", "admin_user",
"(algolia|aws|gcp|azure|heroku|firebase|github|gitlab|slack|datadog|stripe|twilio|vercel|supabase|sendgrid|cloudinary|cloudflare|bitbucket|npm|netlify|auth0|okta|sentry)(_?(api|secret|access)(_?(key|token|id|sid|secret))?|_?(key|token|id|sid|secret))",
"ansible_vault_password", "aos_key",
"api(_key|_secret|_token)",
"app_(id|key|secret)", "application(_key|_id|_secret)",
"auth(_token|_secret|orization)", "authkey", "authsecret",
"bearer_?token",
"bucket(_password|_key)",
"cert_?pass(word)?", "certificate_password",
"client(_id|_secret)",
"codecov_token", "consumer_(key|secret)",
"connection_?string", "credentials?", "crypt(_key|_secret)",
"db_(password|passwd|user(name)?)",
"deploy(_key|_password|_token)",
"docker_?pass(word)?", "dockerhub_?password",
"encryption_(key|password)",
"jwt_secret", "json_web_token",
"keycloak_secret", "kubernetes_token",
"ldap_(password|bindpw)", "login(_password|_token)",
"mail_?password", "mail_smtp_pass",
"mysql_password", "mongo_password",
"netlify_token", "npm(_token|_auth_token)",
"oauth(_token|_secret)",
"openai_(api_key|secret)",
"pass(word)?", "passwd",
"private(_key|_token)",
"rds_password",
"s3(_key|_secret|_access_key_id)",
"secret(_key|_token|_id)", "security_token",
"sendgrid_api_key",
"ses_(smtp|access|secret)",
"service(_account|_key|_token)",
"smtp_pass(word)?", "smtp_secret",
"sonar_token",
"ssh(_key|_private_key|_rsa)",
"supabase(_anon|_service)?_key",
"symfony_secret",
"telegram_bot_token",
"token",
"travis_token",
"vault(_token|_secret)",
"webhook(_secret|_token)",
"zapier_webhook_token",
"access(_key|_token)",
"accessid_secret",
"account(_key|_sid)",
"admin_pass(word)?",
"admin_user",
"(algolia|aws|gcp|azure|heroku|firebase|github|gitlab|slack|datadog|stripe|twilio|vercel|supabase|sendgrid|cloudinary|cloudflare|bitbucket|npm|netlify|auth0|okta|sentry)(_?(api|secret|access)(_?(key|token|id|sid|secret))?|_?(key|token|id|sid|secret))",
"ansible_vault_password",
"aos_key",
"api(_key|_secret|_token)",
"app_(id|key|secret)",
"application(_key|_id|_secret)",
"auth(_token|_secret|orization)",
"authkey",
"authsecret",
"bearer_?token",
"bucket(_password|_key)",
"cert_?pass(word)?",
"certificate_password",
"client(_id|_secret)",
"codecov_token",
"consumer_(key|secret)",
"connection_?string",
"credentials?",
"crypt(_key|_secret)",
"db_(password|passwd|user(name)?)",
"deploy(_key|_password|_token)",
"docker_?pass(word)?",
"dockerhub_?password",
"encryption_(key|password)",
"jwt_secret",
"json_web_token",
"keycloak_secret",
"kubernetes_token",
"ldap_(password|bindpw)",
"login(_password|_token)",
"mail_?password",
"mail_smtp_pass",
"mysql_password",
"mongo_password",
"netlify_token",
"npm(_token|_auth_token)",
"oauth(_token|_secret)",
"openai_(api_key|secret)",
"pass(word)?",
"passwd",
"private(_key|_token)",
"rds_password",
"s3(_key|_secret|_access_key_id)",
"secret(_key|_token|_id)",
"security_token",
"sendgrid_api_key",
"ses_(smtp|access|secret)",
"service(_account|_key|_token)",
"smtp_pass(word)?",
"smtp_secret",
"sonar_token",
"ssh(_key|_private_key|_rsa)",
"supabase(_anon|_service)?_key",
"symfony_secret",
"telegram_bot_token",
"token",
"travis_token",
"vault(_token|_secret)",
"webhook(_secret|_token)",
"zapier_webhook_token",
}
-- Built once at load time.
@@ -74,93 +95,120 @@ local KEYS = {
-- [[:space:]]*[:=] REQUIRED: actual = or : assignment operator
-- [[:space:]]*"? optional whitespace + opening quote
-- [a-zA-Z0-9+/=_.-]{8,} the secret value, at least 8 chars
local KEY_PAT = "(" .. table.concat(KEYS, "|") .. ")"
local KEY_PAT = "(" .. table.concat(KEYS, "|") .. ")"
local FULL_PAT = KEY_PAT .. '[a-z0-9._-]{0,20}[^=:a-zA-Z0-9_]{0,3}[[:space:]]*[:=][[:space:]]*"?[a-zA-Z0-9+/=_.-]{8,}'
local GREP_CMD = "grep -Eoni '" .. FULL_PAT .. "'"
local function is_relevant(ct)
if not ct or ct == "" then return false end
ct = ct:lower()
for _, t in ipairs(CONTENT_TYPES) do
if ct:find(t, 1, true) then return true end
end
return false
if not ct or ct == "" then
return false
end
ct = ct:lower()
for _, t in ipairs(CONTENT_TYPES) do
if ct:find(t, 1, true) then
return true
end
end
return false
end
local function build_context(lines, linenum)
local lo = math.max(1, linenum - 6)
local hi = math.min(#lines, linenum + 6)
local lo = math.max(1, linenum - 6)
local hi = math.min(#lines, linenum + 6)
local before, after = {}, {}
for i = lo, linenum - 1 do
local l = lines[i] or ""
if #l > 120 then l = l:sub(1, 120) .. "..." end
table.insert(before, l)
end
for i = linenum + 1, hi do
local l = lines[i] or ""
if #l > 120 then l = l:sub(1, 120) .. "..." end
table.insert(after, l)
end
local before, after = {}, {}
for i = lo, linenum - 1 do
local l = lines[i] or ""
if #l > 120 then
l = l:sub(1, 120) .. "..."
end
table.insert(before, l)
end
for i = linenum + 1, hi do
local l = lines[i] or ""
if #l > 120 then
l = l:sub(1, 120) .. "..."
end
table.insert(after, l)
end
local matched_line = lines[linenum] or ""
if #matched_line > 200 then matched_line = matched_line:sub(1, 200) .. "..." end
local matched_line = lines[linenum] or ""
if #matched_line > 200 then
matched_line = matched_line:sub(1, 200) .. "..."
end
local parts = {}
if #before > 0 then
table.insert(parts, "```\n" .. table.concat(before, "\n") .. "\n```")
end
table.insert(parts, "> **`" .. matched_line .. "`**")
if #after > 0 then
table.insert(parts, "```\n" .. table.concat(after, "\n") .. "\n```")
end
return table.concat(parts, "\n\n")
local parts = {}
if #before > 0 then
table.insert(parts, "```\n" .. table.concat(before, "\n") .. "\n```")
end
table.insert(parts, "> **`" .. matched_line .. "`**")
if #after > 0 then
table.insert(parts, "```\n" .. table.concat(after, "\n") .. "\n```")
end
return table.concat(parts, "\n\n")
end
local function scan(label, ct, body, host, path)
if not is_relevant(ct) then return end
if not body or body == "" then return end
if not is_relevant(ct) then
return
end
if not body or body == "" then
return
end
local out, err = shell_pipe(GREP_CMD, body)
if err and err ~= "" then
log("grep error on " .. label .. " for " .. host .. path .. ": " .. err)
return
end
if not out or out == "" then return end
local out, err = shell_pipe(GREP_CMD, body)
if err and err ~= "" then
log("grep error on " .. label .. " for " .. host .. path .. ": " .. err)
return
end
if not out or out == "" then
return
end
local lines = {}
for line in (body .. "\n"):gmatch("([^\n]*)\n") do
table.insert(lines, line)
end
local lines = {}
for line in (body .. "\n"):gmatch("([^\n]*)\n") do
table.insert(lines, line)
end
for entry in out:gmatch("[^\n]+") do
local linenum_str, matched = entry:match("^(%d+):(.+)$")
if linenum_str then
local linenum = tonumber(linenum_str)
matched = matched:match("^%s*(.-)%s*$")
if matched ~= "" then
local display = matched
if #display > 200 then display = display:sub(1, 200) .. "..." end
local ctx = build_context(lines, linenum)
create_finding({
title = "Potential secret in " .. label .. " (" .. host .. ")",
description = "**Host:** `" .. host .. "` \n**Path:** `" .. path .. "`\n\n**Match:** `" .. display .. "`\n\n" .. ctx,
key = host .. "|" .. path .. "|" .. label .. "|" .. matched,
severity = "high",
})
end
end
end
for entry in out:gmatch("[^\n]+") do
local linenum_str, matched = entry:match("^(%d+):(.+)$")
if linenum_str then
local linenum = tonumber(linenum_str)
matched = matched:match("^%s*(.-)%s*$")
if matched ~= "" then
local display = matched
if #display > 200 then
display = display:sub(1, 200) .. "..."
end
local ctx = build_context(lines, linenum)
create_finding({
title = "Potential secret in " .. label .. " (" .. host .. ")",
description = "**Host:** `"
.. host
.. "`\n"
.. "\n**Path:** `"
.. path
.. "`\n"
.. "\n**Match:** `"
.. display
.. "`\n\n"
.. ctx,
key = host .. "|" .. path .. "|" .. label .. "|" .. matched,
severity = "high",
})
end
end
end
end
function on_request(req)
scan("request", req.headers["Content-Type"] or "", req:get_body(), req.host, req.path)
scan("request", req.headers["Content-Type"] or "", req:get_body(), req.host, req.path)
end
function on_response(req, res)
local ct = ""
if res.headers then
ct = res.headers["Content-Type"] or ""
end
scan("response", ct, res:get_body(), req.host, req.path)
local ct = ""
if res.headers then
ct = res.headers["Content-Type"] or ""
end
scan("response", ct, res:get_body(), req.host, req.path)
end
+51 -43
View File
@@ -1,61 +1,69 @@
Plugin = {
name = "TruffleHog",
description = [[
name = "TruffleHog",
description = [[
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 },
on_response = { sync = false },
disable_by_default = true,
on_start = { sync = false },
on_request = { sync = false },
on_response = { sync = false },
disable_by_default = true,
}
function on_start()
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")
return false
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")
return false
end
end
local function scan(label, content, host, path)
if not content or content == "" then return end
local out, err = shell_pipe("f=$(mktemp) && cat > \"$f\" && trufflehog filesystem --no-color \"$f\"; rc=$?; rm -f \"$f\"; exit $rc", content)
if err and err ~= "" then
log("trufflehog error on " .. label .. ": " .. err)
return
end
if not out or out == "" then return end
local blocks = {}
local current = nil
for line in out:gmatch("[^\n]+") do
if line:match("^Found ") then
if current then table.insert(blocks, current) end
current = line
elseif current then
current = current .. "\n" .. line
end
end
if current then table.insert(blocks, current) end
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```",
key = host .. "|" .. path .. "|" .. label .. "|" .. block,
severity = "high",
})
end
if not content or content == "" then
return
end
local out, err = shell_pipe(
'f=$(mktemp) && cat > "$f" && trufflehog filesystem --no-color "$f"; rc=$?; rm -f "$f"; exit $rc',
content
)
if err and err ~= "" then
log("trufflehog error on " .. label .. ": " .. err)
return
end
if not out or out == "" then
return
end
local blocks = {}
local current = nil
for line in out:gmatch("[^\n]+") do
if line:match("^Found ") then
if current then
table.insert(blocks, current)
end
current = line
elseif current then
current = current .. "\n" .. line
end
end
if current then
table.insert(blocks, current)
end
for _, block in ipairs(blocks) do
create_finding({
title = "Secret detected in " .. label .. " (" .. host .. ")",
description = "**Host:** `" .. host .. "`\n\n**Path:** `" .. path .. "`\n\n```\n" .. block .. "\n```",
key = host .. "|" .. path .. "|" .. label .. "|" .. block,
severity = "high",
})
end
end
function on_request(req)
scan("request", req:get_body(), req.host, req.path)
scan("request", req:get_body(), req.host, req.path)
end
function on_response(req, res)
scan("response", res:get_body(), req.host, req.path)
scan("response", res:get_body(), req.host, req.path)
end