9 Commits

Author SHA1 Message Date
Hadi 0b395e018a remove unused script
Signed-off-by: Hadi <112569860+anotherhadi@users.noreply.github.com>
2026-05-26 00:54:29 +02:00
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
18 changed files with 692 additions and 312 deletions
-27
View File
@@ -1,27 +0,0 @@
#!/usr/bin/env bash
set -euo pipefail
CURRENT_HASH=$(grep -oP '(?<=vendorHash = ")[^"]+' flake.nix)
go mod vendor
COMPUTED_HASH=$(nix hash path vendor/)
rm -rf vendor/
if [ "$CURRENT_HASH" = "$COMPUTED_HASH" ]; then
echo "vendorHash is up to date"
exit 0
fi
echo "Updating vendorHash in flake.nix..."
python3 -c "
import sys
with open('flake.nix', 'r') as f:
content = f.read()
content = content.replace('$CURRENT_HASH', '$COMPUTED_HASH')
with open('flake.nix', 'w') as f:
f.write(content)
"
echo " Old: $CURRENT_HASH"
echo " New: $COMPUTED_HASH"
+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
}
+23 -1
View File
@@ -13,8 +13,15 @@ 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
@@ -22,6 +29,7 @@ type Manager struct {
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
]
+1 -1
View File
@@ -3,7 +3,7 @@ Plugin = {
description = [[
Inject custom headers into every intercepted request.
**Config** (YAML):
**Config**:
```yaml
headers:
- "X-My-Header: myvalue"
+5 -2
View File
@@ -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 },
@@ -27,7 +28,9 @@ function on_config()
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
if ip ~= "" then
table.insert(blacklist, ip)
end
else
table.insert(whitelist, trimmed)
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
+28 -10
View File
@@ -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
@@ -52,7 +52,9 @@ function on_config()
blacklist_req, whitelist_req = {}, {}
blacklist_hist, whitelist_hist = {}, {}
local cfg = get_config()
if not cfg or not cfg.patterns then return end
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
@@ -73,17 +75,27 @@ end
local function check_skip(url, bl_extra, wl_extra)
for _, p in ipairs(blacklist) do
if url:match(p) then return true end
if url:match(p) then
return true
end
end
for _, p in ipairs(bl_extra) do
if url:match(p) then return true end
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
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
if url:match(p) then
return false
end
end
return true
end
@@ -91,13 +103,19 @@ local function check_skip(url, bl_extra, wl_extra)
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
+76 -28
View File
@@ -21,40 +21,61 @@ local CONTENT_TYPES = {
-- 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",
"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",
"ansible_vault_password",
"aos_key",
"api(_key|_secret|_token)",
"app_(id|key|secret)", "application(_key|_id|_secret)",
"auth(_token|_secret|orization)", "authkey", "authsecret",
"app_(id|key|secret)",
"application(_key|_id|_secret)",
"auth(_token|_secret|orization)",
"authkey",
"authsecret",
"bearer_?token",
"bucket(_password|_key)",
"cert_?pass(word)?", "certificate_password",
"cert_?pass(word)?",
"certificate_password",
"client(_id|_secret)",
"codecov_token", "consumer_(key|secret)",
"connection_?string", "credentials?", "crypt(_key|_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",
"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)",
"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",
"pass(word)?",
"passwd",
"private(_key|_token)",
"rds_password",
"s3(_key|_secret|_access_key_id)",
"secret(_key|_token|_id)", "security_token",
"secret(_key|_token|_id)",
"security_token",
"sendgrid_api_key",
"ses_(smtp|access|secret)",
"service(_account|_key|_token)",
"smtp_pass(word)?", "smtp_secret",
"smtp_pass(word)?",
"smtp_secret",
"sonar_token",
"ssh(_key|_private_key|_rsa)",
"supabase(_anon|_service)?_key",
@@ -79,10 +100,14 @@ local FULL_PAT = KEY_PAT .. '[a-z0-9._-]{0,20}[^=:a-zA-Z0-9_]{0,3}[[:space:]]*[:
local GREP_CMD = "grep -Eoni '" .. FULL_PAT .. "'"
local function is_relevant(ct)
if not ct or ct == "" then return false end
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
if ct:find(t, 1, true) then
return true
end
end
return false
end
@@ -94,17 +119,23 @@ local function build_context(lines, linenum)
local before, after = {}, {}
for i = lo, linenum - 1 do
local l = lines[i] or ""
if #l > 120 then l = l:sub(1, 120) .. "..." end
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
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
if #matched_line > 200 then
matched_line = matched_line:sub(1, 200) .. "..."
end
local parts = {}
if #before > 0 then
@@ -118,15 +149,21 @@ local function build_context(lines, linenum)
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
if not out or out == "" then
return
end
local lines = {}
for line in (body .. "\n"):gmatch("([^\n]*)\n") do
@@ -140,11 +177,22 @@ local function scan(label, ct, body, host, path)
matched = matched:match("^%s*(.-)%s*$")
if matched ~= "" then
local display = matched
if #display > 200 then display = display:sub(1, 200) .. "..." end
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,
description = "**Host:** `"
.. host
.. "`\n"
.. "\n**Path:** `"
.. path
.. "`\n"
.. "\n**Match:** `"
.. display
.. "`\n\n"
.. ctx,
key = host .. "|" .. path .. "|" .. label .. "|" .. matched,
severity = "high",
})
+17 -9
View File
@@ -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 },
@@ -24,28 +21,39 @@ function on_start()
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 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
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
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
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```",
description = "**Host:** `" .. host .. "`\n\n**Path:** `" .. path .. "`\n\n```\n" .. block .. "\n```",
key = host .. "|" .. path .. "|" .. label .. "|" .. block,
severity = "high",
})