mirror of
https://github.com/anotherhadi/spilltea.git
synced 2026-07-06 20:42:33 +02:00
Compare commits
8 Commits
v0.0.6
...
5841ed0a95
| Author | SHA1 | Date | |
|---|---|---|---|
| 5841ed0a95 | |||
| 9cabe81771 | |||
| 021090f52c | |||
| 0b9e1a1cf0 | |||
| f60cdf2033 | |||
| 6af1388652 | |||
| a708830309 | |||
| 44b3c67a37 |
@@ -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.
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
|
|||||||
@@ -3,11 +3,16 @@ package plugins
|
|||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
"context"
|
"context"
|
||||||
|
"crypto/tls"
|
||||||
|
"io"
|
||||||
"log"
|
"log"
|
||||||
|
"net/http"
|
||||||
|
"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"
|
||||||
@@ -197,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, "")
|
||||||
@@ -292,6 +369,21 @@ func pushRequest(L *lua.LState, f *goproxy.Flow) *lua.LTable {
|
|||||||
return 0
|
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
|
return t
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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
@@ -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
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|||||||
@@ -8,6 +8,7 @@
|
|||||||
hooks = {
|
hooks = {
|
||||||
gofmt.enable = true;
|
gofmt.enable = true;
|
||||||
govet.enable = true;
|
govet.enable = true;
|
||||||
|
stylua.enable = true;
|
||||||
|
|
||||||
gomod2nix = {
|
gomod2nix = {
|
||||||
enable = true;
|
enable = true;
|
||||||
@@ -53,6 +54,7 @@ in
|
|||||||
go
|
go
|
||||||
python3
|
python3
|
||||||
doctoc
|
doctoc
|
||||||
|
stylua
|
||||||
trufflehog
|
trufflehog
|
||||||
gomod2nixPkgs.gomod2nix
|
gomod2nixPkgs.gomod2nix
|
||||||
]
|
]
|
||||||
|
|||||||
+17
-17
@@ -1,34 +1,34 @@
|
|||||||
Plugin = {
|
Plugin = {
|
||||||
name = "Inject Header",
|
name = "Inject Header",
|
||||||
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"
|
||||||
```
|
```
|
||||||
]],
|
]],
|
||||||
on_request = { sync = true },
|
on_request = { sync = true },
|
||||||
}
|
}
|
||||||
|
|
||||||
local headers = {}
|
local headers = {}
|
||||||
|
|
||||||
function on_config()
|
function on_config()
|
||||||
headers = {}
|
headers = {}
|
||||||
local cfg = get_config()
|
local cfg = get_config()
|
||||||
if cfg and cfg.headers then
|
if cfg and cfg.headers then
|
||||||
for _, line in ipairs(cfg.headers) do
|
for _, line in ipairs(cfg.headers) do
|
||||||
local name, value = line:match("^([^:]+):%s*(.+)$")
|
local name, value = line:match("^([^:]+):%s*(.+)$")
|
||||||
if name and value then
|
if name and value then
|
||||||
table.insert(headers, { name = name, value = value })
|
table.insert(headers, { name = name, value = value })
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
function on_request(req)
|
function on_request(req)
|
||||||
for _, h in ipairs(headers) do
|
for _, h in ipairs(headers) do
|
||||||
req:set_header(h.name, h.value)
|
req:set_header(h.name, h.value)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
+48
-45
@@ -1,71 +1,74 @@
|
|||||||
Plugin = {
|
Plugin = {
|
||||||
name = "IP Filter (Whitelist/Blacklist)",
|
name = "IP Filter (Whitelist/Blacklist)",
|
||||||
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 },
|
||||||
disable_by_default = true,
|
disable_by_default = true,
|
||||||
}
|
}
|
||||||
|
|
||||||
local whitelist = {}
|
local whitelist = {}
|
||||||
local blacklist = {}
|
local blacklist = {}
|
||||||
|
|
||||||
function on_config()
|
function on_config()
|
||||||
whitelist, blacklist = {}, {}
|
whitelist, blacklist = {}, {}
|
||||||
local cfg = get_config()
|
local cfg = get_config()
|
||||||
if cfg and cfg.ips then
|
if cfg and cfg.ips then
|
||||||
for _, entry in ipairs(cfg.ips) do
|
for _, entry in ipairs(cfg.ips) do
|
||||||
local trimmed = entry:match("^%s*(.-)%s*$")
|
local trimmed = entry:match("^%s*(.-)%s*$")
|
||||||
if trimmed ~= "" then
|
if trimmed ~= "" then
|
||||||
if trimmed:sub(1, 1) == "!" then
|
if trimmed:sub(1, 1) == "!" then
|
||||||
local ip = trimmed:sub(2):match("^%s*(.-)%s*$")
|
local ip = trimmed:sub(2):match("^%s*(.-)%s*$")
|
||||||
if ip ~= "" then table.insert(blacklist, ip) end
|
if ip ~= "" then
|
||||||
else
|
table.insert(blacklist, ip)
|
||||||
table.insert(whitelist, trimmed)
|
end
|
||||||
end
|
else
|
||||||
end
|
table.insert(whitelist, trimmed)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
function on_start()
|
function on_start()
|
||||||
if #whitelist == 0 and #blacklist == 0 then
|
if #whitelist == 0 and #blacklist == 0 then
|
||||||
return
|
return
|
||||||
end
|
end
|
||||||
|
|
||||||
local result, err = shell_pipe("curl -sf https://api.ipify.org 2>/dev/null")
|
local result, err = shell_pipe("curl -sf https://api.ipify.org 2>/dev/null")
|
||||||
result = result and result:match("^%s*(.-)%s*$") or nil
|
result = result and result:match("^%s*(.-)%s*$") or nil
|
||||||
|
|
||||||
if err or not result or result == "" then
|
if err or not result or result == "" then
|
||||||
log("could not determine outbound IP, skipping check")
|
log("could not determine outbound IP, skipping check")
|
||||||
notif("IP Filter", "Could not determine outbound IP, skipping check", "warning")
|
notif("IP Filter", "Could not determine outbound IP, skipping check", "warning")
|
||||||
return
|
return
|
||||||
end
|
end
|
||||||
|
|
||||||
for _, ip in ipairs(blacklist) do
|
for _, ip in ipairs(blacklist) do
|
||||||
if result == ip then
|
if result == ip then
|
||||||
notif("IP Filter", "Outbound IP " .. result .. " is blacklisted!", "error")
|
notif("IP Filter", "Outbound IP " .. result .. " is blacklisted!", "error")
|
||||||
return
|
return
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
if #whitelist == 0 then
|
if #whitelist == 0 then
|
||||||
return
|
return
|
||||||
end
|
end
|
||||||
|
|
||||||
for _, ip in ipairs(whitelist) do
|
for _, ip in ipairs(whitelist) do
|
||||||
if result == ip then
|
if result == ip then
|
||||||
return
|
return
|
||||||
end
|
end
|
||||||
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
|
end
|
||||||
|
|||||||
@@ -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
@@ -1,9 +1,9 @@
|
|||||||
Plugin = {
|
Plugin = {
|
||||||
name = "Scopes",
|
name = "Scopes",
|
||||||
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
|
||||||
@@ -34,70 +34,88 @@ patterns:
|
|||||||
- "h:^$"
|
- "h:^$"
|
||||||
```
|
```
|
||||||
]],
|
]],
|
||||||
priority = 100,
|
priority = 100,
|
||||||
on_request = { sync = true },
|
on_request = { sync = true },
|
||||||
on_response = { sync = true },
|
on_response = { sync = true },
|
||||||
on_history_entry = { sync = true },
|
on_history_entry = { sync = true },
|
||||||
}
|
}
|
||||||
|
|
||||||
local blacklist = {}
|
local blacklist = {}
|
||||||
local whitelist = {}
|
local whitelist = {}
|
||||||
local blacklist_req = {}
|
local blacklist_req = {}
|
||||||
local whitelist_req = {}
|
local whitelist_req = {}
|
||||||
local blacklist_hist = {}
|
local blacklist_hist = {}
|
||||||
local whitelist_hist = {}
|
local whitelist_hist = {}
|
||||||
|
|
||||||
function on_config()
|
function on_config()
|
||||||
blacklist, whitelist = {}, {}
|
blacklist, whitelist = {}, {}
|
||||||
blacklist_req, whitelist_req = {}, {}
|
blacklist_req, whitelist_req = {}, {}
|
||||||
blacklist_hist, whitelist_hist = {}, {}
|
blacklist_hist, whitelist_hist = {}, {}
|
||||||
local cfg = get_config()
|
local cfg = get_config()
|
||||||
if not cfg or not cfg.patterns then return end
|
if not cfg or not cfg.patterns then
|
||||||
for _, line in ipairs(cfg.patterns) do
|
return
|
||||||
local trimmed = line:match("^%s*(.-)%s*$")
|
end
|
||||||
if trimmed ~= "" and trimmed:sub(1, 1) ~= "#" then
|
for _, line in ipairs(cfg.patterns) do
|
||||||
local scope = trimmed:match("^([rh]):")
|
local trimmed = line:match("^%s*(.-)%s*$")
|
||||||
local rest = scope and trimmed:sub(3) or trimmed
|
if trimmed ~= "" and trimmed:sub(1, 1) ~= "#" then
|
||||||
local is_bl = rest:sub(1, 1) == "!"
|
local scope = trimmed:match("^([rh]):")
|
||||||
local pattern = is_bl and rest:sub(2) or rest
|
local rest = scope and trimmed:sub(3) or trimmed
|
||||||
if scope == "r" then
|
local is_bl = rest:sub(1, 1) == "!"
|
||||||
table.insert(is_bl and blacklist_req or whitelist_req, pattern)
|
local pattern = is_bl and rest:sub(2) or rest
|
||||||
elseif scope == "h" then
|
if scope == "r" then
|
||||||
table.insert(is_bl and blacklist_hist or whitelist_hist, pattern)
|
table.insert(is_bl and blacklist_req or whitelist_req, pattern)
|
||||||
else
|
elseif scope == "h" then
|
||||||
table.insert(is_bl and blacklist or whitelist, pattern)
|
table.insert(is_bl and blacklist_hist or whitelist_hist, pattern)
|
||||||
end
|
else
|
||||||
end
|
table.insert(is_bl and blacklist or whitelist, pattern)
|
||||||
end
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
local function check_skip(url, bl_extra, wl_extra)
|
local function check_skip(url, bl_extra, wl_extra)
|
||||||
for _, p in ipairs(blacklist) do
|
for _, p in ipairs(blacklist) do
|
||||||
if url:match(p) then return true end
|
if url:match(p) then
|
||||||
end
|
return true
|
||||||
for _, p in ipairs(bl_extra) do
|
end
|
||||||
if url:match(p) then return true end
|
end
|
||||||
end
|
for _, p in ipairs(bl_extra) do
|
||||||
local wl = {}
|
if url:match(p) then
|
||||||
for _, p in ipairs(whitelist) do wl[#wl + 1] = p end
|
return true
|
||||||
for _, p in ipairs(wl_extra) do wl[#wl + 1] = p end
|
end
|
||||||
if #wl > 0 then
|
end
|
||||||
for _, p in ipairs(wl) do
|
local wl = {}
|
||||||
if url:match(p) then return false end
|
for _, p in ipairs(whitelist) do
|
||||||
end
|
wl[#wl + 1] = p
|
||||||
return true
|
end
|
||||||
end
|
for _, p in ipairs(wl_extra) do
|
||||||
return false
|
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
|
end
|
||||||
|
|
||||||
function on_request(req)
|
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
|
end
|
||||||
|
|
||||||
function on_response(req)
|
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
|
end
|
||||||
|
|
||||||
function on_history_entry(entry)
|
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
|
end
|
||||||
|
|||||||
+168
-120
@@ -1,70 +1,91 @@
|
|||||||
Plugin = {
|
Plugin = {
|
||||||
name = "Secret Scan",
|
name = "Secret Scan",
|
||||||
description = [[
|
description = [[
|
||||||
Scans HTML, JavaScript and JSON content (requests and responses) for hardcoded
|
Scans HTML, JavaScript and JSON content (requests and responses) for hardcoded
|
||||||
secrets by matching common secret key names followed by a non-trivial value.
|
secrets by matching common secret key names followed by a non-trivial value.
|
||||||
|
|
||||||
Uses `grep -E` (available on all Unix systems, no extra dependencies).
|
Uses `grep -E` (available on all Unix systems, no extra dependencies).
|
||||||
]],
|
]],
|
||||||
on_request = { sync = false },
|
on_request = { sync = false },
|
||||||
on_response = { sync = false },
|
on_response = { sync = false },
|
||||||
disable_by_default = true,
|
disable_by_default = true,
|
||||||
}
|
}
|
||||||
|
|
||||||
local CONTENT_TYPES = {
|
local CONTENT_TYPES = {
|
||||||
"text/html",
|
"text/html",
|
||||||
"text/javascript",
|
"text/javascript",
|
||||||
"application/javascript",
|
"application/javascript",
|
||||||
"application/json",
|
"application/json",
|
||||||
}
|
}
|
||||||
|
|
||||||
-- Key name alternation (case-insensitive via grep -i)
|
-- Key name alternation (case-insensitive via grep -i)
|
||||||
-- Suffixes are required (no bare generic keyword alone).
|
-- Suffixes are required (no bare generic keyword alone).
|
||||||
local KEYS = {
|
local KEYS = {
|
||||||
"access(_key|_token)", "accessid_secret", "account(_key|_sid)",
|
"access(_key|_token)",
|
||||||
"admin_pass(word)?", "admin_user",
|
"accessid_secret",
|
||||||
"(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))",
|
"account(_key|_sid)",
|
||||||
"ansible_vault_password", "aos_key",
|
"admin_pass(word)?",
|
||||||
"api(_key|_secret|_token)",
|
"admin_user",
|
||||||
"app_(id|key|secret)", "application(_key|_id|_secret)",
|
"(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))",
|
||||||
"auth(_token|_secret|orization)", "authkey", "authsecret",
|
"ansible_vault_password",
|
||||||
"bearer_?token",
|
"aos_key",
|
||||||
"bucket(_password|_key)",
|
"api(_key|_secret|_token)",
|
||||||
"cert_?pass(word)?", "certificate_password",
|
"app_(id|key|secret)",
|
||||||
"client(_id|_secret)",
|
"application(_key|_id|_secret)",
|
||||||
"codecov_token", "consumer_(key|secret)",
|
"auth(_token|_secret|orization)",
|
||||||
"connection_?string", "credentials?", "crypt(_key|_secret)",
|
"authkey",
|
||||||
"db_(password|passwd|user(name)?)",
|
"authsecret",
|
||||||
"deploy(_key|_password|_token)",
|
"bearer_?token",
|
||||||
"docker_?pass(word)?", "dockerhub_?password",
|
"bucket(_password|_key)",
|
||||||
"encryption_(key|password)",
|
"cert_?pass(word)?",
|
||||||
"jwt_secret", "json_web_token",
|
"certificate_password",
|
||||||
"keycloak_secret", "kubernetes_token",
|
"client(_id|_secret)",
|
||||||
"ldap_(password|bindpw)", "login(_password|_token)",
|
"codecov_token",
|
||||||
"mail_?password", "mail_smtp_pass",
|
"consumer_(key|secret)",
|
||||||
"mysql_password", "mongo_password",
|
"connection_?string",
|
||||||
"netlify_token", "npm(_token|_auth_token)",
|
"credentials?",
|
||||||
"oauth(_token|_secret)",
|
"crypt(_key|_secret)",
|
||||||
"openai_(api_key|secret)",
|
"db_(password|passwd|user(name)?)",
|
||||||
"pass(word)?", "passwd",
|
"deploy(_key|_password|_token)",
|
||||||
"private(_key|_token)",
|
"docker_?pass(word)?",
|
||||||
"rds_password",
|
"dockerhub_?password",
|
||||||
"s3(_key|_secret|_access_key_id)",
|
"encryption_(key|password)",
|
||||||
"secret(_key|_token|_id)", "security_token",
|
"jwt_secret",
|
||||||
"sendgrid_api_key",
|
"json_web_token",
|
||||||
"ses_(smtp|access|secret)",
|
"keycloak_secret",
|
||||||
"service(_account|_key|_token)",
|
"kubernetes_token",
|
||||||
"smtp_pass(word)?", "smtp_secret",
|
"ldap_(password|bindpw)",
|
||||||
"sonar_token",
|
"login(_password|_token)",
|
||||||
"ssh(_key|_private_key|_rsa)",
|
"mail_?password",
|
||||||
"supabase(_anon|_service)?_key",
|
"mail_smtp_pass",
|
||||||
"symfony_secret",
|
"mysql_password",
|
||||||
"telegram_bot_token",
|
"mongo_password",
|
||||||
"token",
|
"netlify_token",
|
||||||
"travis_token",
|
"npm(_token|_auth_token)",
|
||||||
"vault(_token|_secret)",
|
"oauth(_token|_secret)",
|
||||||
"webhook(_secret|_token)",
|
"openai_(api_key|secret)",
|
||||||
"zapier_webhook_token",
|
"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.
|
-- Built once at load time.
|
||||||
@@ -74,93 +95,120 @@ local KEYS = {
|
|||||||
-- [[:space:]]*[:=] REQUIRED: actual = or : assignment operator
|
-- [[:space:]]*[:=] REQUIRED: actual = or : assignment operator
|
||||||
-- [[:space:]]*"? optional whitespace + opening quote
|
-- [[:space:]]*"? optional whitespace + opening quote
|
||||||
-- [a-zA-Z0-9+/=_.-]{8,} the secret value, at least 8 chars
|
-- [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 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 GREP_CMD = "grep -Eoni '" .. FULL_PAT .. "'"
|
||||||
|
|
||||||
local function is_relevant(ct)
|
local function is_relevant(ct)
|
||||||
if not ct or ct == "" then return false end
|
if not ct or ct == "" then
|
||||||
ct = ct:lower()
|
return false
|
||||||
for _, t in ipairs(CONTENT_TYPES) do
|
end
|
||||||
if ct:find(t, 1, true) then return true end
|
ct = ct:lower()
|
||||||
end
|
for _, t in ipairs(CONTENT_TYPES) do
|
||||||
return false
|
if ct:find(t, 1, true) then
|
||||||
|
return true
|
||||||
|
end
|
||||||
|
end
|
||||||
|
return false
|
||||||
end
|
end
|
||||||
|
|
||||||
local function build_context(lines, linenum)
|
local function build_context(lines, linenum)
|
||||||
local lo = math.max(1, linenum - 6)
|
local lo = math.max(1, linenum - 6)
|
||||||
local hi = math.min(#lines, linenum + 6)
|
local hi = math.min(#lines, linenum + 6)
|
||||||
|
|
||||||
local before, after = {}, {}
|
local before, after = {}, {}
|
||||||
for i = lo, linenum - 1 do
|
for i = lo, linenum - 1 do
|
||||||
local l = lines[i] or ""
|
local l = lines[i] or ""
|
||||||
if #l > 120 then l = l:sub(1, 120) .. "..." end
|
if #l > 120 then
|
||||||
table.insert(before, l)
|
l = l:sub(1, 120) .. "..."
|
||||||
end
|
end
|
||||||
for i = linenum + 1, hi do
|
table.insert(before, l)
|
||||||
local l = lines[i] or ""
|
end
|
||||||
if #l > 120 then l = l:sub(1, 120) .. "..." end
|
for i = linenum + 1, hi do
|
||||||
table.insert(after, l)
|
local l = lines[i] or ""
|
||||||
end
|
if #l > 120 then
|
||||||
|
l = l:sub(1, 120) .. "..."
|
||||||
|
end
|
||||||
|
table.insert(after, l)
|
||||||
|
end
|
||||||
|
|
||||||
local matched_line = lines[linenum] or ""
|
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 = {}
|
local parts = {}
|
||||||
if #before > 0 then
|
if #before > 0 then
|
||||||
table.insert(parts, "```\n" .. table.concat(before, "\n") .. "\n```")
|
table.insert(parts, "```\n" .. table.concat(before, "\n") .. "\n```")
|
||||||
end
|
end
|
||||||
table.insert(parts, "> **`" .. matched_line .. "`**")
|
table.insert(parts, "> **`" .. matched_line .. "`**")
|
||||||
if #after > 0 then
|
if #after > 0 then
|
||||||
table.insert(parts, "```\n" .. table.concat(after, "\n") .. "\n```")
|
table.insert(parts, "```\n" .. table.concat(after, "\n") .. "\n```")
|
||||||
end
|
end
|
||||||
return table.concat(parts, "\n\n")
|
return table.concat(parts, "\n\n")
|
||||||
end
|
end
|
||||||
|
|
||||||
local function scan(label, ct, body, host, path)
|
local function scan(label, ct, body, host, path)
|
||||||
if not is_relevant(ct) then return end
|
if not is_relevant(ct) then
|
||||||
if not body or body == "" then return end
|
return
|
||||||
|
end
|
||||||
|
if not body or body == "" then
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
local out, err = shell_pipe(GREP_CMD, body)
|
local out, err = shell_pipe(GREP_CMD, body)
|
||||||
if err and err ~= "" then
|
if err and err ~= "" then
|
||||||
log("grep error on " .. label .. " for " .. host .. path .. ": " .. err)
|
log("grep error on " .. label .. " for " .. host .. path .. ": " .. err)
|
||||||
return
|
return
|
||||||
end
|
end
|
||||||
if not out or out == "" then return end
|
if not out or out == "" then
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
local lines = {}
|
local lines = {}
|
||||||
for line in (body .. "\n"):gmatch("([^\n]*)\n") do
|
for line in (body .. "\n"):gmatch("([^\n]*)\n") do
|
||||||
table.insert(lines, line)
|
table.insert(lines, line)
|
||||||
end
|
end
|
||||||
|
|
||||||
for entry in out:gmatch("[^\n]+") do
|
for entry in out:gmatch("[^\n]+") do
|
||||||
local linenum_str, matched = entry:match("^(%d+):(.+)$")
|
local linenum_str, matched = entry:match("^(%d+):(.+)$")
|
||||||
if linenum_str then
|
if linenum_str then
|
||||||
local linenum = tonumber(linenum_str)
|
local linenum = tonumber(linenum_str)
|
||||||
matched = matched:match("^%s*(.-)%s*$")
|
matched = matched:match("^%s*(.-)%s*$")
|
||||||
if matched ~= "" then
|
if matched ~= "" then
|
||||||
local display = matched
|
local display = matched
|
||||||
if #display > 200 then display = display:sub(1, 200) .. "..." end
|
if #display > 200 then
|
||||||
local ctx = build_context(lines, linenum)
|
display = display:sub(1, 200) .. "..."
|
||||||
create_finding({
|
end
|
||||||
title = "Potential secret in " .. label .. " (" .. host .. ")",
|
local ctx = build_context(lines, linenum)
|
||||||
description = "**Host:** `" .. host .. "` \n**Path:** `" .. path .. "`\n\n**Match:** `" .. display .. "`\n\n" .. ctx,
|
create_finding({
|
||||||
key = host .. "|" .. path .. "|" .. label .. "|" .. matched,
|
title = "Potential secret in " .. label .. " (" .. host .. ")",
|
||||||
severity = "high",
|
description = "**Host:** `"
|
||||||
})
|
.. host
|
||||||
end
|
.. "`\n"
|
||||||
end
|
.. "\n**Path:** `"
|
||||||
end
|
.. path
|
||||||
|
.. "`\n"
|
||||||
|
.. "\n**Match:** `"
|
||||||
|
.. display
|
||||||
|
.. "`\n\n"
|
||||||
|
.. ctx,
|
||||||
|
key = host .. "|" .. path .. "|" .. label .. "|" .. matched,
|
||||||
|
severity = "high",
|
||||||
|
})
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
function on_request(req)
|
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
|
end
|
||||||
|
|
||||||
function on_response(req, res)
|
function on_response(req, res)
|
||||||
local ct = ""
|
local ct = ""
|
||||||
if res.headers then
|
if res.headers then
|
||||||
ct = res.headers["Content-Type"] or ""
|
ct = res.headers["Content-Type"] or ""
|
||||||
end
|
end
|
||||||
scan("response", ct, res:get_body(), req.host, req.path)
|
scan("response", ct, res:get_body(), req.host, req.path)
|
||||||
end
|
end
|
||||||
|
|||||||
+51
-43
@@ -1,61 +1,69 @@
|
|||||||
Plugin = {
|
Plugin = {
|
||||||
name = "TruffleHog",
|
name = "TruffleHog",
|
||||||
description = [[
|
description = [[
|
||||||
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 },
|
||||||
on_response = { sync = false },
|
on_response = { sync = false },
|
||||||
disable_by_default = true,
|
disable_by_default = true,
|
||||||
}
|
}
|
||||||
|
|
||||||
function on_start()
|
function on_start()
|
||||||
local result, _ = shell_pipe("command -v trufflehog 2>/dev/null")
|
local result, _ = shell_pipe("command -v trufflehog 2>/dev/null")
|
||||||
if not result or result:match("^%s*$") then
|
if not result or result:match("^%s*$") then
|
||||||
log("trufflehog is not installed or not in PATH")
|
log("trufflehog is not installed or not in PATH")
|
||||||
notif("TruffleHog", "trufflehog is not installed or not in PATH, plugin disabled", "error")
|
notif("TruffleHog", "trufflehog is not installed or not in PATH, plugin disabled", "error")
|
||||||
return false
|
return false
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
local function scan(label, content, host, path)
|
local function scan(label, content, host, path)
|
||||||
if not content or content == "" then return end
|
if not content or content == "" then
|
||||||
local out, err = shell_pipe("f=$(mktemp) && cat > \"$f\" && trufflehog filesystem --no-color \"$f\"; rc=$?; rm -f \"$f\"; exit $rc", content)
|
return
|
||||||
if err and err ~= "" then
|
end
|
||||||
log("trufflehog error on " .. label .. ": " .. err)
|
local out, err = shell_pipe(
|
||||||
return
|
'f=$(mktemp) && cat > "$f" && trufflehog filesystem --no-color "$f"; rc=$?; rm -f "$f"; exit $rc',
|
||||||
end
|
content
|
||||||
if not out or out == "" then return end
|
)
|
||||||
local blocks = {}
|
if err and err ~= "" then
|
||||||
local current = nil
|
log("trufflehog error on " .. label .. ": " .. err)
|
||||||
for line in out:gmatch("[^\n]+") do
|
return
|
||||||
if line:match("^Found ") then
|
end
|
||||||
if current then table.insert(blocks, current) end
|
if not out or out == "" then
|
||||||
current = line
|
return
|
||||||
elseif current then
|
end
|
||||||
current = current .. "\n" .. line
|
local blocks = {}
|
||||||
end
|
local current = nil
|
||||||
end
|
for line in out:gmatch("[^\n]+") do
|
||||||
if current then table.insert(blocks, current) end
|
if line:match("^Found ") then
|
||||||
for _, block in ipairs(blocks) do
|
if current then
|
||||||
create_finding({
|
table.insert(blocks, current)
|
||||||
title = "Secret detected in " .. label .. " (" .. host .. ")",
|
end
|
||||||
description = "**Host:** `" .. host .. "` \n**Path:** `" .. path .. "`\n\n```\n" .. block .. "\n```",
|
current = line
|
||||||
key = host .. "|" .. path .. "|" .. label .. "|" .. block,
|
elseif current then
|
||||||
severity = "high",
|
current = current .. "\n" .. line
|
||||||
})
|
end
|
||||||
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
|
end
|
||||||
|
|
||||||
function on_request(req)
|
function on_request(req)
|
||||||
scan("request", req:get_body(), req.host, req.path)
|
scan("request", req:get_body(), req.host, req.path)
|
||||||
end
|
end
|
||||||
|
|
||||||
function on_response(req, res)
|
function on_response(req, res)
|
||||||
scan("response", res:get_body(), req.host, req.path)
|
scan("response", res:get_body(), req.host, req.path)
|
||||||
end
|
end
|
||||||
|
|||||||
Reference in New Issue
Block a user