18 Commits

Author SHA1 Message Date
Hadi 2225afd9ee v0.0.5
Signed-off-by: Hadi <112569860+anotherhadi@users.noreply.github.com>
2026-05-19 23:08:18 +02:00
Hadi 6dc959de77 add sendtodiff in replay
Signed-off-by: Hadi <112569860+anotherhadi@users.noreply.github.com>
2026-05-19 23:06:26 +02:00
Hadi 0017f37c33 truncate title
Signed-off-by: Hadi <112569860+anotherhadi@users.noreply.github.com>
2026-05-19 23:06:06 +02:00
Hadi 924cb73afb refactor page/list movement
Signed-off-by: Hadi <112569860+anotherhadi@users.noreply.github.com>
2026-05-19 23:01:04 +02:00
Hadi 746f1afd1b edit write clipboard
Signed-off-by: Hadi <112569860+anotherhadi@users.noreply.github.com>
2026-05-19 23:00:41 +02:00
Hadi 905013943d edit keybind
Signed-off-by: Hadi <112569860+anotherhadi@users.noreply.github.com>
2026-05-19 23:00:19 +02:00
Hadi c6bca887cb Implement prevpage nextpage
Signed-off-by: Hadi <112569860+anotherhadi@users.noreply.github.com>
2026-05-19 21:58:26 +02:00
Hadi dcf9cb4c8e add a notifications when copied to clipboard
Signed-off-by: Hadi <112569860+anotherhadi@users.noreply.github.com>
2026-05-19 21:53:36 +02:00
Hadi ae372d7283 change default keybinds
Signed-off-by: Hadi <112569860+anotherhadi@users.noreply.github.com>
2026-05-19 21:53:13 +02:00
Hadi e20250f0a0 Init secret scan plugin #2
Signed-off-by: Hadi <112569860+anotherhadi@users.noreply.github.com>
2026-05-19 21:30:35 +02:00
Hadi 3463e51739 Copy func in findings
Signed-off-by: Hadi <112569860+anotherhadi@users.noreply.github.com>
2026-05-19 21:29:41 +02:00
Hadi 87fa9448d6 check if trufflehog is installed on_start
Signed-off-by: Hadi <112569860+anotherhadi@users.noreply.github.com>
2026-05-19 21:02:35 +02:00
Hadi 4240c4ceb9 fix ip filter
Signed-off-by: Hadi <112569860+anotherhadi@users.noreply.github.com>
2026-05-19 20:54:04 +02:00
Hadi d79c9f91d1 Make on_start run when the plugin is toggled
Signed-off-by: Hadi <112569860+anotherhadi@users.noreply.github.com>
2026-05-19 20:52:17 +02:00
Hadi 33e2afe709 Init trufflehog plugin
Signed-off-by: Hadi <112569860+anotherhadi@users.noreply.github.com>
2026-05-19 20:26:16 +02:00
Hadi 2c3e19258f Fix scroll & copy buttons
Signed-off-by: Hadi <112569860+anotherhadi@users.noreply.github.com>
2026-05-19 20:25:50 +02:00
Hadi 69d5d0ffec Add shell exec to plugins
Signed-off-by: Hadi <112569860+anotherhadi@users.noreply.github.com>
2026-05-19 20:00:04 +02:00
Hadi d47f51d2b5 Fix cursor/scroll jump
Signed-off-by: Hadi <112569860+anotherhadi@users.noreply.github.com>
2026-05-19 19:59:31 +02:00
30 changed files with 805 additions and 301 deletions
+36 -5
View File
@@ -31,14 +31,14 @@ Plugin = {
### Hook reference ### Hook reference
| Hook | When called | Sync/async | Return value (sync only) | | Hook | When called | Sync/async | Return value |
| ------------------------- | ------------------------------------- | ------------ | ----------------------------------------------- | | ------------------------- | ------------------------------------- | ------------ | ----------------------------------------------- |
| `on_config(config_text)` | At startup and on config save | always sync | ignored | | `on_config(config_text)` | At startup and on config save | always sync | ignored |
| `on_start()` | Once at startup, after `on_config` | configurable | ignored | | `on_start()` | Once at startup, after `on_config` | configurable | `false` to self-disable the plugin, otherwise ignored |
| `on_quit()` | When the app exits | always sync | ignored | | `on_quit()` | When the app exits | always sync | ignored |
| `on_request(req)` | Every request, before auto-forward | configurable | `"drop"`, `"forward"`, or `nil` | | `on_request(req)` | Every request, before auto-forward | configurable | `"drop"`, `"forward"`, or `nil` (sync only) |
| `on_response(req, res)` | Every response | configurable | `"drop"`, `"forward"`, or `nil` | | `on_response(req, res)` | Every response | configurable | `"drop"`, `"forward"`, or `nil` (sync only) |
| `on_history_entry(entry)` | Sync: before DB insert / Async: after | configurable | `"skip"` (don't save), `"keep"` or `nil` (save) | | `on_history_entry(entry)` | Sync: before DB insert / Async: after | configurable | `"skip"` (don't save), `"keep"` or `nil` (save) -- sync only |
## Request and response objects ## Request and response objects
@@ -110,6 +110,16 @@ end
-- Quit the app (useful for startup checks that fail) -- Quit the app (useful for startup checks that fail)
quit("reason message") quit("reason message")
-- Run a shell command, optionally piping a string to its stdin.
-- Returns: output string, error string (nil on success).
-- The command runs via "sh -c" with a 30-second timeout.
local out, err = shell_pipe("trufflehog filesystem --no-update --json /dev/stdin", body)
if err then
log("command failed: " .. err)
else
log("output: " .. out)
end
``` ```
### Finding deduplication ### Finding deduplication
@@ -131,6 +141,27 @@ Each plugin gets a **config textarea** on the Plugins page. The raw text is pass
### Return values for sync hooks ### Return values for sync hooks
**`on_start`:**
| Return value | Effect |
| ------------ | -------------------------------------------------------------------------------------------- |
| `false` | The plugin is disabled immediately and the state is persisted (equivalent to toggling it off). |
| anything else | Ignored. |
This is useful for prerequisite checks (binary not found, config invalid, etc.) so the plugin does not silently run in a broken state:
```lua
function on_start()
local h = io.popen("command -v mytool 2>/dev/null")
local result = h and h:read("*a") or ""
if h then h:close() end
if result:match("^%s*$") then
notif("MyPlugin", "mytool not found, plugin disabled", "error")
return false
end
end
```
**`on_request` and `on_response`:** **`on_request` and `on_response`:**
| Return value | Effect | | Return value | Effect |
+1 -1
View File
@@ -14,7 +14,7 @@
(system: f system (import nixpkgs {inherit system;})); (system: f system (import nixpkgs {inherit system;}));
pname = "spilltea"; pname = "spilltea";
version = "0.0.4"; version = "0.0.5";
ldflags = ["-s" "-w" "-X main.version=${version}"]; ldflags = ["-s" "-w" "-X main.version=${version}"];
in { in {
+15 -15
View File
@@ -47,35 +47,35 @@ tui:
keybindings: keybindings:
global: global:
quit: "q,ctrl+c" quit: "q,ctrl+c"
help: "?"
open_logs: "ctrl+g" open_logs: "ctrl+g"
toggle_sidebar: "ctrl+b" toggle_sidebar: "ctrl+b"
help: "?" cycle_focus: "tab"
send_to_replay: "ctrl+r"
send_to_diff: "ctrl+d"
copy_as: "ctrl+y"
copy: "y"
up: "up,k" up: "up,k"
down: "down,j" down: "down,j"
left: "left,h" left: "left,h"
right: "right,l" right: "right,l"
cycle_focus: "tab" goto_top: "g"
copy_as: "ctrl+y" goto_bottom: "G,end"
copy: "y"
send_to_replay: "ctrl+r"
scroll_up: "pgup" scroll_up: "pgup"
scroll_down: "pgdown" scroll_down: "pgdown"
send_to_diff: "ctrl+d"
goto_top: "home"
goto_bottom: "G,end"
prev_page: "[" prev_page: "["
next_page: "]" next_page: "]"
intercept: intercept:
toggle_intercept: "i"
capture_response: "r"
forward: "f" forward: "f"
forward_all: "F" forward_all: "F"
drop: "d" drop: "d"
drop_all: "D" drop_all: "D"
toggle_intercept: "i"
capture_response: "r"
undo_edits: "ctrl+z"
edit: "e,enter" edit: "e,enter"
edit_external: "E" edit_external: "E"
undo_edits: "ctrl+z"
history: history:
delete_entry: "x" delete_entry: "x"
@@ -85,20 +85,20 @@ keybindings:
flag: "m" flag: "m"
home: home:
open: "enter,l" open: "l,enter"
delete: "x" delete: "x"
filter: "/" filter: "/"
replay: replay:
send: "enter,s" send: "s, enter"
edit: "e" edit: "e"
edit_external: "E" edit_external: "E"
undo_edits: "R" undo_edits: "ctrl+z"
delete_entry: "x" delete_entry: "x"
delete_all: "X" delete_all: "X"
diff: diff:
clear: "c" clear: "x"
findings: findings:
dismiss: "x" dismiss: "x"
+2
View File
@@ -17,6 +17,8 @@ type Finding struct {
// UpsertFinding inserts the finding if the (plugin_name, dedup_key) pair does // UpsertFinding inserts the finding if the (plugin_name, dedup_key) pair does
// not already exist. Returns true when the row was actually inserted. // not already exist. Returns true when the row was actually inserted.
func (d *DB) UpsertFinding(f Finding) (bool, error) { func (d *DB) UpsertFinding(f Finding) (bool, error) {
d.dedupMu.Lock()
defer d.dedupMu.Unlock()
res, err := d.conn.Exec( res, err := d.conn.Exec(
`INSERT OR IGNORE INTO findings (plugin_name, dedup_key, title, description, severity, dismissed, created_at) `INSERT OR IGNORE INTO findings (plugin_name, dedup_key, title, description, severity, dismissed, created_at)
VALUES (?, ?, ?, ?, ?, 0, ?)`, VALUES (?, ?, ?, ?, ?, 0, ?)`,
+32 -7
View File
@@ -1,7 +1,10 @@
package plugins package plugins
import ( import (
"bytes"
"context"
"log" "log"
"os/exec"
"strings" "strings"
"time" "time"
@@ -171,6 +174,31 @@ func registerUtilities(L *lua.LState, mgr *Manager, p *Plugin) {
} }
return 0 return 0
})) }))
L.SetGlobal("shell_pipe", L.NewFunction(func(L *lua.LState) int {
cmd := L.CheckString(1)
input := L.OptString(2, "")
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
c := exec.CommandContext(ctx, "sh", "-c", cmd)
c.Stdin = strings.NewReader(input)
var stdout, stderr bytes.Buffer
c.Stdout = &stdout
c.Stderr = &stderr
err := c.Run()
if err != nil {
L.Push(lua.LString(stdout.String()))
L.Push(lua.LString(err.Error() + ": " + stderr.String()))
return 2
}
L.Push(lua.LString(stdout.String()))
L.Push(lua.LNil)
return 2
}))
} }
func luaTableString(t *lua.LTable, key string) string { func luaTableString(t *lua.LTable, key string) string {
@@ -264,22 +292,19 @@ func pushEntry(L *lua.LState, e db.Entry) *lua.LTable {
return t return t
} }
func callHook(p *Plugin, hookName string, args ...lua.LValue) (string, error) { func callHook(p *Plugin, hookName string, args ...lua.LValue) (lua.LValue, error) {
fn := p.L.GetGlobal(hookName) fn := p.L.GetGlobal(hookName)
if fn == lua.LNil { if fn == lua.LNil {
return "", nil return lua.LNil, nil
} }
if err := p.L.CallByParam(lua.P{ if err := p.L.CallByParam(lua.P{
Fn: fn, Fn: fn,
NRet: 1, NRet: 1,
Protect: true, Protect: true,
}, args...); err != nil { }, args...); err != nil {
return "", err return lua.LNil, err
} }
ret := p.L.Get(-1) ret := p.L.Get(-1)
p.L.Pop(1) p.L.Pop(1)
if s, ok := ret.(lua.LString); ok { return ret, nil
return string(s), nil
}
return "", nil
} }
+60 -8
View File
@@ -184,6 +184,42 @@ func (m *Manager) TogglePlugin(name string) {
if m.db != nil { if m.db != nil {
_ = m.db.SavePluginState(name, enabled, configText) _ = m.db.SavePluginState(name, enabled, configText)
} }
if !enabled {
return
}
hc, ok := found.hooks["on_start"]
if !ok {
return
}
disableIfFalse := func(p *Plugin, ret lua.LValue) {
if ret == lua.LFalse {
p.Enabled = false
if m.db != nil {
_ = m.db.SavePluginState(p.Name, false, p.ConfigText)
}
}
}
if hc.Sync {
found.mu.Lock()
ret, err := callHook(found, "on_start")
if err != nil {
log.Printf("plugin %s on_start: %v", found.Name, err)
} else {
disableIfFalse(found, ret)
}
found.mu.Unlock()
} else {
go func() {
found.mu.Lock()
ret, err := callHook(found, "on_start")
if err != nil {
log.Printf("plugin %s on_start: %v", found.Name, err)
} else {
disableIfFalse(found, ret)
}
found.mu.Unlock()
}()
}
} }
func (m *Manager) SaveConfig(name, configText string) { func (m *Manager) SaveConfig(name, configText string) {
@@ -242,17 +278,31 @@ func (m *Manager) RunOnStart() {
if !ok { if !ok {
continue continue
} }
disableIfFalse := func(p *Plugin, ret lua.LValue) {
if ret == lua.LFalse {
p.Enabled = false
if m.db != nil {
_ = m.db.SavePluginState(p.Name, false, p.ConfigText)
}
}
}
if hc.Sync { if hc.Sync {
p.mu.Lock() p.mu.Lock()
if _, err := callHook(p, "on_start"); err != nil { ret, err := callHook(p, "on_start")
if err != nil {
log.Printf("plugin %s on_start: %v", p.Name, err) log.Printf("plugin %s on_start: %v", p.Name, err)
} else {
disableIfFalse(p, ret)
} }
p.mu.Unlock() p.mu.Unlock()
} else { } else {
go func(p *Plugin) { go func(p *Plugin) {
p.mu.Lock() p.mu.Lock()
if _, err := callHook(p, "on_start"); err != nil { ret, err := callHook(p, "on_start")
if err != nil {
log.Printf("plugin %s on_start: %v", p.Name, err) log.Printf("plugin %s on_start: %v", p.Name, err)
} else {
disableIfFalse(p, ret)
} }
p.mu.Unlock() p.mu.Unlock()
}(p) }(p)
@@ -294,11 +344,13 @@ func (m *Manager) runSyncDecisionForPlugins(hookName string, argsFor func(*Plugi
log.Printf("plugin %s %s: %v", p.Name, hookName, err) log.Printf("plugin %s %s: %v", p.Name, hookName, err)
continue continue
} }
switch result { if s, ok := result.(lua.LString); ok {
case "drop": switch string(s) {
return intercept.Drop case "drop":
case "forward": return intercept.Drop
return intercept.Forward case "forward":
return intercept.Forward
}
} }
} }
return intercept.Intercept return intercept.Intercept
@@ -366,7 +418,7 @@ func (m *Manager) RunSyncOnHistoryEntry(e db.Entry) bool {
log.Printf("plugin %s on_history_entry: %v", p.Name, err) log.Printf("plugin %s on_history_entry: %v", p.Name, err)
continue continue
} }
if result == "skip" { if s, ok := result.(lua.LString); ok && string(s) == "skip" {
return false return false
} }
} }
+25 -8
View File
@@ -187,45 +187,62 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch { switch {
case key.Matches(msg, keys.Keys.Global.CopyAs): case key.Matches(msg, keys.Keys.Global.CopyAs):
var raw, scheme string var raw, scheme string
var responseFocused bool
switch m.page { switch m.page {
case pageDiff:
raw = m.diff.CurrentRaw()
scheme = "https"
case pageIntercept: case pageIntercept:
raw = m.intercept.CurrentRaw() raw = m.intercept.CurrentRaw()
scheme = m.intercept.CurrentScheme() scheme = m.intercept.CurrentScheme()
responseFocused = m.intercept.IsResponseFocused()
case pageHistory: case pageHistory:
raw = m.history.CurrentRaw() raw = m.history.CurrentRaw()
scheme = m.history.CurrentScheme() scheme = m.history.CurrentScheme()
responseFocused = m.history.IsResponseFocused()
case pageReplay: case pageReplay:
raw = m.replay.CurrentRaw() raw = m.replay.CurrentRaw()
scheme = m.replay.CurrentScheme() scheme = m.replay.CurrentScheme()
responseFocused = m.replay.IsResponseFocused()
} }
if raw != "" { if raw != "" && !responseFocused {
m.copyAs.SetSize(m.width, m.height) m.copyAs.SetSize(m.width, m.height)
m.copyAs.Open(copyasUI.OpenMsg{RawRequest: raw, Scheme: scheme}) m.copyAs.Open(copyasUI.OpenMsg{RawRequest: raw, Scheme: scheme})
} }
return m, nil return m, nil
case key.Matches(msg, keys.Keys.Global.Copy): case key.Matches(msg, keys.Keys.Global.Copy):
if m.page == pageFindings {
if md := m.findingsPage.CurrentMarkdown(); md != "" {
return m, tea.Batch(
tea.SetClipboard(md),
func() tea.Msg {
return notificationsUI.NotificationMsg{
Title: "Copied",
Body: "Finding copied to clipboard",
Kind: notificationsUI.KindSuccess,
}
},
)
}
return m, nil
}
var raw, scheme string var raw, scheme string
var responseFocused bool
switch m.page { switch m.page {
case pageIntercept: case pageIntercept:
raw = m.intercept.CurrentRaw() raw = m.intercept.CurrentRaw()
scheme = m.intercept.CurrentScheme() scheme = m.intercept.CurrentScheme()
case pageDiff: responseFocused = m.intercept.IsResponseFocused()
raw = m.diff.CurrentRaw()
scheme = "https"
case pageHistory: case pageHistory:
raw = m.history.CurrentRaw() raw = m.history.CurrentRaw()
scheme = m.history.CurrentScheme() scheme = m.history.CurrentScheme()
responseFocused = m.history.IsResponseFocused()
case pageReplay: case pageReplay:
raw = m.replay.CurrentRaw() raw = m.replay.CurrentRaw()
scheme = m.replay.CurrentScheme() scheme = m.replay.CurrentScheme()
responseFocused = m.replay.IsResponseFocused()
} }
if raw != "" { if raw != "" {
m.copy.SetSize(m.width, m.height) m.copy.SetSize(m.width, m.height)
m.copy.Open(copyUI.OpenMsg{RawRequest: raw, Scheme: scheme}) m.copy.Open(copyUI.OpenMsg{RawRequest: raw, Scheme: scheme, ShowURL: !responseFocused})
} }
return m, nil return m, nil
+12 -7
View File
@@ -1,9 +1,6 @@
package copy package copy
import ( import (
"encoding/base64"
"fmt"
"os"
"strings" "strings"
"charm.land/bubbles/v2/list" "charm.land/bubbles/v2/list"
@@ -17,14 +14,11 @@ const (
popupH = 20 popupH = 20
) )
func writeClipboard(text string) {
encoded := base64.StdEncoding.EncodeToString([]byte(text))
fmt.Fprintf(os.Stderr, "\033]52;c;%s\a", encoded)
}
type OpenMsg struct { type OpenMsg struct {
RawRequest string RawRequest string
Scheme string Scheme string
ShowURL bool
} }
type copyItem struct { type copyItem struct {
@@ -90,6 +84,17 @@ func (m *Model) Open(msg OpenMsg) {
m.rawRequest = msg.RawRequest m.rawRequest = msg.RawRequest
m.scheme = msg.Scheme m.scheme = msg.Scheme
m.open = true m.open = true
items := allItems
if !msg.ShowURL {
filtered := make([]list.Item, 0, len(allItems))
for _, it := range allItems {
if it.(copyItem).id != "url" {
filtered = append(filtered, it)
}
}
items = filtered
}
m.list.SetItems(items)
m.list.ResetFilter() m.list.ResetFilter()
m.list.Select(0) m.list.Select(0)
m.list.SetSize(m.popupInnerWidth(), m.listHeight()) m.list.SetSize(m.popupInnerWidth(), m.listHeight())
+13 -3
View File
@@ -4,16 +4,26 @@ import (
"charm.land/bubbles/v2/key" "charm.land/bubbles/v2/key"
tea "charm.land/bubbletea/v2" tea "charm.land/bubbletea/v2"
"github.com/anotherhadi/spilltea/internal/keys" "github.com/anotherhadi/spilltea/internal/keys"
notificationsUI "github.com/anotherhadi/spilltea/internal/ui/components/notifications"
) )
func (m Model) Update(msg tea.Msg) (Model, tea.Cmd) { func (m Model) Update(msg tea.Msg) (Model, tea.Cmd) {
if kp, ok := msg.(tea.KeyPressMsg); ok { if kp, ok := msg.(tea.KeyPressMsg); ok {
switch { switch {
case kp.String() == "enter": case kp.String() == "enter":
if item, ok := m.list.SelectedItem().(copyItem); ok {
writeClipboard(m.extract(item.id))
}
m.open = false m.open = false
if item, ok := m.list.SelectedItem().(copyItem); ok {
return m, tea.Batch(
tea.SetClipboard(m.extract(item.id)),
func() tea.Msg {
return notificationsUI.NotificationMsg{
Title: "Copied",
Body: "Request copied to clipboard",
Kind: notificationsUI.KindSuccess,
}
},
)
}
return m, nil return m, nil
case key.Matches(kp, keys.Keys.Global.Escape): case key.Matches(kp, keys.Keys.Global.Escape):
if m.list.SettingFilter() { if m.list.SettingFilter() {
-10
View File
@@ -1,10 +1,6 @@
package copyas package copyas
import ( import (
"encoding/base64"
"fmt"
"os"
"charm.land/bubbles/v2/list" "charm.land/bubbles/v2/list"
tea "charm.land/bubbletea/v2" tea "charm.land/bubbletea/v2"
"charm.land/lipgloss/v2" "charm.land/lipgloss/v2"
@@ -16,12 +12,6 @@ const (
popupH = 20 popupH = 20
) )
// writeClipboard uses the OSC 52 terminal escape sequence to set the clipboard.
// Supported by most modern terminals (foot, kitty, wezterm, alacritty, xterm…).
func writeClipboard(text string) {
encoded := base64.StdEncoding.EncodeToString([]byte(text))
fmt.Fprintf(os.Stderr, "\033]52;c;%s\a", encoded)
}
type OpenMsg struct { type OpenMsg struct {
RawRequest string RawRequest string
+13 -3
View File
@@ -4,16 +4,26 @@ import (
"charm.land/bubbles/v2/key" "charm.land/bubbles/v2/key"
tea "charm.land/bubbletea/v2" tea "charm.land/bubbletea/v2"
"github.com/anotherhadi/spilltea/internal/keys" "github.com/anotherhadi/spilltea/internal/keys"
notificationsUI "github.com/anotherhadi/spilltea/internal/ui/components/notifications"
) )
func (m Model) Update(msg tea.Msg) (Model, tea.Cmd) { func (m Model) Update(msg tea.Msg) (Model, tea.Cmd) {
if kp, ok := msg.(tea.KeyPressMsg); ok { if kp, ok := msg.(tea.KeyPressMsg); ok {
switch { switch {
case kp.String() == "enter": case kp.String() == "enter":
if item, ok := m.list.SelectedItem().(formatItem); ok {
writeClipboard(formatAs(item.id, m.rawRequest, m.scheme))
}
m.open = false m.open = false
if item, ok := m.list.SelectedItem().(formatItem); ok {
return m, tea.Batch(
tea.SetClipboard(formatAs(item.id, m.rawRequest, m.scheme)),
func() tea.Msg {
return notificationsUI.NotificationMsg{
Title: "Copied",
Body: "Request copied to clipboard",
Kind: notificationsUI.KindSuccess,
}
},
)
}
return m, nil return m, nil
case key.Matches(kp, keys.Keys.Global.Escape): case key.Matches(kp, keys.Keys.Global.Escape):
if m.list.SettingFilter() { if m.list.SettingFilter() {
+1 -1
View File
@@ -405,7 +405,7 @@ func (diffKeyMap) ShortHelp() []key.Binding {
func (m diffKeyMap) FullHelp() [][]key.Binding { func (m diffKeyMap) FullHelp() [][]key.Binding {
g := keys.Keys.Global g := keys.Keys.Global
pageGlobals := []key.Binding{g.Up, g.Down, g.CycleFocus, g.ScrollUp, g.ScrollDown, g.Left, g.Right, g.Copy, g.CopyAs} pageGlobals := []key.Binding{g.Up, g.Down, g.CycleFocus, g.ScrollUp, g.ScrollDown, g.Left, g.Right}
all := append(keys.Keys.Diff.Bindings(), pageGlobals...) all := append(keys.Keys.Diff.Bindings(), pageGlobals...)
all = append(all, g.CommonBindings()...) all = append(all, g.CommonBindings()...)
return keys.ChunkByWidth(all, m.width) return keys.ChunkByWidth(all, m.width)
+7
View File
@@ -7,6 +7,7 @@ import (
"charm.land/lipgloss/v2" "charm.land/lipgloss/v2"
"github.com/anotherhadi/spilltea/internal/icons" "github.com/anotherhadi/spilltea/internal/icons"
"github.com/anotherhadi/spilltea/internal/style" "github.com/anotherhadi/spilltea/internal/style"
"github.com/charmbracelet/x/ansi"
) )
func (m Model) View() tea.View { func (m Model) View() tea.View {
@@ -38,6 +39,12 @@ func (m *Model) renderPanels(panelH int) string {
if m.right.label != "" { if m.right.label != "" {
rightTitle = icons.I.Diff + "Second: " + m.right.label rightTitle = icons.I.Diff + "Second: " + m.right.label
} }
if maxW := leftW - 4; maxW > 0 {
leftTitle = ansi.Truncate(leftTitle, maxW, "…")
}
if maxW := rightW - 4; maxW > 0 {
rightTitle = ansi.Truncate(rightTitle, maxW, "…")
}
leftBorder := s.Panel leftBorder := s.Panel
rightBorder := s.Panel rightBorder := s.Panel
+4 -16
View File
@@ -4,6 +4,7 @@ import (
"charm.land/bubbles/v2/key" "charm.land/bubbles/v2/key"
tea "charm.land/bubbletea/v2" tea "charm.land/bubbletea/v2"
"github.com/anotherhadi/spilltea/internal/keys" "github.com/anotherhadi/spilltea/internal/keys"
"github.com/anotherhadi/spilltea/internal/util"
) )
func (e Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { func (e Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
@@ -12,12 +13,7 @@ func (e Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) { switch msg := msg.(type) {
case tea.MouseWheelMsg: case tea.MouseWheelMsg:
switch msg.Button { util.HandleMouseWheel(msg, &e.viewport)
case tea.MouseWheelUp:
e.viewport.SetYOffset(e.viewport.YOffset() - 1)
case tea.MouseWheelDown:
e.viewport.SetYOffset(e.viewport.YOffset() + 1)
}
case tea.KeyPressMsg: case tea.KeyPressMsg:
if e.searching { if e.searching {
@@ -61,17 +57,9 @@ func (e Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
case key.Matches(msg, g.Down): case key.Matches(msg, g.Down):
e.viewport.SetYOffset(e.viewport.YOffset() + 1) e.viewport.SetYOffset(e.viewport.YOffset() + 1)
case key.Matches(msg, g.ScrollUp): case key.Matches(msg, g.ScrollUp):
step := e.viewport.Height() / 2 util.ScrollViewport(&e.viewport, -1)
if step < 1 {
step = 1
}
e.viewport.SetYOffset(e.viewport.YOffset() - step)
case key.Matches(msg, g.ScrollDown): case key.Matches(msg, g.ScrollDown):
step := e.viewport.Height() / 2 util.ScrollViewport(&e.viewport, 1)
if step < 1 {
step = 1
}
e.viewport.SetYOffset(e.viewport.YOffset() + step)
case key.Matches(msg, g.Help): case key.Matches(msg, g.Help):
e.help.ShowAll = !e.help.ShowAll e.help.ShowAll = !e.help.ShowAll
e.SetSize(e.width, e.height) e.SetSize(e.width, e.height)
+21 -3
View File
@@ -46,6 +46,14 @@ func New() Model {
func (m Model) Init() tea.Cmd { return nil } func (m Model) Init() tea.Cmd { return nil }
func (m *Model) CurrentMarkdown() string {
if len(m.findings) == 0 {
return ""
}
f := m.findings[m.cursor]
return "# " + f.Title + "\n\n" + f.Description
}
func (m *Model) SetDB(d *db.DB) { func (m *Model) SetDB(d *db.DB) {
m.database = d m.database = d
} }
@@ -113,6 +121,14 @@ type FindingsLoadedMsg struct {
} }
func (m *Model) refreshBody() { func (m *Model) refreshBody() {
m.refreshBodyScroll(true)
}
func (m *Model) refreshBodyKeepScroll() {
m.refreshBodyScroll(false)
}
func (m *Model) refreshBodyScroll(reset bool) {
if len(m.findings) == 0 { if len(m.findings) == 0 {
m.bodyViewport.SetContent("") m.bodyViewport.SetContent("")
return return
@@ -120,7 +136,9 @@ func (m *Model) refreshBody() {
f := m.findings[m.cursor] f := m.findings[m.cursor]
rendered := m.renderMarkdownCached(f.Description, m.bodyViewport.Width()) rendered := m.renderMarkdownCached(f.Description, m.bodyViewport.Width())
m.bodyViewport.SetContent(rendered) m.bodyViewport.SetContent(rendered)
m.bodyViewport.GotoTop() if reset {
m.bodyViewport.GotoTop()
}
} }
func (m *Model) renderMarkdownCached(src string, width int) string { func (m *Model) renderMarkdownCached(src string, width int) string {
@@ -164,12 +182,12 @@ type findingsKeyMap struct{ width int }
func (findingsKeyMap) ShortHelp() []key.Binding { func (findingsKeyMap) ShortHelp() []key.Binding {
g := keys.Keys.Global g := keys.Keys.Global
f := keys.Keys.Findings f := keys.Keys.Findings
return []key.Binding{g.Up, g.Down, f.Dismiss, g.Help} return []key.Binding{g.Up, g.Down, f.Dismiss, g.Copy, g.Help}
} }
func (m findingsKeyMap) FullHelp() [][]key.Binding { func (m findingsKeyMap) FullHelp() [][]key.Binding {
g := keys.Keys.Global g := keys.Keys.Global
pageGlobals := []key.Binding{g.Up, g.Down, g.ScrollUp, g.ScrollDown} pageGlobals := []key.Binding{g.Up, g.Down, g.ScrollUp, g.ScrollDown, g.Copy}
all := append(keys.Keys.Findings.Bindings(), pageGlobals...) all := append(keys.Keys.Findings.Bindings(), pageGlobals...)
all = append(all, g.CommonBindings()...) all = append(all, g.CommonBindings()...)
return keys.ChunkByWidth(all, m.width) return keys.ChunkByWidth(all, m.width)
+23 -42
View File
@@ -6,6 +6,7 @@ import (
"charm.land/bubbles/v2/key" "charm.land/bubbles/v2/key"
tea "charm.land/bubbletea/v2" tea "charm.land/bubbletea/v2"
"github.com/anotherhadi/spilltea/internal/keys" "github.com/anotherhadi/spilltea/internal/keys"
"github.com/anotherhadi/spilltea/internal/util"
) )
func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
@@ -15,6 +16,10 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
log.Printf("findings load error: %v", msg.Err) log.Printf("findings load error: %v", msg.Err)
return m, nil return m, nil
} }
var prevID int64
if len(m.findings) > 0 && m.cursor < len(m.findings) {
prevID = m.findings[m.cursor].ID
}
m.findings = msg.Findings m.findings = msg.Findings
if m.cursor >= len(m.findings) { if m.cursor >= len(m.findings) {
m.cursor = max(0, len(m.findings)-1) m.cursor = max(0, len(m.findings)-1)
@@ -26,16 +31,19 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
m.pager.SetTotalPages(len(m.findings)) m.pager.SetTotalPages(len(m.findings))
} }
m.refreshListViewport() m.refreshListViewport()
m.refreshBody() var newID int64
if len(m.findings) > 0 && m.cursor < len(m.findings) {
newID = m.findings[m.cursor].ID
}
if newID != prevID {
m.refreshBody()
} else {
m.refreshBodyKeepScroll()
}
return m, nil return m, nil
case tea.MouseWheelMsg: case tea.MouseWheelMsg:
switch msg.Button { util.HandleMouseWheel(msg, &m.bodyViewport)
case tea.MouseWheelUp:
m.bodyViewport.SetYOffset(m.bodyViewport.YOffset() - 1)
case tea.MouseWheelDown:
m.bodyViewport.SetYOffset(m.bodyViewport.YOffset() + 1)
}
return m, nil return m, nil
case tea.KeyPressMsg: case tea.KeyPressMsg:
@@ -70,17 +78,9 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
return m, RefreshCmd(m.database) return m, RefreshCmd(m.database)
} }
case key.Matches(msg, g.ScrollUp): case key.Matches(msg, g.ScrollUp):
step := m.bodyViewport.Height() / 2 util.ScrollViewport(&m.bodyViewport, -1)
if step < 1 {
step = 1
}
m.bodyViewport.SetYOffset(m.bodyViewport.YOffset() - step)
case key.Matches(msg, g.ScrollDown): case key.Matches(msg, g.ScrollDown):
step := m.bodyViewport.Height() / 2 util.ScrollViewport(&m.bodyViewport, 1)
if step < 1 {
step = 1
}
m.bodyViewport.SetYOffset(m.bodyViewport.YOffset() + step)
case key.Matches(msg, g.GotoTop): case key.Matches(msg, g.GotoTop):
m.cursor = 0 m.cursor = 0
m.pager.Page = 0 m.pager.Page = 0
@@ -88,38 +88,19 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
m.refreshBody() m.refreshBody()
case key.Matches(msg, g.GotoBottom): case key.Matches(msg, g.GotoBottom):
if len(m.findings) > 0 { m.cursor = util.CursorGotoBottom(len(m.findings))
m.cursor = len(m.findings) - 1 m.pager.Page = util.CursorGotoBottom(m.pager.TotalPages)
m.pager.Page = m.pager.TotalPages - 1 m.refreshListViewport()
m.refreshListViewport() m.refreshBody()
m.refreshBody()
}
case key.Matches(msg, g.PrevPage): case key.Matches(msg, g.PrevPage):
step := m.pager.PerPage m.cursor = util.CursorMovePage(m.cursor, len(m.findings), m.pager.PerPage, false)
if step < 1 {
step = 1
}
m.cursor -= step
if m.cursor < 0 {
m.cursor = 0
}
m.pager.Page = m.cursor / m.pager.PerPage m.pager.Page = m.cursor / m.pager.PerPage
m.refreshListViewport() m.refreshListViewport()
m.refreshBody() m.refreshBody()
case key.Matches(msg, g.NextPage): case key.Matches(msg, g.NextPage):
step := m.pager.PerPage m.cursor = util.CursorMovePage(m.cursor, len(m.findings), m.pager.PerPage, true)
if step < 1 {
step = 1
}
m.cursor += step
if m.cursor >= len(m.findings) {
m.cursor = len(m.findings) - 1
if m.cursor < 0 {
m.cursor = 0
}
}
m.pager.Page = m.cursor / m.pager.PerPage m.pager.Page = m.cursor / m.pager.PerPage
m.refreshListViewport() m.refreshListViewport()
m.refreshBody() m.refreshBody()
+7
View File
@@ -60,9 +60,16 @@ func (m Model) CurrentRaw() string {
if len(m.entries) == 0 || m.cursor >= len(m.entries) { if len(m.entries) == 0 || m.cursor >= len(m.entries) {
return "" return ""
} }
if m.focusedPanel == panelResponse {
return m.entries[m.cursor].ResponseRaw
}
return m.entries[m.cursor].RequestRaw return m.entries[m.cursor].RequestRaw
} }
func (m Model) IsResponseFocused() bool {
return m.focusedPanel == panelResponse
}
func (m Model) CurrentScheme() string { func (m Model) CurrentScheme() string {
if len(m.entries) == 0 || m.cursor >= len(m.entries) { if len(m.entries) == 0 || m.cursor >= len(m.entries) {
return "https" return "https"
+30 -57
View File
@@ -36,18 +36,36 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
if m.searchKind != searchKindOff && (m.searchAccepted || m.searchInput.Value() != "") { if m.searchKind != searchKindOff && (m.searchAccepted || m.searchInput.Value() != "") {
return m, nil return m, nil
} }
prevCursor := m.cursor // Remember the selected entry's ID so we can re-anchor after the list is
// reloaded (new entries are prepended; a pure index-based cursor would
// silently jump to a different entry).
var selectedID int64
if m.cursor >= 0 && m.cursor < len(m.entries) {
selectedID = m.entries[m.cursor].ID
}
m.entries = msg.Entries m.entries = msg.Entries
entryChanged := true
if selectedID != 0 {
for i, e := range m.entries {
if e.ID == selectedID {
m.cursor = i
entryChanged = false
break
}
}
}
if m.cursor >= len(m.entries) { if m.cursor >= len(m.entries) {
m.cursor = len(m.entries) - 1 m.cursor = len(m.entries) - 1
entryChanged = true
} }
if m.cursor < 0 { if m.cursor < 0 {
m.cursor = 0 m.cursor = 0
entryChanged = true
} }
m.pager.SetTotalPages(len(m.entries)) m.pager.SetTotalPages(len(m.entries))
m.refreshListViewport() m.refreshListViewport()
m.refreshBody() m.refreshBody()
if m.cursor != prevCursor { if entryChanged {
m.bodyViewport.SetYOffset(0) m.bodyViewport.SetYOffset(0)
m.bodyViewport.SetXOffset(0) m.bodyViewport.SetXOffset(0)
} }
@@ -75,24 +93,7 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
m.bodyViewport.SetXOffset(0) m.bodyViewport.SetXOffset(0)
case tea.MouseWheelMsg: case tea.MouseWheelMsg:
switch msg.Button { util.HandleMouseWheel(msg, &m.bodyViewport)
case tea.MouseWheelUp:
if msg.Mod.Contains(tea.ModShift) {
m.bodyViewport.ScrollLeft(6)
} else {
m.bodyViewport.SetYOffset(m.bodyViewport.YOffset() - 1)
}
case tea.MouseWheelDown:
if msg.Mod.Contains(tea.ModShift) {
m.bodyViewport.ScrollRight(6)
} else {
m.bodyViewport.SetYOffset(m.bodyViewport.YOffset() + 1)
}
case tea.MouseWheelLeft:
m.bodyViewport.ScrollLeft(6)
case tea.MouseWheelRight:
m.bodyViewport.ScrollRight(6)
}
case tea.KeyPressMsg: case tea.KeyPressMsg:
h := keys.Keys.History h := keys.Keys.History
@@ -258,18 +259,10 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
return m, m.clearSearch() return m, m.clearSearch()
case key.Matches(msg, g.ScrollUp): case key.Matches(msg, g.ScrollUp):
step := m.bodyViewport.Height() / 2 util.ScrollViewport(&m.bodyViewport, -1)
if step < 1 {
step = 1
}
m.bodyViewport.SetYOffset(m.bodyViewport.YOffset() - step)
case key.Matches(msg, g.ScrollDown): case key.Matches(msg, g.ScrollDown):
step := m.bodyViewport.Height() / 2 util.ScrollViewport(&m.bodyViewport, 1)
if step < 1 {
step = 1
}
m.bodyViewport.SetYOffset(m.bodyViewport.YOffset() + step)
case key.Matches(msg, g.Left): case key.Matches(msg, g.Left):
m.bodyViewport.ScrollLeft(6) m.bodyViewport.ScrollLeft(6)
@@ -286,41 +279,21 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
m.bodyViewport.SetXOffset(0) m.bodyViewport.SetXOffset(0)
case key.Matches(msg, g.GotoBottom): case key.Matches(msg, g.GotoBottom):
if len(m.entries) > 0 { m.cursor = util.CursorGotoBottom(len(m.entries))
m.cursor = len(m.entries) - 1 m.refreshListViewport()
m.pager.Page = m.pager.TotalPages - 1 m.refreshBody()
m.refreshListViewport() m.bodyViewport.SetYOffset(0)
m.refreshBody() m.bodyViewport.SetXOffset(0)
m.bodyViewport.SetYOffset(0)
m.bodyViewport.SetXOffset(0)
}
case key.Matches(msg, g.PrevPage): case key.Matches(msg, g.PrevPage):
step := m.pager.PerPage m.cursor = util.CursorMovePage(m.cursor, len(m.entries), m.pager.PerPage, false)
if step < 1 {
step = 1
}
m.cursor -= step
if m.cursor < 0 {
m.cursor = 0
}
m.refreshListViewport() m.refreshListViewport()
m.refreshBody() m.refreshBody()
m.bodyViewport.SetYOffset(0) m.bodyViewport.SetYOffset(0)
m.bodyViewport.SetXOffset(0) m.bodyViewport.SetXOffset(0)
case key.Matches(msg, g.NextPage): case key.Matches(msg, g.NextPage):
step := m.pager.PerPage m.cursor = util.CursorMovePage(m.cursor, len(m.entries), m.pager.PerPage, true)
if step < 1 {
step = 1
}
m.cursor += step
if m.cursor >= len(m.entries) {
m.cursor = len(m.entries) - 1
if m.cursor < 0 {
m.cursor = 0
}
}
m.refreshListViewport() m.refreshListViewport()
m.refreshBody() m.refreshBody()
m.bodyViewport.SetYOffset(0) m.bodyViewport.SetYOffset(0)
-1
View File
@@ -171,7 +171,6 @@ type Model struct {
teapotFrame int teapotFrame int
} }
func New(projectDir string) Model { func New(projectDir string) Model {
projects := loadProjects(projectDir) projects := loadProjects(projectDir)
+4
View File
@@ -78,6 +78,10 @@ func (m Model) Init() tea.Cmd { return nil }
func (m Model) IsEditing() bool { return m.editing } func (m Model) IsEditing() bool { return m.editing }
func (m Model) IsResponseFocused() bool {
return m.captureResponse && m.focusedPanel == panelResponses
}
func (m Model) CurrentScheme() string { func (m Model) CurrentScheme() string {
if len(m.queue) == 0 { if len(m.queue) == 0 {
return "https" return "https"
+25 -34
View File
@@ -52,24 +52,7 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
case tea.MouseWheelMsg: case tea.MouseWheelMsg:
if !m.editing { if !m.editing {
switch msg.Button { util.HandleMouseWheel(msg, &m.bodyViewport)
case tea.MouseWheelUp:
if msg.Mod.Contains(tea.ModShift) {
m.bodyViewport.ScrollLeft(6)
} else {
m.bodyViewport.SetYOffset(m.bodyViewport.YOffset() - 1)
}
case tea.MouseWheelDown:
if msg.Mod.Contains(tea.ModShift) {
m.bodyViewport.ScrollRight(6)
} else {
m.bodyViewport.SetYOffset(m.bodyViewport.YOffset() + 1)
}
case tea.MouseWheelLeft:
m.bodyViewport.ScrollLeft(6)
case tea.MouseWheelRight:
m.bodyViewport.ScrollRight(6)
}
} }
case tea.KeyPressMsg: case tea.KeyPressMsg:
@@ -127,18 +110,10 @@ func (m Model) updateNormalMode(msg tea.KeyPressMsg, cmds *[]tea.Cmd) (tea.Model
} }
case key.Matches(msg, keys.Keys.Global.ScrollUp): case key.Matches(msg, keys.Keys.Global.ScrollUp):
step := m.bodyViewport.Height() / 2 util.ScrollViewport(&m.bodyViewport, -1)
if step < 1 {
step = 1
}
m.bodyViewport.SetYOffset(m.bodyViewport.YOffset() - step)
case key.Matches(msg, keys.Keys.Global.ScrollDown): case key.Matches(msg, keys.Keys.Global.ScrollDown):
step := m.bodyViewport.Height() / 2 util.ScrollViewport(&m.bodyViewport, 1)
if step < 1 {
step = 1
}
m.bodyViewport.SetYOffset(m.bodyViewport.YOffset() + step)
case key.Matches(msg, keys.Keys.Global.Left): case key.Matches(msg, keys.Keys.Global.Left):
m.bodyViewport.ScrollLeft(6) m.bodyViewport.ScrollLeft(6)
@@ -278,13 +253,29 @@ func (m Model) updateNormalMode(msg tea.KeyPressMsg, cmds *[]tea.Cmd) (tea.Model
case key.Matches(msg, keys.Keys.Global.GotoBottom): case key.Matches(msg, keys.Keys.Global.GotoBottom):
if onResponses { if onResponses {
if len(m.responseQueue) > 0 { m.responseCursor = util.CursorGotoBottom(len(m.responseQueue))
m.responseCursor = len(m.responseQueue) - 1
}
} else { } else {
if len(m.queue) > 0 { m.cursor = util.CursorGotoBottom(len(m.queue))
m.cursor = len(m.queue) - 1 }
} m.refreshListViewport()
m.refreshResponseListViewport()
m.refreshBody()
case key.Matches(msg, keys.Keys.Global.PrevPage):
if onResponses {
m.responseCursor = util.CursorMovePage(m.responseCursor, len(m.responseQueue), m.responsePager.PerPage, false)
} else {
m.cursor = util.CursorMovePage(m.cursor, len(m.queue), m.pager.PerPage, false)
}
m.refreshListViewport()
m.refreshResponseListViewport()
m.refreshBody()
case key.Matches(msg, keys.Keys.Global.NextPage):
if onResponses {
m.responseCursor = util.CursorMovePage(m.responseCursor, len(m.responseQueue), m.responsePager.PerPage, true)
} else {
m.cursor = util.CursorMovePage(m.cursor, len(m.queue), m.pager.PerPage, true)
} }
m.refreshListViewport() m.refreshListViewport()
m.refreshResponseListViewport() m.refreshResponseListViewport()
+16 -16
View File
@@ -4,6 +4,7 @@ import (
"charm.land/bubbles/v2/key" "charm.land/bubbles/v2/key"
tea "charm.land/bubbletea/v2" tea "charm.land/bubbletea/v2"
"github.com/anotherhadi/spilltea/internal/keys" "github.com/anotherhadi/spilltea/internal/keys"
"github.com/anotherhadi/spilltea/internal/util"
) )
func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
@@ -20,12 +21,7 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) { switch msg := msg.(type) {
case tea.MouseWheelMsg: case tea.MouseWheelMsg:
if !m.editing { if !m.editing {
switch msg.Button { util.HandleMouseWheel(msg, &m.detailViewport)
case tea.MouseWheelUp:
m.detailViewport.SetYOffset(m.detailViewport.YOffset() - 1)
case tea.MouseWheelDown:
m.detailViewport.SetYOffset(m.detailViewport.YOffset() + 1)
}
} }
case tea.KeyPressMsg: case tea.KeyPressMsg:
@@ -128,19 +124,23 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
m.textarea.Focus() m.textarea.Focus()
} }
case key.Matches(msg, g.PrevPage):
m.cursor = util.CursorMovePage(m.cursor, len(m.filtered), m.pager.PerPage, false)
m.recalcSizes()
m.syncTextarea()
m.detailViewport.GotoTop()
case key.Matches(msg, g.NextPage):
m.cursor = util.CursorMovePage(m.cursor, len(m.filtered), m.pager.PerPage, true)
m.recalcSizes()
m.syncTextarea()
m.detailViewport.GotoTop()
case key.Matches(msg, g.ScrollUp): case key.Matches(msg, g.ScrollUp):
step := m.detailViewport.Height() / 2 util.ScrollViewport(&m.detailViewport, -1)
if step < 1 {
step = 1
}
m.detailViewport.SetYOffset(m.detailViewport.YOffset() - step)
case key.Matches(msg, g.ScrollDown): case key.Matches(msg, g.ScrollDown):
step := m.detailViewport.Height() / 2 util.ScrollViewport(&m.detailViewport, 1)
if step < 1 {
step = 1
}
m.detailViewport.SetYOffset(m.detailViewport.YOffset() + step)
case key.Matches(msg, g.Help): case key.Matches(msg, g.Help):
m.help.ShowAll = !m.help.ShowAll m.help.ShowAll = !m.help.ShowAll
+22 -6
View File
@@ -34,11 +34,20 @@ type Entry struct {
Err error Err error
} }
type panel int
const (
panelList panel = iota
panelRequest
panelResponse
)
type Model struct { type Model struct {
entries []Entry entries []Entry
cursor int cursor int
editing bool editing bool
database *db.DB focusedPanel panel
database *db.DB
listViewport viewport.Model listViewport viewport.Model
requestViewport viewport.Model requestViewport viewport.Model
@@ -68,10 +77,17 @@ func (m Model) Init() tea.Cmd { return nil }
func (m Model) IsEditing() bool { return m.editing } func (m Model) IsEditing() bool { return m.editing }
func (m Model) IsResponseFocused() bool {
return m.focusedPanel == panelResponse
}
func (m Model) CurrentRaw() string { func (m Model) CurrentRaw() string {
if len(m.entries) == 0 || m.cursor >= len(m.entries) { if len(m.entries) == 0 || m.cursor >= len(m.entries) {
return "" return ""
} }
if m.focusedPanel == panelResponse {
return m.entries[m.cursor].ResponseRaw
}
return m.entries[m.cursor].RequestRaw return m.entries[m.cursor].RequestRaw
} }
@@ -183,12 +199,12 @@ type replayKeyMap struct{ width int }
func (replayKeyMap) ShortHelp() []key.Binding { func (replayKeyMap) ShortHelp() []key.Binding {
g := keys.Keys.Global g := keys.Keys.Global
r := keys.Keys.Replay r := keys.Keys.Replay
return []key.Binding{g.Up, g.Down, r.Send, r.Edit, g.Help} return []key.Binding{g.Up, g.Down, g.CycleFocus, r.Send, r.Edit, g.Help}
} }
func (m replayKeyMap) FullHelp() [][]key.Binding { func (m replayKeyMap) FullHelp() [][]key.Binding {
g := keys.Keys.Global g := keys.Keys.Global
pageGlobals := []key.Binding{g.Up, g.Down, g.ScrollUp, g.ScrollDown, g.Left, g.Right, g.Escape, g.Copy, g.CopyAs} pageGlobals := []key.Binding{g.Up, g.Down, g.CycleFocus, g.ScrollUp, g.ScrollDown, g.Left, g.Right, g.Escape, g.Copy, g.CopyAs, g.SendToDiff}
all := append(keys.Keys.Replay.Bindings(), pageGlobals...) all := append(keys.Keys.Replay.Bindings(), pageGlobals...)
all = append(all, g.CommonBindings()...) all = append(all, g.CommonBindings()...)
return keys.ChunkByWidth(all, m.width) return keys.ChunkByWidth(all, m.width)
+124 -45
View File
@@ -1,6 +1,9 @@
package replay package replay
import ( import (
"bytes"
"compress/gzip"
"compress/zlib"
"crypto/tls" "crypto/tls"
"fmt" "fmt"
"io" "io"
@@ -9,13 +12,17 @@ import (
"time" "time"
"charm.land/bubbles/v2/key" "charm.land/bubbles/v2/key"
"charm.land/bubbles/v2/viewport"
tea "charm.land/bubbletea/v2" tea "charm.land/bubbletea/v2"
"charm.land/lipgloss/v2" "charm.land/lipgloss/v2"
"github.com/andybalholm/brotli"
"github.com/anotherhadi/spilltea/internal/config" "github.com/anotherhadi/spilltea/internal/config"
"github.com/anotherhadi/spilltea/internal/db" "github.com/anotherhadi/spilltea/internal/db"
"github.com/anotherhadi/spilltea/internal/keys" "github.com/anotherhadi/spilltea/internal/keys"
"github.com/anotherhadi/spilltea/internal/style" "github.com/anotherhadi/spilltea/internal/style"
"github.com/anotherhadi/spilltea/internal/util" "github.com/anotherhadi/spilltea/internal/util"
diffUI "github.com/anotherhadi/spilltea/internal/ui/diff"
"github.com/klauspost/compress/zstd"
) )
type sentMsg struct { type sentMsg struct {
@@ -91,14 +98,14 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
m.requestViewport.ScrollLeft(6) m.requestViewport.ScrollLeft(6)
m.responseViewport.ScrollLeft(6) m.responseViewport.ScrollLeft(6)
} else { } else {
m.responseViewport.SetYOffset(m.responseViewport.YOffset() - 1) m.scrollFocusedViewportVertical(-1)
} }
case tea.MouseWheelDown: case tea.MouseWheelDown:
if msg.Mod.Contains(tea.ModShift) { if msg.Mod.Contains(tea.ModShift) {
m.requestViewport.ScrollRight(6) m.requestViewport.ScrollRight(6)
m.responseViewport.ScrollRight(6) m.responseViewport.ScrollRight(6)
} else { } else {
m.responseViewport.SetYOffset(m.responseViewport.YOffset() + 1) m.scrollFocusedViewportVertical(1)
} }
case tea.MouseWheelLeft: case tea.MouseWheelLeft:
m.requestViewport.ScrollLeft(6) m.requestViewport.ScrollLeft(6)
@@ -124,17 +131,35 @@ func (m Model) updateNormalMode(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) {
r := keys.Keys.Replay r := keys.Keys.Replay
switch { switch {
case key.Matches(msg, g.Up): case key.Matches(msg, g.Up):
if m.cursor > 0 { if m.focusedPanel == panelList {
m.cursor-- if m.cursor > 0 {
m.refreshListViewport() m.cursor--
m.refreshBody() m.refreshListViewport()
m.refreshBody()
}
} else {
m.scrollFocusedViewportVertical(-1)
} }
case key.Matches(msg, g.Down): case key.Matches(msg, g.Down):
if m.cursor < len(m.entries)-1 { if m.focusedPanel == panelList {
m.cursor++ if m.cursor < len(m.entries)-1 {
m.refreshListViewport() m.cursor++
m.refreshBody() m.refreshListViewport()
m.refreshBody()
}
} else {
m.scrollFocusedViewportVertical(1)
}
case key.Matches(msg, g.CycleFocus):
switch m.focusedPanel {
case panelList:
m.focusedPanel = panelRequest
case panelRequest:
m.focusedPanel = panelResponse
default:
m.focusedPanel = panelList
} }
case key.Matches(msg, r.Send): case key.Matches(msg, r.Send):
@@ -166,18 +191,14 @@ func (m Model) updateNormalMode(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) {
} }
case key.Matches(msg, g.ScrollUp): case key.Matches(msg, g.ScrollUp):
step := m.responseViewport.Height() / 2 vp := m.focusedViewport()
if step < 1 { util.ScrollViewport(&vp, -1)
step = 1 m.setFocusedViewport(vp)
}
m.responseViewport.SetYOffset(m.responseViewport.YOffset() - step)
case key.Matches(msg, g.ScrollDown): case key.Matches(msg, g.ScrollDown):
step := m.responseViewport.Height() / 2 vp := m.focusedViewport()
if step < 1 { util.ScrollViewport(&vp, 1)
step = 1 m.setFocusedViewport(vp)
}
m.responseViewport.SetYOffset(m.responseViewport.YOffset() + step)
case key.Matches(msg, g.Left): case key.Matches(msg, g.Left):
m.requestViewport.ScrollLeft(6) m.requestViewport.ScrollLeft(6)
@@ -219,40 +240,38 @@ func (m Model) updateNormalMode(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) {
m.refreshBody() m.refreshBody()
case key.Matches(msg, keys.Keys.Global.GotoBottom): case key.Matches(msg, keys.Keys.Global.GotoBottom):
if len(m.entries) > 0 { m.cursor = util.CursorGotoBottom(len(m.entries))
m.cursor = len(m.entries) - 1 m.refreshListViewport()
m.pager.Page = m.pager.TotalPages - 1 m.refreshBody()
m.refreshListViewport()
m.refreshBody()
}
case key.Matches(msg, keys.Keys.Global.PrevPage): case key.Matches(msg, keys.Keys.Global.PrevPage):
step := m.pager.PerPage m.cursor = util.CursorMovePage(m.cursor, len(m.entries), m.pager.PerPage, false)
if step < 1 {
step = 1
}
m.cursor -= step
if m.cursor < 0 {
m.cursor = 0
}
m.refreshListViewport() m.refreshListViewport()
m.refreshBody() m.refreshBody()
case key.Matches(msg, keys.Keys.Global.NextPage): case key.Matches(msg, keys.Keys.Global.NextPage):
step := m.pager.PerPage m.cursor = util.CursorMovePage(m.cursor, len(m.entries), m.pager.PerPage, true)
if step < 1 {
step = 1
}
m.cursor += step
if m.cursor >= len(m.entries) {
m.cursor = len(m.entries) - 1
if m.cursor < 0 {
m.cursor = 0
}
}
m.refreshListViewport() m.refreshListViewport()
m.refreshBody() m.refreshBody()
case key.Matches(msg, g.SendToDiff):
if len(m.entries) > 0 {
e := m.entries[m.cursor]
var raw, label string
if m.focusedPanel == panelResponse {
raw = e.ResponseRaw
label = fmt.Sprintf("%d %s", e.StatusCode, http.StatusText(e.StatusCode))
} else {
raw = e.RequestRaw
label = e.Method + " " + e.Host + e.Path
}
if raw != "" {
return m, func() tea.Msg {
return diffUI.SendToDiffMsg{Label: label, Raw: raw}
}
}
}
case key.Matches(msg, g.Help): case key.Matches(msg, g.Help):
m.help.ShowAll = !m.help.ShowAll m.help.ShowAll = !m.help.ShowAll
m.recalcSizes() m.recalcSizes()
@@ -280,6 +299,29 @@ func (m Model) updateEditMode(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) {
return m, nil return m, nil
} }
// focusedViewport returns the viewport that should receive scroll events.
// When the list is focused, scroll targets the request panel.
func (m *Model) focusedViewport() viewport.Model {
if m.focusedPanel == panelResponse {
return m.responseViewport
}
return m.requestViewport
}
func (m *Model) setFocusedViewport(vp viewport.Model) {
if m.focusedPanel == panelResponse {
m.responseViewport = vp
} else {
m.requestViewport = vp
}
}
func (m *Model) scrollFocusedViewportVertical(delta int) {
vp := m.focusedViewport()
vp.SetYOffset(vp.YOffset() + delta)
m.setFocusedViewport(vp)
}
func (m *Model) refreshListViewport() { func (m *Model) refreshListViewport() {
if m.pager.PerPage > 0 { if m.pager.PerPage > 0 {
if len(m.entries) == 0 { if len(m.entries) == 0 {
@@ -369,6 +411,14 @@ func doSend(entry Entry) (responseRaw string, statusCode int, err error) {
limit := int64(config.Global.App.MaxBodySizeMB) * 1024 * 1024 limit := int64(config.Global.App.MaxBodySizeMB) * 1024 * 1024
respBody, _ := io.ReadAll(io.LimitReader(resp.Body, limit)) respBody, _ := io.ReadAll(io.LimitReader(resp.Body, limit))
if enc := resp.Header.Get("Content-Encoding"); enc != "" {
if decoded, decErr := decodeBody(enc, respBody); decErr == nil {
respBody = decoded
resp.Header.Del("Content-Encoding")
resp.Header.Del("Content-Length")
}
}
var sb strings.Builder var sb strings.Builder
fmt.Fprintf(&sb, "%s %d %s\n", resp.Proto, resp.StatusCode, http.StatusText(resp.StatusCode)) fmt.Fprintf(&sb, "%s %d %s\n", resp.Proto, resp.StatusCode, http.StatusText(resp.StatusCode))
for _, line := range util.SortedHeaderLines(resp.Header) { for _, line := range util.SortedHeaderLines(resp.Header) {
@@ -380,6 +430,35 @@ func doSend(entry Entry) (responseRaw string, statusCode int, err error) {
return sb.String(), resp.StatusCode, nil return sb.String(), resp.StatusCode, nil
} }
func decodeBody(encoding string, body []byte) ([]byte, error) {
switch strings.ToLower(strings.TrimSpace(encoding)) {
case "gzip":
r, err := gzip.NewReader(bytes.NewReader(body))
if err != nil {
return nil, err
}
defer r.Close()
return io.ReadAll(r)
case "br":
return io.ReadAll(brotli.NewReader(bytes.NewReader(body)))
case "deflate":
r, err := zlib.NewReader(bytes.NewReader(body))
if err != nil {
return nil, err
}
defer r.Close()
return io.ReadAll(r)
case "zstd":
r, err := zstd.NewReader(bytes.NewReader(body))
if err != nil {
return nil, err
}
defer r.Close()
return io.ReadAll(r)
}
return nil, fmt.Errorf("unsupported encoding: %s", encoding)
}
func entryToDB(e Entry) db.ReplayEntry { func entryToDB(e Entry) db.ReplayEntry {
errMsg := "" errMsg := ""
if e.Err != nil { if e.Err != nil {
+11 -4
View File
@@ -34,9 +34,9 @@ func (m Model) View() tea.View {
func (m *Model) renderListPanel(w, h int) string { func (m *Model) renderListPanel(w, h int) string {
s := style.S s := style.S
panelStyle := s.PanelFocused panelStyle := s.Panel
if m.editing { if !m.editing && m.focusedPanel == panelList {
panelStyle = s.Panel panelStyle = s.PanelFocused
} }
var dots string var dots string
if len(m.entries) > 0 { if len(m.entries) > 0 {
@@ -58,13 +58,20 @@ func (m *Model) renderRequestPanel(w, h int) string {
border = s.PanelFocused border = s.PanelFocused
} else { } else {
body = m.requestViewport.View() body = m.requestViewport.View()
if m.focusedPanel == panelRequest {
border = s.PanelFocused
}
} }
return style.RenderWithTitle(border, icons.I.Request+"Request", body, w, h) return style.RenderWithTitle(border, icons.I.Request+"Request", body, w, h)
} }
func (m *Model) renderResponsePanel(w, h int) string { func (m *Model) renderResponsePanel(w, h int) string {
s := style.S s := style.S
return style.RenderWithTitle(s.Panel, icons.I.Response+"Response", m.responseViewport.View(), w, h) border := s.Panel
if !m.editing && m.focusedPanel == panelResponse {
border = s.PanelFocused
}
return style.RenderWithTitle(border, icons.I.Response+"Response", m.responseViewport.View(), w, h)
} }
func (m *Model) renderStatusBar() string { func (m *Model) renderStatusBar() string {
+30
View File
@@ -0,0 +1,30 @@
package util
// CursorMovePage moves cursor forward or backward by one page (perPage items),
// clamped to [0, total-1].
func CursorMovePage(cursor, total, perPage int, forward bool) int {
step := perPage
if step < 1 {
step = 1
}
if forward {
cursor += step
} else {
cursor -= step
}
if cursor < 0 || total <= 0 {
return 0
}
if cursor >= total {
return total - 1
}
return cursor
}
// CursorGotoBottom returns the last valid cursor index for a list of total items.
func CursorGotoBottom(total int) int {
if total <= 0 {
return 0
}
return total - 1
}
+39
View File
@@ -0,0 +1,39 @@
package util
import (
"charm.land/bubbles/v2/viewport"
tea "charm.land/bubbletea/v2"
)
// ScrollViewport scrolls vp vertically by half its height.
// delta should be -1 for up, +1 for down.
func ScrollViewport(vp *viewport.Model, delta int) {
step := vp.Height() / 2
if step < 1 {
step = 1
}
vp.SetYOffset(vp.YOffset() + delta*step)
}
// HandleMouseWheel applies standard mouse wheel scrolling to vp.
// Vertical: one line at a time. Shift+vertical or horizontal: scroll 6 columns.
func HandleMouseWheel(msg tea.MouseWheelMsg, vp *viewport.Model) {
switch msg.Button {
case tea.MouseWheelUp:
if msg.Mod.Contains(tea.ModShift) {
vp.ScrollLeft(6)
} else {
vp.SetYOffset(vp.YOffset() - 1)
}
case tea.MouseWheelDown:
if msg.Mod.Contains(tea.ModShift) {
vp.ScrollRight(6)
} else {
vp.SetYOffset(vp.YOffset() + 1)
}
case tea.MouseWheelLeft:
vp.ScrollLeft(6)
case tea.MouseWheelRight:
vp.ScrollRight(6)
}
}
+3 -9
View File
@@ -40,16 +40,10 @@ function on_start()
return return
end end
-- Fetch the current outbound IP via a public API. local result, err = shell_pipe("curl -sf https://api.ipify.org 2>/dev/null")
local ok, result = pcall(function() result = result and result:match("^%s*(.-)%s*$") or nil
local handle = io.popen("curl -sf https://api.ipify.org 2>/dev/null")
if not handle then return nil end
local ip = handle:read("*a")
handle:close()
return ip and ip:match("^%s*(.-)%s*$") or nil
end)
if not ok 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
+166
View File
@@ -0,0 +1,166 @@
Plugin = {
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,
}
local CONTENT_TYPES = {
"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",
}
-- Built once at load time.
-- Pattern breakdown:
-- KEY[a-z0-9._-]{0,20} key name + optional alphanumeric suffix (e.g. _ID in AWS_ACCESS_KEY_ID)
-- [^=:a-zA-Z0-9_]{0,3} optional non-identifier chars before separator (e.g. closing " in JSON "key":)
-- [[: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 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
end
local function build_context(lines, linenum)
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 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")
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
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
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
end
function on_request(req)
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)
end
+63
View File
@@ -0,0 +1,63 @@
Plugin = {
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,
}
function on_start()
local handle = io.popen("command -v trufflehog 2>/dev/null")
local result = handle and handle:read("*a") or ""
if handle then handle:close() end
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
end
function on_request(req)
scan("request", req:get_body(), req.host, req.path)
end
function on_response(req, res)
scan("response", res:get_body(), req.host, req.path)
end