34 Commits

Author SHA1 Message Date
Hadi 6e673f5c11 v0.0.6
Signed-off-by: Hadi <112569860+anotherhadi@users.noreply.github.com>
2026-05-20 15:12:25 +02:00
Hadi 2c63cdbeff Edit plugins id & docs
Signed-off-by: Hadi <112569860+anotherhadi@users.noreply.github.com>
2026-05-20 15:09:09 +02:00
Hadi 0e982c6ade add pages "update" label
Signed-off-by: Hadi <112569860+anotherhadi@users.noreply.github.com>
2026-05-20 14:49:01 +02:00
Hadi 04ba32cbd5 order by asc
Signed-off-by: Hadi <112569860+anotherhadi@users.noreply.github.com>
2026-05-20 14:48:34 +02:00
Hadi f7e9da94cc Add scroll icon on viewports
Signed-off-by: Hadi <112569860+anotherhadi@users.noreply.github.com>
2026-05-20 14:36:27 +02:00
Hadi 9253d85c81 init faq.md
Signed-off-by: Hadi <112569860+anotherhadi@users.noreply.github.com>
2026-05-20 13:42:02 +02:00
Hadi 1bb547870e add "temp" for temporary projects
Signed-off-by: Hadi <112569860+anotherhadi@users.noreply.github.com>
2026-05-20 13:39:11 +02:00
Hadi 4251e4fb2a plugin's config is now in yaml
Signed-off-by: Hadi <112569860+anotherhadi@users.noreply.github.com>
2026-05-20 11:43:26 +02:00
Hadi b547a79d6e add trufflehog to dev deps
Signed-off-by: Hadi <112569860+anotherhadi@users.noreply.github.com>
2026-05-20 10:53:42 +02:00
Hadi fe58468abf add direnv
Signed-off-by: Hadi <112569860+anotherhadi@users.noreply.github.com>
2026-05-20 10:45:38 +02:00
Hadi 3542098905 add gomod2nix.toml
Signed-off-by: Hadi <112569860+anotherhadi@users.noreply.github.com>
2026-05-20 10:35:48 +02:00
Hadi f78b3f7174 Move pre-commit hooks to nix
Signed-off-by: Hadi <112569860+anotherhadi@users.noreply.github.com>
2026-05-20 10:33:16 +02:00
Hadi 722021ba02 merge plugins & docs embed
Signed-off-by: Hadi <112569860+anotherhadi@users.noreply.github.com>
2026-05-20 10:27:47 +02:00
Hadi e18f660e83 move the goreleaser config
Signed-off-by: Hadi <112569860+anotherhadi@users.noreply.github.com>
2026-05-20 10:27:35 +02:00
Hadi 67fe8eb911 fix: log silent errors, harden proxy auth, optimize db and render pipeline
Signed-off-by: Hadi <112569860+anotherhadi@users.noreply.github.com>
2026-05-20 10:19:37 +02:00
Hadi af872afbe8 gofmt
Signed-off-by: Hadi <112569860+anotherhadi@users.noreply.github.com>
2026-05-19 23:09:00 +02:00
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
65 changed files with 1733 additions and 620 deletions
+1
View File
@@ -0,0 +1 @@
use flake
+1 -1
View File
@@ -23,6 +23,6 @@ jobs:
- uses: goreleaser/goreleaser-action@v6
with:
version: "~> v2"
args: release --clean
args: release --clean --config .github/.goreleaser.yaml
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+1
View File
@@ -1,3 +1,4 @@
.claude/
CLAUDE.md
result/
.pre-commit-config.yaml
-6
View File
@@ -1,6 +0,0 @@
package spilltea
import "embed"
//go:embed docs
var DocsFS embed.FS
+27
View File
@@ -0,0 +1,27 @@
## How do I convert a temporary session into a regular project?
Temporary sessions are stored under `/tmp/spilltea/<random-id>/` and will be lost on reboot. To keep the data, move the directory into the Spilltea projects folder and give it a name.
**1. Find the temporary session directory**
```bash
ls /tmp/spilltea/
```
You will see one or more directories with a random hex name (e.g. `a1b2c3d4`).
**2. Move it to the projects folder**
```bash
mv /tmp/spilltea/<random-id> ~/.local/share/spilltea/<project-name>
```
The project name must only contain lowercase letters, digits, `-`, and `_`.
**3. Open the project**
```bash
spilltea -P my-project
```
Or simply launch Spilltea and pick `my-project` from the project list.
+63 -29
View File
@@ -1,5 +1,7 @@
# Plugins
> **Warning:** Plugins can execute arbitrary shell commands, read and write files via `shell_pipe`, and access all intercepted traffic. Only load plugins you trust and have reviewed. You are solely responsible for the plugins you run.
Spilltea supports Lua plugins that can intercept, modify, and analyze HTTP traffic.
You can found some pre-built plugins [here](../../plugins/).
@@ -31,14 +33,14 @@ Plugin = {
### Hook reference
| Hook | When called | Sync/async | Return value (sync only) |
| ------------------------- | ------------------------------------- | ------------ | ----------------------------------------------- |
| `on_config(config_text)` | At startup and on config save | always sync | ignored |
| `on_start()` | Once at startup, after `on_config` | configurable | ignored |
| Hook | When called | Sync/async | Return value |
| ------------------------- | ------------------------------------- | ------------ | ----------------------------------------------------------------------------------------- |
| `on_config()` | At startup and on config save | always sync | 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_request(req)` | Every request, before auto-forward | configurable | `"drop"`, `"forward"`, or `nil` |
| `on_response(req, res)` | Every response | configurable | `"drop"`, `"forward"`, or `nil` |
| `on_history_entry(entry)` | Sync: before DB insert / Async: after | configurable | `"skip"` (don't save), `"keep"` or `nil` (save) |
| `on_request(req)` | Every request, before auto-forward | configurable | `"drop"`, `"forward"`, or `nil` (nil does nothing and le the user/TUI choose) (sync only) |
| `on_response(req, res)` | Every response | configurable | `"drop"`, `"forward"`, or `nil` (nil does nothing and le the user/TUI choose) (sync only) |
| `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
@@ -110,17 +112,64 @@ end
-- Quit the app (useful for startup checks that fail)
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
-- Return the plugin's config section as a Lua table (parsed from YAML).
-- Returns an empty table if no config is set.
local cfg = get_config()
```
### Finding deduplication
A finding is identified by `(plugin_name, key)`. If a finding with that pair already exists in the database it will **not** be re-created, even across restarts. If the user **dismisses** a finding it is permanently hidden and will never reappear, even if the plugin generates it again.
A finding is identified by `(plugin_name, key)`. If a finding with that pair already exists in the database it will **not** be re-created, even across restarts.
If the user **dismisses** a finding it is permanently hidden and will never reappear, even if the plugin generates it again.
## Configuration
Each plugin gets a **config textarea** on the Plugins page. The raw text is passed as-is to `on_config(config_text)`. Parse it however you like (line by line, key=value, JSON, etc.).
Plugin configuration is stored in a `plugins.yaml` file alongside the project database.
Each plugin is keyed by its filename (without the `.lua` extension) and has an `enable` toggle and an optional `config` block (arbitrary YAML).
`on_config` is called once at startup (before `on_start`) and again every time the user saves the config in the UI.
```yaml
plugins:
my_plugin:
enable: true
config:
some_key: some_value
list:
- item1
- item2
other_plugin:
enable: false
```
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.
`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.
```lua
local items = {}
function on_config()
items = {}
local cfg = get_config()
if cfg and cfg.list then
for _, v in ipairs(cfg.list) do
table.insert(items, v)
end
end
end
```
## Sync vs async
@@ -129,28 +178,13 @@ Each plugin gets a **config textarea** on the Plugins page. The raw text is pass
`on_config` and `on_quit` are always synchronous regardless of the Plugin table declaration.
### Return values for sync hooks
**`on_request` and `on_response`:**
| Return value | Effect |
| ------------ | --------------------------------------------------------------------------------- |
| `"drop"` | The flow is dropped immediately and never shown in the intercept panel. |
| `"forward"` | The flow is forwarded immediately without going through the intercept panel. |
| `nil` | Normal behaviour: the flow appears in the intercept panel for the user to decide. |
**`on_history_entry` (sync only):**
| Return value | Effect |
| ----------------- | --------------------------------- |
| `"skip"` | The entry is not saved to the DB. |
| `"keep"` or `nil` | The entry is saved normally. |
Sync `on_history_entry` runs **before** the DB insert, so it can prevent an entry from ever appearing in history. Async `on_history_entry` runs **after** the insert and cannot affect it.
Sync `on_history_entry` runs **before** the DB insert, so it can prevent an entry from ever appearing in history.
Async `on_history_entry` runs **after** the insert and cannot affect it.
## Priority
Plugins with a higher `priority` value run before plugins with a lower value (default `0`). This matters for sync hooks that return a decision: the first plugin to return a non-nil value short-circuits the remaining plugins.
Plugins with a higher `priority` value run before plugins with a lower value (default `0`).
This matters for sync hooks that return a decision: the first plugin to return a non-nil value short-circuits the remaining plugins.
```lua
Plugin = {
Generated
+115
View File
@@ -1,5 +1,103 @@
{
"nodes": {
"flake-compat": {
"flake": false,
"locked": {
"lastModified": 1767039857,
"narHash": "sha256-vNpUSpF5Nuw8xvDLj2KCwwksIbjua2LZCqhV1LNRDns=",
"owner": "NixOS",
"repo": "flake-compat",
"rev": "5edf11c44bc78a0d334f6334cdaf7d60d732daab",
"type": "github"
},
"original": {
"owner": "NixOS",
"repo": "flake-compat",
"type": "github"
}
},
"flake-utils": {
"inputs": {
"systems": "systems"
},
"locked": {
"lastModified": 1731533236,
"narHash": "sha256-l0KFg5HjrsfsO/JpG+r7fRrqm12kzFHyUHqHCVpMMbI=",
"owner": "numtide",
"repo": "flake-utils",
"rev": "11707dc2f618dd54ca8739b309ec4fc024de578b",
"type": "github"
},
"original": {
"owner": "numtide",
"repo": "flake-utils",
"type": "github"
}
},
"git-hooks": {
"inputs": {
"flake-compat": "flake-compat",
"gitignore": "gitignore",
"nixpkgs": [
"nixpkgs"
]
},
"locked": {
"lastModified": 1778507602,
"narHash": "sha256-kTwur1wV+01SdqskVMSo6JMEpg71ps3HpbFY2GsflKs=",
"owner": "cachix",
"repo": "git-hooks.nix",
"rev": "61ab0e80d9c7ab14c256b5b453d8b3fb0189ba0a",
"type": "github"
},
"original": {
"owner": "cachix",
"repo": "git-hooks.nix",
"type": "github"
}
},
"gitignore": {
"inputs": {
"nixpkgs": [
"git-hooks",
"nixpkgs"
]
},
"locked": {
"lastModified": 1709087332,
"narHash": "sha256-HG2cCnktfHsKV0s4XW83gU3F57gaTljL9KNSuG6bnQs=",
"owner": "hercules-ci",
"repo": "gitignore.nix",
"rev": "637db329424fd7e46cf4185293b9cc8c88c95394",
"type": "github"
},
"original": {
"owner": "hercules-ci",
"repo": "gitignore.nix",
"type": "github"
}
},
"gomod2nix": {
"inputs": {
"flake-utils": "flake-utils",
"nixpkgs": [
"nixpkgs"
]
},
"locked": {
"lastModified": 1770585520,
"narHash": "sha256-yBz9Ozd5Wb56i3e3cHZ8WcbzCQ9RlVaiW18qDYA/AzA=",
"owner": "nix-community",
"repo": "gomod2nix",
"rev": "1201ddd1279c35497754f016ef33d5e060f3da8d",
"type": "github"
},
"original": {
"owner": "nix-community",
"repo": "gomod2nix",
"type": "github"
}
},
"nixpkgs": {
"locked": {
"lastModified": 1777954456,
@@ -18,8 +116,25 @@
},
"root": {
"inputs": {
"git-hooks": "git-hooks",
"gomod2nix": "gomod2nix",
"nixpkgs": "nixpkgs"
}
},
"systems": {
"locked": {
"lastModified": 1681028828,
"narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=",
"owner": "nix-systems",
"repo": "default",
"rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e",
"type": "github"
},
"original": {
"owner": "nix-systems",
"repo": "default",
"type": "github"
}
}
},
"root": "root",
+21 -35
View File
@@ -1,55 +1,41 @@
{
description = "Spilltea: A minimal, terminal-based HTTP(S) proxy for pentesters and CTF players.";
inputs = {nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";};
inputs = {
nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
gomod2nix = {
url = "github:nix-community/gomod2nix";
inputs.nixpkgs.follows = "nixpkgs";
};
git-hooks = {
url = "github:cachix/git-hooks.nix";
inputs.nixpkgs.follows = "nixpkgs";
};
};
outputs = {
self,
nixpkgs,
gomod2nix,
git-hooks,
}: let
supportedSystems = ["x86_64-linux" "aarch64-linux"];
forAllSystems = f:
nixpkgs.lib.genAttrs supportedSystems
(system: f system (import nixpkgs {inherit system;}));
pname = "spilltea";
version = "0.0.4";
ldflags = ["-s" "-w" "-X main.version=${version}"];
in {
packages = forAllSystems (system: pkgs: let
pkg = pkgs.buildGoModule {
inherit pname version ldflags;
src = ./.;
outputs = ["out"];
vendorHash = "sha256-1iPwFsyzdonak9EWMRnudwcCQZfI+Uvre38+puG4s0s=";
meta = with pkgs.lib; {
description = "A minimal, terminal-based HTTP(S) proxy for pentesters and CTF players.";
homepage = "https://github.com/anotherhadi/spilltea";
platforms = platforms.unix;
};
};
in {
"${pname}" = pkg;
default = pkg;
packages = forAllSystems (system: pkgs:
import ./nix/package.nix {
inherit pkgs;
buildGoApplication = gomod2nix.legacyPackages.${system}.buildGoApplication;
});
devShells = forAllSystems (system: pkgs: {
default = pkgs.mkShell {
packages = with pkgs; [
go
python3
lefthook
doctoc
];
shellHook = ''
lefthook install
'';
default = import ./nix/shell.nix {
inherit pkgs;
gitHooksLib = git-hooks.lib.${system};
gomod2nixPkgs = gomod2nix.legacyPackages.${system};
};
});
};
+2 -1
View File
@@ -41,6 +41,7 @@ type Config struct {
DefaultInterceptEnabled bool `mapstructure:"default_intercept_enabled"`
DefaultCaptureResponse bool `mapstructure:"default_capture_response"`
AutoForwardRegex []string `mapstructure:"auto_forward_regex"`
QueueSize int `mapstructure:"queue_size"`
} `mapstructure:"intercept"`
Replay struct {
@@ -82,7 +83,7 @@ func WriteDefaultConfig(path string) error {
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
return fmt.Errorf("create config dir: %w", err)
}
if err := os.WriteFile(path, defaultConfig, 0o644); err != nil {
if err := os.WriteFile(path, defaultConfig, 0o600); err != nil {
return fmt.Errorf("write config: %w", err)
}
return nil
+17 -16
View File
@@ -5,13 +5,14 @@ app:
project_dir: ~/.local/share/spilltea
plugins_dir: ~/.config/spilltea/plugins
upstream_proxy: "" # e.g. http://corporate-proxy:8888 or http://user:pass@host:8888
proxy_auth: "" # require basic auth to use the proxy, format: user:pass (empty = disabled)
proxy_auth: "" # require basic auth to use the proxy, format: user:pass (empty = disabled). Also run: chmod 600 ~/.config/spilltea/config.yaml
max_body_size_mb: 50 # max response body size read into memory for large streamed responses (MB)
external_editor: "" # override $EDITOR for external editing (e.g. nvim, code --wait)
intercept:
default_intercept_enabled: true
default_capture_response: false
queue_size: 64 # max pending intercepted requests/responses before the proxy blocks
auto_forward_regex:
- '\.(js|css|png|gif|ico|woff2?|ttf|svg)(\?.*)?$'
@@ -47,35 +48,35 @@ tui:
keybindings:
global:
quit: "q,ctrl+c"
help: "?"
open_logs: "ctrl+g"
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"
down: "down,j"
left: "left,h"
right: "right,l"
cycle_focus: "tab"
copy_as: "ctrl+y"
copy: "y"
send_to_replay: "ctrl+r"
goto_top: "g"
goto_bottom: "G,end"
scroll_up: "pgup"
scroll_down: "pgdown"
send_to_diff: "ctrl+d"
goto_top: "home"
goto_bottom: "G,end"
prev_page: "["
next_page: "]"
intercept:
toggle_intercept: "i"
capture_response: "r"
forward: "f"
forward_all: "F"
drop: "d"
drop_all: "D"
toggle_intercept: "i"
capture_response: "r"
undo_edits: "ctrl+z"
edit: "e,enter"
edit_external: "E"
undo_edits: "ctrl+z"
history:
delete_entry: "x"
@@ -85,20 +86,20 @@ keybindings:
flag: "m"
home:
open: "enter,l"
open: "l,enter"
delete: "x"
filter: "/"
replay:
send: "enter,s"
send: "s, enter"
edit: "e"
edit_external: "E"
undo_edits: "R"
undo_edits: "ctrl+z"
delete_entry: "x"
delete_all: "X"
diff:
clear: "c"
clear: "x"
findings:
dismiss: "x"
+14 -6
View File
@@ -9,6 +9,7 @@ import (
type DB struct {
conn *sql.DB
roConn *sql.DB
path string
dedupMu sync.Mutex
}
@@ -27,6 +28,17 @@ func Open(path string) (*DB, error) {
conn.Close()
return nil, err
}
roConn, err := sql.Open("sqlite", path)
if err != nil {
conn.Close()
return nil, err
}
if _, err := roConn.Exec("PRAGMA query_only=ON"); err != nil {
conn.Close()
roConn.Close()
return nil, err
}
d.roConn = roConn
return d, nil
}
@@ -60,12 +72,7 @@ CREATE TABLE IF NOT EXISTS replay_entries (
status_code INTEGER NOT NULL,
error_msg TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS plugins (
name TEXT PRIMARY KEY,
enabled INTEGER NOT NULL DEFAULT 1,
config_text TEXT NOT NULL DEFAULT ''
);
CREATE TABLE IF NOT EXISTS findings (
CREATE TABLE IF NOT EXISTS findings (
id INTEGER PRIMARY KEY AUTOINCREMENT,
plugin_name TEXT NOT NULL,
dedup_key TEXT NOT NULL,
@@ -94,6 +101,7 @@ func (d *DB) Close() error {
if d == nil {
return nil
}
_ = d.roConn.Close()
return d.conn.Close()
}
+9 -12
View File
@@ -4,6 +4,7 @@ import (
"crypto/sha256"
"database/sql"
"fmt"
"log"
"strings"
"time"
)
@@ -63,7 +64,11 @@ func (d *DB) InsertEntry(e Entry, body string) (Entry, error) {
if err != nil {
return e, err
}
e.ID, _ = res.LastInsertId()
var idErr error
e.ID, idErr = res.LastInsertId()
if idErr != nil {
log.Printf("db: LastInsertId: %v", idErr)
}
return e, nil
}
@@ -113,19 +118,11 @@ func (d *DB) SearchEntries(term string) ([]Entry, error) {
// QueryEntries runs a WHERE expression supplied by the user against the entries
// table (e.g. "status_code = 404" or "host LIKE '%example.com%'").
// It opens a dedicated read-only connection so that any DML or DDL in the
// user-supplied expression is rejected by SQLite before it can execute.
// Uses the persistent read-only connection (PRAGMA query_only=ON) so that any
// DML or DDL in the user-supplied expression is rejected by SQLite before it executes.
func (d *DB) QueryEntries(where string) ([]Entry, error) {
roConn, err := sql.Open("sqlite", d.path)
if err != nil {
return nil, err
}
defer roConn.Close()
if _, err := roConn.Exec("PRAGMA query_only=ON"); err != nil {
return nil, err
}
q := "SELECT id, timestamp, method, host, path, status_code, request_raw, response_raw, flagged FROM entries WHERE " + strings.TrimSpace(where)
rows, err := roConn.Query(q)
rows, err := d.roConn.Query(q)
if err != nil {
return nil, err
}
+3 -1
View File
@@ -17,6 +17,8 @@ type Finding struct {
// UpsertFinding inserts the finding if the (plugin_name, dedup_key) pair does
// not already exist. Returns true when the row was actually inserted.
func (d *DB) UpsertFinding(f Finding) (bool, error) {
d.dedupMu.Lock()
defer d.dedupMu.Unlock()
res, err := d.conn.Exec(
`INSERT OR IGNORE INTO findings (plugin_name, dedup_key, title, description, severity, dismissed, created_at)
VALUES (?, ?, ?, ?, ?, 0, ?)`,
@@ -33,7 +35,7 @@ func (d *DB) UpsertFinding(f Finding) (bool, error) {
func (d *DB) LoadFindings() ([]Finding, error) {
rows, err := d.conn.Query(
`SELECT id, plugin_name, dedup_key, title, description, severity, created_at
FROM findings WHERE dismissed = 0 ORDER BY id DESC`,
FROM findings WHERE dismissed = 0 ORDER BY id ASC`,
)
if err != nil {
return nil, err
-35
View File
@@ -1,35 +0,0 @@
package db
type PluginState struct {
Name string
Enabled bool
ConfigText string
}
func (d *DB) SavePluginState(name string, enabled bool, configText string) error {
_, err := d.conn.Exec(
`INSERT INTO plugins (name, enabled, config_text) VALUES (?, ?, ?)
ON CONFLICT(name) DO UPDATE SET enabled = excluded.enabled, config_text = excluded.config_text`,
name, enabled, configText,
)
return err
}
func (d *DB) LoadPluginStates() ([]PluginState, error) {
rows, err := d.conn.Query(`SELECT name, enabled, config_text FROM plugins`)
if err != nil {
return nil, err
}
defer rows.Close()
var out []PluginState
for rows.Next() {
var s PluginState
var enabled int
if err := rows.Scan(&s.Name, &enabled, &s.ConfigText); err != nil {
return nil, err
}
s.Enabled = enabled != 0
out = append(out, s)
}
return out, rows.Err()
}
+6 -2
View File
@@ -58,9 +58,13 @@ func (b *Broker) SetOnNewEntry(cb func(db.Entry)) {
}
func NewBroker() *Broker {
size := config.Global.Intercept.QueueSize
if size <= 0 {
size = 64
}
b := &Broker{
Incoming: make(chan *PendingRequest, 64),
IncomingResponse: make(chan *PendingResponse, 64),
Incoming: make(chan *PendingRequest, size),
IncomingResponse: make(chan *PendingResponse, size),
}
b.SetAutoForwardRegex(config.Global.Intercept.AutoForwardRegex)
return b
+83 -7
View File
@@ -1,13 +1,17 @@
package plugins
import (
"bytes"
"context"
"log"
"os/exec"
"strings"
"time"
"github.com/anotherhadi/spilltea/internal/db"
goproxy "github.com/lqqyt2423/go-mitmproxy/proxy"
lua "github.com/yuin/gopher-lua"
"gopkg.in/yaml.v3"
)
func newLuaState(mgr *Manager, p *Plugin) *lua.LState {
@@ -171,6 +175,81 @@ func registerUtilities(L *lua.LState, mgr *Manager, p *Plugin) {
}
return 0
}))
L.SetGlobal("get_config", L.NewFunction(func(L *lua.LState) int {
// p.mu is already held by the hook caller - do not lock again.
configText := p.ConfigText
if configText == "" {
L.Push(L.NewTable())
return 1
}
var data interface{}
if err := yaml.Unmarshal([]byte(configText), &data); err != nil || data == nil {
L.Push(L.NewTable())
return 1
}
lv := goToLuaValue(L, data)
if _, ok := lv.(*lua.LTable); !ok {
L.Push(L.NewTable())
return 1
}
L.Push(lv)
return 1
}))
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 goToLuaValue(L *lua.LState, v interface{}) lua.LValue {
switch val := v.(type) {
case map[string]interface{}:
t := L.NewTable()
for k, v2 := range val {
L.SetField(t, k, goToLuaValue(L, v2))
}
return t
case []interface{}:
t := L.NewTable()
for i, v2 := range val {
L.RawSetInt(t, i+1, goToLuaValue(L, v2))
}
return t
case string:
return lua.LString(val)
case int:
return lua.LNumber(val)
case float64:
return lua.LNumber(val)
case bool:
if val {
return lua.LTrue
}
return lua.LFalse
}
return lua.LNil
}
func luaTableString(t *lua.LTable, key string) string {
@@ -264,22 +343,19 @@ func pushEntry(L *lua.LState, e db.Entry) *lua.LTable {
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)
if fn == lua.LNil {
return "", nil
return lua.LNil, nil
}
if err := p.L.CallByParam(lua.P{
Fn: fn,
NRet: 1,
Protect: true,
}, args...); err != nil {
return "", err
return lua.LNil, err
}
ret := p.L.Get(-1)
p.L.Pop(1)
if s, ok := ret.(lua.LString); ok {
return string(s), nil
}
return "", nil
return ret, nil
}
+95 -49
View File
@@ -20,6 +20,7 @@ type Manager struct {
plugins []*Plugin
db *db.DB
pluginsFile *PluginsFile
broker *intercept.Broker
Notifs chan PluginNotifMsg
@@ -43,6 +44,10 @@ func (m *Manager) SetDB(d *db.DB) {
m.db = d
}
func (m *Manager) SetPluginsFile(pf *PluginsFile) {
m.pluginsFile = pf
}
func (m *Manager) LoadFromDir(dir string) error {
entries, err := os.ReadDir(dir)
if os.IsNotExist(err) {
@@ -52,17 +57,6 @@ func (m *Manager) LoadFromDir(dir string) error {
return err
}
var states map[string]db.PluginState
if m.db != nil {
list, err := m.db.LoadPluginStates()
if err == nil {
states = make(map[string]db.PluginState, len(list))
for _, s := range list {
states[s.Name] = s
}
}
}
for _, e := range entries {
if e.IsDir() || !strings.HasSuffix(e.Name(), ".lua") {
continue
@@ -73,9 +67,13 @@ func (m *Manager) LoadFromDir(dir string) error {
log.Printf("plugin load error %s: %v", path, err)
continue
}
if s, ok := states[p.Name]; ok {
p.Enabled = s.Enabled
p.ConfigText = s.ConfigText
if m.pluginsFile != nil {
if enabled, configText, found := m.pluginsFile.get(p.ID); found {
p.Enabled = enabled
p.ConfigText = configText
} else {
m.pluginsFile.register(p.ID, p.Enabled)
}
}
m.mu.Lock()
m.plugins = append(m.plugins, p)
@@ -89,6 +87,7 @@ func (m *Manager) LoadFromDir(dir string) error {
func (m *Manager) loadPlugin(path string) (*Plugin, error) {
p := &Plugin{
ID: strings.TrimSuffix(filepath.Base(path), ".lua"),
FilePath: path,
Enabled: true,
hooks: make(map[string]HookConfig),
@@ -109,7 +108,7 @@ func (m *Manager) loadPlugin(path string) (*Plugin, error) {
p.Name = string(s)
}
if p.Name == "" {
p.Name = strings.TrimSuffix(filepath.Base(path), ".lua")
p.Name = p.ID
}
if s, ok := pluginTable.RawGetString("description").(lua.LString); ok {
@@ -131,12 +130,6 @@ func (m *Manager) loadPlugin(path string) (*Plugin, error) {
"on_response": false,
"on_history_entry": false,
}
// Fixed-sync hooks: always sync, not configurable.
fixedSyncHooks := map[string]struct{}{
"on_config": {},
"on_quit": {},
}
for hookName, defaultSync := range configurableHooks {
if tbl, ok := pluginTable.RawGetString(hookName).(*lua.LTable); ok {
p.hooks[hookName] = HookConfig{Sync: tbl.RawGetString("sync") == lua.LTrue}
@@ -146,9 +139,9 @@ func (m *Manager) loadPlugin(path string) (*Plugin, error) {
p.hooks[hookName] = HookConfig{Sync: defaultSync}
}
}
for hookName := range fixedSyncHooks {
if p.L.GetGlobal(hookName) != lua.LNil {
p.hooks[hookName] = HookConfig{Sync: true}
for _, fixedSync := range []string{"on_config", "on_quit"} {
if p.L.GetGlobal(fixedSync) != lua.LNil {
p.hooks[fixedSync] = HookConfig{Sync: true}
}
}
@@ -163,11 +156,11 @@ func (m *Manager) GetPlugins() []*Plugin {
return out
}
func (m *Manager) TogglePlugin(name string) {
func (m *Manager) TogglePlugin(id string) {
m.mu.RLock()
var found *Plugin
for _, p := range m.plugins {
if p.Name == name {
if p.ID == id {
found = p
break
}
@@ -179,18 +172,57 @@ func (m *Manager) TogglePlugin(name string) {
found.mu.Lock()
found.Enabled = !found.Enabled
enabled := found.Enabled
configText := found.ConfigText
found.mu.Unlock()
if m.db != nil {
_ = m.db.SavePluginState(name, enabled, configText)
if m.pluginsFile != nil {
if err := m.pluginsFile.setEnabled(id, enabled); err != nil {
log.Printf("plugin %s: save state: %v", id, err)
}
}
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.pluginsFile != nil {
if err := m.pluginsFile.setEnabled(p.ID, false); err != nil {
log.Printf("plugin %s: save state: %v", p.ID, err)
}
}
}
}
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(id, configText string) {
m.mu.RLock()
var found *Plugin
for _, p := range m.plugins {
if p.Name == name {
if p.ID == id {
found = p
break
}
@@ -201,39 +233,35 @@ func (m *Manager) SaveConfig(name, configText string) {
}
found.mu.Lock()
found.ConfigText = configText
enabled := found.Enabled
_, hasOnConfig := found.hooks["on_config"]
found.mu.Unlock()
if m.db != nil {
_ = m.db.SavePluginState(name, enabled, configText)
if m.pluginsFile != nil {
if err := m.pluginsFile.setConfig(id, configText); err != nil {
log.Printf("plugin %s: save config: %v", id, err)
}
if !hasOnConfig {
}
if _, ok := found.hooks["on_config"]; !ok {
return
}
// on_config is always sync.
found.mu.Lock()
if _, err := callHook(found, "on_config", lua.LString(configText)); err != nil {
log.Printf("plugin %s on_config (config reload): %v", name, err)
if _, err := callHook(found, "on_config"); err != nil {
log.Printf("plugin %s on_config: %v", id, err)
}
found.mu.Unlock()
}
func (m *Manager) RunOnStart() {
// on_config runs first, always sync, for every enabled plugin that has it.
for _, p := range m.GetPlugins() {
if !p.Enabled {
continue
}
if _, ok := p.hooks["on_config"]; !ok {
continue
}
if _, ok := p.hooks["on_config"]; ok {
p.mu.Lock()
if _, err := callHook(p, "on_config", lua.LString(p.ConfigText)); err != nil {
if _, err := callHook(p, "on_config"); err != nil {
log.Printf("plugin %s on_config: %v", p.Name, err)
}
p.mu.Unlock()
}
// on_start runs after, sync or async depending on plugin config.
}
for _, p := range m.GetPlugins() {
if !p.Enabled {
continue
@@ -242,17 +270,33 @@ func (m *Manager) RunOnStart() {
if !ok {
continue
}
disableIfFalse := func(p *Plugin, ret lua.LValue) {
if ret == lua.LFalse {
p.Enabled = false
if m.pluginsFile != nil {
if err := m.pluginsFile.setEnabled(p.ID, false); err != nil {
log.Printf("plugin %s: save state: %v", p.ID, err)
}
}
}
}
if hc.Sync {
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)
} else {
disableIfFalse(p, ret)
}
p.mu.Unlock()
} else {
go func(p *Plugin) {
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)
} else {
disableIfFalse(p, ret)
}
p.mu.Unlock()
}(p)
@@ -294,13 +338,15 @@ func (m *Manager) runSyncDecisionForPlugins(hookName string, argsFor func(*Plugi
log.Printf("plugin %s %s: %v", p.Name, hookName, err)
continue
}
switch result {
if s, ok := result.(lua.LString); ok {
switch string(s) {
case "drop":
return intercept.Drop
case "forward":
return intercept.Forward
}
}
}
return intercept.Intercept
}
@@ -366,7 +412,7 @@ func (m *Manager) RunSyncOnHistoryEntry(e db.Entry) bool {
log.Printf("plugin %s on_history_entry: %v", p.Name, err)
continue
}
if result == "skip" {
if s, ok := result.(lua.LString); ok && string(s) == "skip" {
return false
}
}
+96
View File
@@ -0,0 +1,96 @@
package plugins
import (
"os"
"path/filepath"
"gopkg.in/yaml.v3"
)
type pluginFileEntry struct {
Enable bool `yaml:"enable"`
Config interface{} `yaml:"config,omitempty"`
}
type pluginsFileData struct {
Plugins map[string]pluginFileEntry `yaml:"plugins"`
}
type PluginsFile struct {
path string
data pluginsFileData
}
func OpenPluginsFile(dbPath string) (*PluginsFile, error) {
path := filepath.Join(filepath.Dir(dbPath), "plugins.yaml")
pf := &PluginsFile{
path: path,
data: pluginsFileData{Plugins: make(map[string]pluginFileEntry)},
}
raw, err := os.ReadFile(path)
if os.IsNotExist(err) {
return pf, nil
}
if err != nil {
return nil, err
}
if err := yaml.Unmarshal(raw, &pf.data); err != nil {
return nil, err
}
if pf.data.Plugins == nil {
pf.data.Plugins = make(map[string]pluginFileEntry)
}
return pf, nil
}
func (pf *PluginsFile) save() error {
raw, err := yaml.Marshal(&pf.data)
if err != nil {
return err
}
return os.WriteFile(pf.path, raw, 0o600)
}
func (pf *PluginsFile) get(id string) (enabled bool, config string, found bool) {
e, ok := pf.data.Plugins[id]
if !ok {
return false, "", false
}
if e.Config == nil {
return e.Enable, "", true
}
raw, err := yaml.Marshal(e.Config)
if err != nil {
return e.Enable, "", true
}
return e.Enable, string(raw), true
}
func (pf *PluginsFile) register(id string, defaultEnabled bool) {
if _, ok := pf.data.Plugins[id]; !ok {
pf.data.Plugins[id] = pluginFileEntry{Enable: defaultEnabled}
_ = pf.save()
}
}
func (pf *PluginsFile) setEnabled(id string, enabled bool) error {
e := pf.data.Plugins[id]
e.Enable = enabled
pf.data.Plugins[id] = e
return pf.save()
}
func (pf *PluginsFile) setConfig(id string, configText string) error {
e := pf.data.Plugins[id]
if configText == "" {
e.Config = nil
} else {
var parsed interface{}
if err := yaml.Unmarshal([]byte(configText), &parsed); err != nil {
return err
}
e.Config = parsed
}
pf.data.Plugins[id] = e
return pf.save()
}
+3
View File
@@ -11,6 +11,7 @@ type HookConfig struct {
}
type Plugin struct {
ID string
Name string
Description string
FilePath string
@@ -37,6 +38,7 @@ func (p *Plugin) HookConfig(name string) (HookConfig, bool) {
}
type Info struct {
ID string
Name string
Description string
FilePath string
@@ -57,6 +59,7 @@ func (p *Plugin) Info() Info {
hooks[k] = v
}
return Info{
ID: p.ID,
Name: p.Name,
Description: p.Description,
FilePath: p.FilePath,
+4 -2
View File
@@ -1,6 +1,7 @@
package proxy
import (
"crypto/subtle"
"encoding/base64"
"fmt"
"io"
@@ -46,7 +47,6 @@ func (a *interceptAddon) Request(f *goproxy.Flow) {
switch a.plugins.RunSyncOnRequest(f) {
case intercept.Drop:
f.Response = dropResponse()
go a.plugins.RunAsyncOnRequest(f)
return
case intercept.Forward:
go a.plugins.RunAsyncOnRequest(f)
@@ -133,7 +133,9 @@ func Start(broker *intercept.Broker, mgr *plugins.Manager) error {
wantUser, wantPass := parts[0], parts[1]
p.SetAuthProxy(func(res http.ResponseWriter, req *http.Request) (bool, error) {
user, pass, ok := parseBasicProxyAuth(req.Header.Get("Proxy-Authorization"))
if !ok || user != wantUser || pass != wantPass {
userOK := subtle.ConstantTimeCompare([]byte(user), []byte(wantUser))
passOK := subtle.ConstantTimeCompare([]byte(pass), []byte(wantPass))
if !ok || userOK&passOK != 1 {
res.Header().Set("Proxy-Authenticate", `Basic realm="spilltea"`)
return false, fmt.Errorf("invalid credentials")
}
+19
View File
@@ -15,6 +15,25 @@ func NewViewport() viewport.Model {
return vp
}
func ViewportView(vp *viewport.Model) string {
v := vp.View()
if vp.AtBottom() {
return v
}
lines := strings.Split(v, "\n")
if len(lines) == 0 {
return v
}
arrow := lipgloss.NewStyle().Foreground(S.Subtle).Render("↓")
arrowW := lipgloss.Width(arrow)
inner := vp.Width() - 2*arrowW
if inner < 0 {
inner = 0
}
lines[len(lines)-1] = arrow + strings.Repeat(" ", inner) + arrow
return strings.Join(lines, "\n")
}
func NewPaginator() paginator.Model {
p := paginator.New()
p.Type = paginator.Dots
+1
View File
@@ -18,6 +18,7 @@ func Paint(c color.Color, s string) string {
func HighlightHTTP(raw string) string {
raw = strings.ReplaceAll(raw, "\r\n", "\n")
raw = strings.ReplaceAll(raw, "\r", "\n")
raw = strings.ReplaceAll(raw, "\t", " ")
idx := strings.Index(raw, "\n\n")
if idx == -1 {
return highlightHeaders(raw)
+18 -6
View File
@@ -28,6 +28,12 @@ type Styles struct {
PagerDotActive string
PagerDotInactive string
methodGet lipgloss.Style
methodPost lipgloss.Style
methodPutPatch lipgloss.Style
methodDelete lipgloss.Style
methodDefault lipgloss.Style
}
var S *Styles
@@ -46,6 +52,7 @@ func Init(cfg *config.Config) {
primary := lipgloss.Color("#" + c.Base0D) // Accent: primary
purple := lipgloss.Color("#" + c.Base0E) // Purple: editing
methodBase := lipgloss.NewStyle().Bold(true).Width(7)
S = &Styles{
Primary: primary,
Success: success,
@@ -74,6 +81,12 @@ func Init(cfg *config.Config) {
PagerDotActive: lipgloss.NewStyle().Foreground(primary).SetString("•").String(),
PagerDotInactive: lipgloss.NewStyle().Foreground(subtle).SetString("•").String(),
methodGet: methodBase.Foreground(success),
methodPost: methodBase.Foreground(warning),
methodPutPatch: methodBase.Foreground(primary),
methodDelete: methodBase.Foreground(errCol),
methodDefault: methodBase.Foreground(text),
}
}
@@ -90,17 +103,16 @@ func NewHelp() help.Model {
}
func (s *Styles) Method(method string) lipgloss.Style {
base := lipgloss.NewStyle().Bold(true).Width(7)
switch method {
case "GET":
return base.Foreground(s.Success)
return s.methodGet
case "POST":
return base.Foreground(s.Warning)
return s.methodPost
case "PUT", "PATCH":
return base.Foreground(s.Primary)
return s.methodPutPatch
case "DELETE":
return base.Foreground(s.Error)
return s.methodDelete
default:
return base.Foreground(s.Text)
return s.methodDefault
}
}
+6
View File
@@ -106,6 +106,12 @@ func New(broker *intercept.Broker, name, path string) Model {
m.findingsPage.SetDB(d)
mgr.SetDB(d)
pf, err := plugins.OpenPluginsFile(path)
if err != nil {
log.Printf("plugins file: %v", err)
}
mgr.SetPluginsFile(pf)
pluginsDir := config.ExpandPath(cfg.App.PluginsDir)
if err := mgr.LoadFromDir(pluginsDir); err != nil {
log.Printf("plugins: %v", err)
+4
View File
@@ -37,6 +37,8 @@ type pageEntry struct {
isEditing func(m *Model) bool
// resize propagates a new (w, h) to the page model.
resize func(m *Model, w, h int)
// hasUpdate reports whether the page has unseen updates.
hasUpdate func(m *Model) bool
}
var pageRegistry = []pageEntry{
@@ -52,6 +54,7 @@ var pageRegistry = []pageEntry{
},
isEditing: func(m *Model) bool { return m.intercept.IsEditing() },
resize: func(m *Model, w, h int) { m.intercept.SetSize(w, h) },
hasUpdate: func(m *Model) bool { return m.intercept.HasUnread() },
},
{
id: pageHistory,
@@ -115,6 +118,7 @@ var pageRegistry = []pageEntry{
return cmd
},
resize: func(m *Model, w, h int) { m.findingsPage.SetSize(w, h) },
hasUpdate: func(m *Model) bool { return m.findingsPage.HasUnread() },
},
{
id: pageDocs,
+4
View File
@@ -61,11 +61,15 @@ func (m *Model) renderSidebar() string {
lineStyle := lipgloss.NewStyle().Width(inner).Padding(0, 1)
var items strings.Builder
badgeUnread := lipgloss.NewStyle().Foreground(s.Warning).Bold(true)
for i, entry := range sidebarEntries {
selected := entry.id == m.page
badgeStyle, textStyle := badgeNormal, textNormal
if selected {
badgeStyle, textStyle = badgeSelected, textSelected
} else if entry.hasUpdate != nil && entry.hasUpdate(m) {
badgeStyle = badgeUnread
}
icon := ""
if entry.icon != nil {
+35 -8
View File
@@ -37,6 +37,9 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
case intercept.RequestArrivedMsg:
updated, cmd := m.intercept.Update(msg)
m.intercept = updated.(interceptUI.Model)
if m.page == pageIntercept {
m.intercept.ClearUnread()
}
return m, tea.Batch(cmd, intercept.WaitForRequest(m.broker))
case intercept.ResponseArrivedMsg:
updated, cmd := m.intercept.Update(msg)
@@ -129,6 +132,9 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
case findingsUI.FindingsLoadedMsg:
updated, cmd := m.findingsPage.Update(msg)
m.findingsPage = updated.(findingsUI.Model)
if m.page == pageFindings {
m.findingsPage.ClearUnread()
}
return m, cmd
case replayUI.SendToReplayMsg:
@@ -187,45 +193,62 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch {
case key.Matches(msg, keys.Keys.Global.CopyAs):
var raw, scheme string
var responseFocused bool
switch m.page {
case pageDiff:
raw = m.diff.CurrentRaw()
scheme = "https"
case pageIntercept:
raw = m.intercept.CurrentRaw()
scheme = m.intercept.CurrentScheme()
responseFocused = m.intercept.IsResponseFocused()
case pageHistory:
raw = m.history.CurrentRaw()
scheme = m.history.CurrentScheme()
responseFocused = m.history.IsResponseFocused()
case pageReplay:
raw = m.replay.CurrentRaw()
scheme = m.replay.CurrentScheme()
responseFocused = m.replay.IsResponseFocused()
}
if raw != "" {
if raw != "" && !responseFocused {
m.copyAs.SetSize(m.width, m.height)
m.copyAs.Open(copyasUI.OpenMsg{RawRequest: raw, Scheme: scheme})
}
return m, nil
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 responseFocused bool
switch m.page {
case pageIntercept:
raw = m.intercept.CurrentRaw()
scheme = m.intercept.CurrentScheme()
case pageDiff:
raw = m.diff.CurrentRaw()
scheme = "https"
responseFocused = m.intercept.IsResponseFocused()
case pageHistory:
raw = m.history.CurrentRaw()
scheme = m.history.CurrentScheme()
responseFocused = m.history.IsResponseFocused()
case pageReplay:
raw = m.replay.CurrentRaw()
scheme = m.replay.CurrentScheme()
responseFocused = m.replay.IsResponseFocused()
}
if raw != "" {
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
@@ -241,8 +264,12 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
return m, m.history.RefreshCmd()
}
if p == pageFindings {
m.findingsPage.ClearUnread()
return m, findingsUI.RefreshCmd(m.database)
}
if p == pageIntercept {
m.intercept.ClearUnread()
}
}
}
}
+12 -8
View File
@@ -1,9 +1,6 @@
package copy
import (
"encoding/base64"
"fmt"
"os"
"strings"
"charm.land/bubbles/v2/list"
@@ -17,14 +14,10 @@ const (
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 {
RawRequest string
Scheme string
ShowURL bool
}
type copyItem struct {
@@ -90,6 +83,17 @@ func (m *Model) Open(msg OpenMsg) {
m.rawRequest = msg.RawRequest
m.scheme = msg.Scheme
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.Select(0)
m.list.SetSize(m.popupInnerWidth(), m.listHeight())
+13 -3
View File
@@ -4,16 +4,26 @@ import (
"charm.land/bubbles/v2/key"
tea "charm.land/bubbletea/v2"
"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) {
if kp, ok := msg.(tea.KeyPressMsg); ok {
switch {
case kp.String() == "enter":
if item, ok := m.list.SelectedItem().(copyItem); ok {
writeClipboard(m.extract(item.id))
}
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
case key.Matches(kp, keys.Keys.Global.Escape):
if m.list.SettingFilter() {
-11
View File
@@ -1,10 +1,6 @@
package copyas
import (
"encoding/base64"
"fmt"
"os"
"charm.land/bubbles/v2/list"
tea "charm.land/bubbletea/v2"
"charm.land/lipgloss/v2"
@@ -16,13 +12,6 @@ const (
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 {
RawRequest string
Scheme string
+13 -3
View File
@@ -4,16 +4,26 @@ import (
"charm.land/bubbles/v2/key"
tea "charm.land/bubbletea/v2"
"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) {
if kp, ok := msg.(tea.KeyPressMsg); ok {
switch {
case kp.String() == "enter":
if item, ok := m.list.SelectedItem().(formatItem); ok {
writeClipboard(formatAs(item.id, m.rawRequest, m.scheme))
}
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
case key.Matches(kp, keys.Keys.Global.Escape):
if m.list.SettingFilter() {
+1 -1
View File
@@ -405,7 +405,7 @@ func (diffKeyMap) ShortHelp() []key.Binding {
func (m diffKeyMap) FullHelp() [][]key.Binding {
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(all, g.CommonBindings()...)
return keys.ChunkByWidth(all, m.width)
+9 -2
View File
@@ -7,6 +7,7 @@ import (
"charm.land/lipgloss/v2"
"github.com/anotherhadi/spilltea/internal/icons"
"github.com/anotherhadi/spilltea/internal/style"
"github.com/charmbracelet/x/ansi"
)
func (m Model) View() tea.View {
@@ -38,6 +39,12 @@ func (m *Model) renderPanels(panelH int) string {
if 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
rightBorder := s.Panel
@@ -51,8 +58,8 @@ func (m *Model) renderPanels(panelH int) string {
rightBorder = s.PanelFocused
}
left := style.RenderWithTitle(leftBorder, leftTitle, m.leftViewport.View(), leftW, panelH)
right := style.RenderWithTitle(rightBorder, rightTitle, m.rightViewport.View(), rightW, panelH)
left := style.RenderWithTitle(leftBorder, leftTitle, style.ViewportView(&m.leftViewport), leftW, panelH)
right := style.RenderWithTitle(rightBorder, rightTitle, style.ViewportView(&m.rightViewport), rightW, panelH)
return lipgloss.JoinHorizontal(lipgloss.Top, left, right)
}
+4 -16
View File
@@ -4,6 +4,7 @@ import (
"charm.land/bubbles/v2/key"
tea "charm.land/bubbletea/v2"
"github.com/anotherhadi/spilltea/internal/keys"
"github.com/anotherhadi/spilltea/internal/util"
)
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) {
case tea.MouseWheelMsg:
switch msg.Button {
case tea.MouseWheelUp:
e.viewport.SetYOffset(e.viewport.YOffset() - 1)
case tea.MouseWheelDown:
e.viewport.SetYOffset(e.viewport.YOffset() + 1)
}
util.HandleMouseWheel(msg, &e.viewport)
case tea.KeyPressMsg:
if e.searching {
@@ -61,17 +57,9 @@ func (e Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
case key.Matches(msg, g.Down):
e.viewport.SetYOffset(e.viewport.YOffset() + 1)
case key.Matches(msg, g.ScrollUp):
step := e.viewport.Height() / 2
if step < 1 {
step = 1
}
e.viewport.SetYOffset(e.viewport.YOffset() - step)
util.ScrollViewport(&e.viewport, -1)
case key.Matches(msg, g.ScrollDown):
step := e.viewport.Height() / 2
if step < 1 {
step = 1
}
e.viewport.SetYOffset(e.viewport.YOffset() + step)
util.ScrollViewport(&e.viewport, 1)
case key.Matches(msg, g.Help):
e.help.ShowAll = !e.help.ShowAll
e.SetSize(e.width, e.height)
+25 -2
View File
@@ -22,6 +22,8 @@ type Model struct {
database *db.DB
findings []db.Finding
cursor int
hasUnread bool
knownCount int
listViewport viewport.Model
bodyViewport viewport.Model
@@ -46,6 +48,17 @@ func New() Model {
func (m Model) Init() tea.Cmd { return nil }
func (m Model) HasUnread() bool { return m.hasUnread }
func (m *Model) ClearUnread() { m.hasUnread = false; m.knownCount = len(m.findings) }
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) {
m.database = d
}
@@ -113,6 +126,14 @@ type FindingsLoadedMsg struct {
}
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 {
m.bodyViewport.SetContent("")
return
@@ -120,7 +141,9 @@ func (m *Model) refreshBody() {
f := m.findings[m.cursor]
rendered := m.renderMarkdownCached(f.Description, m.bodyViewport.Width())
m.bodyViewport.SetContent(rendered)
if reset {
m.bodyViewport.GotoTop()
}
}
func (m *Model) renderMarkdownCached(src string, width int) string {
@@ -164,12 +187,12 @@ type findingsKeyMap struct{ width int }
func (findingsKeyMap) ShortHelp() []key.Binding {
g := keys.Keys.Global
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 {
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(all, g.CommonBindings()...)
return keys.ChunkByWidth(all, m.width)
+23 -39
View File
@@ -6,6 +6,7 @@ import (
"charm.land/bubbles/v2/key"
tea "charm.land/bubbletea/v2"
"github.com/anotherhadi/spilltea/internal/keys"
"github.com/anotherhadi/spilltea/internal/util"
)
func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
@@ -15,7 +16,14 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
log.Printf("findings load error: %v", msg.Err)
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
if len(m.findings) > m.knownCount {
m.hasUnread = true
}
if m.cursor >= len(m.findings) {
m.cursor = max(0, len(m.findings)-1)
}
@@ -26,16 +34,19 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
m.pager.SetTotalPages(len(m.findings))
}
m.refreshListViewport()
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
case tea.MouseWheelMsg:
switch msg.Button {
case tea.MouseWheelUp:
m.bodyViewport.SetYOffset(m.bodyViewport.YOffset() - 1)
case tea.MouseWheelDown:
m.bodyViewport.SetYOffset(m.bodyViewport.YOffset() + 1)
}
util.HandleMouseWheel(msg, &m.bodyViewport)
return m, nil
case tea.KeyPressMsg:
@@ -70,17 +81,9 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
return m, RefreshCmd(m.database)
}
case key.Matches(msg, g.ScrollUp):
step := m.bodyViewport.Height() / 2
if step < 1 {
step = 1
}
m.bodyViewport.SetYOffset(m.bodyViewport.YOffset() - step)
util.ScrollViewport(&m.bodyViewport, -1)
case key.Matches(msg, g.ScrollDown):
step := m.bodyViewport.Height() / 2
if step < 1 {
step = 1
}
m.bodyViewport.SetYOffset(m.bodyViewport.YOffset() + step)
util.ScrollViewport(&m.bodyViewport, 1)
case key.Matches(msg, g.GotoTop):
m.cursor = 0
m.pager.Page = 0
@@ -88,38 +91,19 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
m.refreshBody()
case key.Matches(msg, g.GotoBottom):
if len(m.findings) > 0 {
m.cursor = len(m.findings) - 1
m.pager.Page = m.pager.TotalPages - 1
m.cursor = util.CursorGotoBottom(len(m.findings))
m.pager.Page = util.CursorGotoBottom(m.pager.TotalPages)
m.refreshListViewport()
m.refreshBody()
}
case key.Matches(msg, g.PrevPage):
step := m.pager.PerPage
if step < 1 {
step = 1
}
m.cursor -= step
if m.cursor < 0 {
m.cursor = 0
}
m.cursor = util.CursorMovePage(m.cursor, len(m.findings), m.pager.PerPage, false)
m.pager.Page = m.cursor / m.pager.PerPage
m.refreshListViewport()
m.refreshBody()
case key.Matches(msg, g.NextPage):
step := m.pager.PerPage
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.cursor = util.CursorMovePage(m.cursor, len(m.findings), m.pager.PerPage, true)
m.pager.Page = m.cursor / m.pager.PerPage
m.refreshListViewport()
m.refreshBody()
+2 -8
View File
@@ -45,7 +45,7 @@ func (m *Model) renderBodyPanel(h int) string {
if len(m.findings) > 0 {
title = m.findings[m.cursor].Title
}
return style.RenderWithTitle(s.Panel, title, m.bodyViewport.View(), m.width, h)
return style.RenderWithTitle(s.Panel, title, style.ViewportView(&m.bodyViewport), m.width, h)
}
func (m *Model) renderList() string {
@@ -58,13 +58,7 @@ func (m *Model) renderList() string {
)
}
start, end := m.pager.GetSliceBounds(len(m.findings))
if start < 0 {
start = 0
}
if end < start {
end = start
}
start, end := util.PageBounds(m.pager, len(m.findings))
var sb strings.Builder
for i, f := range m.findings[start:end] {
+7
View File
@@ -60,9 +60,16 @@ func (m Model) CurrentRaw() string {
if len(m.entries) == 0 || m.cursor >= len(m.entries) {
return ""
}
if m.focusedPanel == panelResponse {
return m.entries[m.cursor].ResponseRaw
}
return m.entries[m.cursor].RequestRaw
}
func (m Model) IsResponseFocused() bool {
return m.focusedPanel == panelResponse
}
func (m Model) CurrentScheme() string {
if len(m.entries) == 0 || m.cursor >= len(m.entries) {
return "https"
+26 -53
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() != "") {
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
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) {
m.cursor = len(m.entries) - 1
entryChanged = true
}
if m.cursor < 0 {
m.cursor = 0
entryChanged = true
}
m.pager.SetTotalPages(len(m.entries))
m.refreshListViewport()
m.refreshBody()
if m.cursor != prevCursor {
if entryChanged {
m.bodyViewport.SetYOffset(0)
m.bodyViewport.SetXOffset(0)
}
@@ -75,24 +93,7 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
m.bodyViewport.SetXOffset(0)
case tea.MouseWheelMsg:
switch msg.Button {
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)
}
util.HandleMouseWheel(msg, &m.bodyViewport)
case tea.KeyPressMsg:
h := keys.Keys.History
@@ -258,18 +259,10 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
return m, m.clearSearch()
case key.Matches(msg, g.ScrollUp):
step := m.bodyViewport.Height() / 2
if step < 1 {
step = 1
}
m.bodyViewport.SetYOffset(m.bodyViewport.YOffset() - step)
util.ScrollViewport(&m.bodyViewport, -1)
case key.Matches(msg, g.ScrollDown):
step := m.bodyViewport.Height() / 2
if step < 1 {
step = 1
}
m.bodyViewport.SetYOffset(m.bodyViewport.YOffset() + step)
util.ScrollViewport(&m.bodyViewport, 1)
case key.Matches(msg, g.Left):
m.bodyViewport.ScrollLeft(6)
@@ -286,41 +279,21 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
m.bodyViewport.SetXOffset(0)
case key.Matches(msg, g.GotoBottom):
if len(m.entries) > 0 {
m.cursor = len(m.entries) - 1
m.pager.Page = m.pager.TotalPages - 1
m.cursor = util.CursorGotoBottom(len(m.entries))
m.refreshListViewport()
m.refreshBody()
m.bodyViewport.SetYOffset(0)
m.bodyViewport.SetXOffset(0)
}
case key.Matches(msg, g.PrevPage):
step := m.pager.PerPage
if step < 1 {
step = 1
}
m.cursor -= step
if m.cursor < 0 {
m.cursor = 0
}
m.cursor = util.CursorMovePage(m.cursor, len(m.entries), m.pager.PerPage, false)
m.refreshListViewport()
m.refreshBody()
m.bodyViewport.SetYOffset(0)
m.bodyViewport.SetXOffset(0)
case key.Matches(msg, g.NextPage):
step := m.pager.PerPage
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.cursor = util.CursorMovePage(m.cursor, len(m.entries), m.pager.PerPage, true)
m.refreshListViewport()
m.refreshBody()
m.bodyViewport.SetYOffset(0)
+2 -8
View File
@@ -46,7 +46,7 @@ func (m *Model) renderBodyPanel(h int) string {
if m.focusedPanel == panelResponse {
title = icons.I.Response + "Response"
}
return style.RenderWithTitle(s.Panel, title, m.bodyViewport.View(), m.width, h)
return style.RenderWithTitle(s.Panel, title, style.ViewportView(&m.bodyViewport), m.width, h)
}
func (m *Model) renderStatusBar() string {
@@ -96,13 +96,7 @@ func (m *Model) renderList() string {
)
}
start, end := m.pager.GetSliceBounds(len(m.entries))
if start < 0 {
start = 0
}
if end < start {
end = start
}
start, end := util.PageBounds(m.pager, len(m.entries))
var sb strings.Builder
for i, e := range m.entries[start:end] {
-1
View File
@@ -171,7 +171,6 @@ type Model struct {
teapotFrame int
}
func New(projectDir string) Model {
projects := loadProjects(projectDir)
+2 -2
View File
@@ -138,14 +138,14 @@ func sanitizeName(s string) string {
}
func IsValidProjectName(s string) bool {
if s == "tmp" {
if s == "tmp" || s == "temp" || s == "temporary" {
return true
}
return s != "" && s == sanitizeName(s)
}
func OpenProject(projectDir, name string) (*Project, error) {
if name == "tmp" {
if name == "tmp" || name == "temp" || name == "temporary" {
dir := tempDir()
if err := os.MkdirAll(dir, 0o755); err != nil {
return nil, err
+8
View File
@@ -30,6 +30,7 @@ type Model struct {
editing bool
interceptEnabled bool
hasUnread bool
pendingEdits map[*intercept.PendingRequest]string
pendingResponseEdits map[*intercept.PendingResponse]string
@@ -76,8 +77,15 @@ func New(broker *intercept.Broker) Model {
func (m Model) Init() tea.Cmd { return nil }
func (m Model) HasUnread() bool { return m.hasUnread }
func (m *Model) ClearUnread() { m.hasUnread = false }
func (m Model) IsEditing() bool { return m.editing }
func (m Model) IsResponseFocused() bool {
return m.captureResponse && m.focusedPanel == panelResponses
}
func (m Model) CurrentScheme() string {
if len(m.queue) == 0 {
return "https"
+25 -33
View File
@@ -31,6 +31,7 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
}
wasEmpty := len(m.queue) == 0
m.queue = append(m.queue, msg.Req)
m.hasUnread = true
m.refreshListViewport()
if wasEmpty && (!m.captureResponse || m.focusedPanel == panelRequests) {
m.refreshBody()
@@ -52,24 +53,7 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
case tea.MouseWheelMsg:
if !m.editing {
switch msg.Button {
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)
}
util.HandleMouseWheel(msg, &m.bodyViewport)
}
case tea.KeyPressMsg:
@@ -127,18 +111,10 @@ func (m Model) updateNormalMode(msg tea.KeyPressMsg, cmds *[]tea.Cmd) (tea.Model
}
case key.Matches(msg, keys.Keys.Global.ScrollUp):
step := m.bodyViewport.Height() / 2
if step < 1 {
step = 1
}
m.bodyViewport.SetYOffset(m.bodyViewport.YOffset() - step)
util.ScrollViewport(&m.bodyViewport, -1)
case key.Matches(msg, keys.Keys.Global.ScrollDown):
step := m.bodyViewport.Height() / 2
if step < 1 {
step = 1
}
m.bodyViewport.SetYOffset(m.bodyViewport.YOffset() + step)
util.ScrollViewport(&m.bodyViewport, 1)
case key.Matches(msg, keys.Keys.Global.Left):
m.bodyViewport.ScrollLeft(6)
@@ -278,13 +254,29 @@ func (m Model) updateNormalMode(msg tea.KeyPressMsg, cmds *[]tea.Cmd) (tea.Model
case key.Matches(msg, keys.Keys.Global.GotoBottom):
if onResponses {
if len(m.responseQueue) > 0 {
m.responseCursor = len(m.responseQueue) - 1
}
m.responseCursor = util.CursorGotoBottom(len(m.responseQueue))
} else {
if len(m.queue) > 0 {
m.cursor = len(m.queue) - 1
m.cursor = util.CursorGotoBottom(len(m.queue))
}
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.refreshResponseListViewport()
+3 -15
View File
@@ -87,7 +87,7 @@ func (m *Model) renderBodyPanel(h int) string {
if m.editing {
body = m.textarea.View()
} else {
body = m.bodyViewport.View()
body = style.ViewportView(&m.bodyViewport)
}
border := s.Panel
@@ -109,13 +109,7 @@ func (m *Model) renderList() string {
}
s := style.S
start, end := m.pager.GetSliceBounds(len(m.queue))
if start < 0 {
start = 0
}
if end < start {
end = start
}
start, end := util.PageBounds(m.pager, len(m.queue))
var sb strings.Builder
for i, req := range m.queue[start:end] {
@@ -165,13 +159,7 @@ func (m *Model) renderResponseList() string {
}
s := style.S
start, end := m.responsePager.GetSliceBounds(len(m.responseQueue))
if start < 0 {
start = 0
}
if end < start {
end = start
}
start, end := util.PageBounds(m.responsePager, len(m.responseQueue))
var sb strings.Builder
for i, resp := range m.responseQueue[start:end] {
+20 -20
View File
@@ -4,6 +4,7 @@ import (
"charm.land/bubbles/v2/key"
tea "charm.land/bubbletea/v2"
"github.com/anotherhadi/spilltea/internal/keys"
"github.com/anotherhadi/spilltea/internal/util"
)
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) {
case tea.MouseWheelMsg:
if !m.editing {
switch msg.Button {
case tea.MouseWheelUp:
m.detailViewport.SetYOffset(m.detailViewport.YOffset() - 1)
case tea.MouseWheelDown:
m.detailViewport.SetYOffset(m.detailViewport.YOffset() + 1)
}
util.HandleMouseWheel(msg, &m.detailViewport)
}
case tea.KeyPressMsg:
@@ -63,11 +59,11 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
m.textarea.Blur()
if info, ok := m.selected(); ok && m.manager != nil {
val := m.textarea.Value()
m.manager.SaveConfig(info.Name, val)
m.manager.SaveConfig(info.ID, val)
// Update cached info.
m.filtered[m.cursor].ConfigText = val
for i := range m.items {
if m.items[i].Name == info.Name {
if m.items[i].ID == info.ID {
m.items[i].ConfigText = val
break
}
@@ -111,10 +107,10 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
case key.Matches(msg, pk.Toggle):
if info, ok := m.selected(); ok && m.manager != nil {
m.manager.TogglePlugin(info.Name)
m.manager.TogglePlugin(info.ID)
m.filtered[m.cursor].Enabled = !info.Enabled
for i := range m.items {
if m.items[i].Name == info.Name {
if m.items[i].ID == info.ID {
m.items[i].Enabled = !info.Enabled
break
}
@@ -128,19 +124,23 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
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):
step := m.detailViewport.Height() / 2
if step < 1 {
step = 1
}
m.detailViewport.SetYOffset(m.detailViewport.YOffset() - step)
util.ScrollViewport(&m.detailViewport, -1)
case key.Matches(msg, g.ScrollDown):
step := m.detailViewport.Height() / 2
if step < 1 {
step = 1
}
m.detailViewport.SetYOffset(m.detailViewport.YOffset() + step)
util.ScrollViewport(&m.detailViewport, 1)
case key.Matches(msg, g.Help):
m.help.ShowAll = !m.help.ShowAll
+2 -8
View File
@@ -73,7 +73,7 @@ func (m *Model) renderDetailPanel(h int) string {
s.Faint.Render(filepath.Base(info.FilePath)),
)
parts := []string{header, m.detailViewport.View()}
parts := []string{header, style.ViewportView(&m.detailViewport)}
if m.hasConfig() {
var configLabel string
@@ -143,13 +143,7 @@ func (m *Model) renderList() string {
)
}
start, end := m.pager.GetSliceBounds(len(m.filtered))
if start < 0 {
start = 0
}
if end < start {
end = start
}
start, end := util.PageBounds(m.pager, len(m.filtered))
var sb strings.Builder
for i, p := range m.filtered[start:end] {
+18 -2
View File
@@ -34,10 +34,19 @@ type Entry struct {
Err error
}
type panel int
const (
panelList panel = iota
panelRequest
panelResponse
)
type Model struct {
entries []Entry
cursor int
editing bool
focusedPanel panel
database *db.DB
listViewport 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) IsResponseFocused() bool {
return m.focusedPanel == panelResponse
}
func (m Model) CurrentRaw() string {
if len(m.entries) == 0 || m.cursor >= len(m.entries) {
return ""
}
if m.focusedPanel == panelResponse {
return m.entries[m.cursor].ResponseRaw
}
return m.entries[m.cursor].RequestRaw
}
@@ -183,12 +199,12 @@ type replayKeyMap struct{ width int }
func (replayKeyMap) ShortHelp() []key.Binding {
g := keys.Keys.Global
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 {
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(all, g.CommonBindings()...)
return keys.ChunkByWidth(all, m.width)
+114 -35
View File
@@ -1,6 +1,9 @@
package replay
import (
"bytes"
"compress/gzip"
"compress/zlib"
"crypto/tls"
"fmt"
"io"
@@ -9,13 +12,17 @@ import (
"time"
"charm.land/bubbles/v2/key"
"charm.land/bubbles/v2/viewport"
tea "charm.land/bubbletea/v2"
"charm.land/lipgloss/v2"
"github.com/andybalholm/brotli"
"github.com/anotherhadi/spilltea/internal/config"
"github.com/anotherhadi/spilltea/internal/db"
"github.com/anotherhadi/spilltea/internal/keys"
"github.com/anotherhadi/spilltea/internal/style"
diffUI "github.com/anotherhadi/spilltea/internal/ui/diff"
"github.com/anotherhadi/spilltea/internal/util"
"github.com/klauspost/compress/zstd"
)
type sentMsg struct {
@@ -91,14 +98,14 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
m.requestViewport.ScrollLeft(6)
m.responseViewport.ScrollLeft(6)
} else {
m.responseViewport.SetYOffset(m.responseViewport.YOffset() - 1)
m.scrollFocusedViewportVertical(-1)
}
case tea.MouseWheelDown:
if msg.Mod.Contains(tea.ModShift) {
m.requestViewport.ScrollRight(6)
m.responseViewport.ScrollRight(6)
} else {
m.responseViewport.SetYOffset(m.responseViewport.YOffset() + 1)
m.scrollFocusedViewportVertical(1)
}
case tea.MouseWheelLeft:
m.requestViewport.ScrollLeft(6)
@@ -124,18 +131,36 @@ func (m Model) updateNormalMode(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) {
r := keys.Keys.Replay
switch {
case key.Matches(msg, g.Up):
if m.focusedPanel == panelList {
if m.cursor > 0 {
m.cursor--
m.refreshListViewport()
m.refreshBody()
}
} else {
m.scrollFocusedViewportVertical(-1)
}
case key.Matches(msg, g.Down):
if m.focusedPanel == panelList {
if m.cursor < len(m.entries)-1 {
m.cursor++
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):
if len(m.entries) > 0 && !m.entries[m.cursor].Sending {
@@ -166,18 +191,14 @@ func (m Model) updateNormalMode(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) {
}
case key.Matches(msg, g.ScrollUp):
step := m.responseViewport.Height() / 2
if step < 1 {
step = 1
}
m.responseViewport.SetYOffset(m.responseViewport.YOffset() - step)
vp := m.focusedViewport()
util.ScrollViewport(&vp, -1)
m.setFocusedViewport(vp)
case key.Matches(msg, g.ScrollDown):
step := m.responseViewport.Height() / 2
if step < 1 {
step = 1
}
m.responseViewport.SetYOffset(m.responseViewport.YOffset() + step)
vp := m.focusedViewport()
util.ScrollViewport(&vp, 1)
m.setFocusedViewport(vp)
case key.Matches(msg, g.Left):
m.requestViewport.ScrollLeft(6)
@@ -219,40 +240,38 @@ func (m Model) updateNormalMode(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) {
m.refreshBody()
case key.Matches(msg, keys.Keys.Global.GotoBottom):
if len(m.entries) > 0 {
m.cursor = len(m.entries) - 1
m.pager.Page = m.pager.TotalPages - 1
m.cursor = util.CursorGotoBottom(len(m.entries))
m.refreshListViewport()
m.refreshBody()
}
case key.Matches(msg, keys.Keys.Global.PrevPage):
step := m.pager.PerPage
if step < 1 {
step = 1
}
m.cursor -= step
if m.cursor < 0 {
m.cursor = 0
}
m.cursor = util.CursorMovePage(m.cursor, len(m.entries), m.pager.PerPage, false)
m.refreshListViewport()
m.refreshBody()
case key.Matches(msg, keys.Keys.Global.NextPage):
step := m.pager.PerPage
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.cursor = util.CursorMovePage(m.cursor, len(m.entries), m.pager.PerPage, true)
m.refreshListViewport()
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):
m.help.ShowAll = !m.help.ShowAll
m.recalcSizes()
@@ -280,6 +299,29 @@ func (m Model) updateEditMode(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) {
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() {
if m.pager.PerPage > 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
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
fmt.Fprintf(&sb, "%s %d %s\n", resp.Proto, resp.StatusCode, http.StatusText(resp.StatusCode))
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
}
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 {
errMsg := ""
if e.Err != nil {
+13 -12
View File
@@ -34,9 +34,9 @@ func (m Model) View() tea.View {
func (m *Model) renderListPanel(w, h int) string {
s := style.S
panelStyle := s.PanelFocused
if m.editing {
panelStyle = s.Panel
panelStyle := s.Panel
if !m.editing && m.focusedPanel == panelList {
panelStyle = s.PanelFocused
}
var dots string
if len(m.entries) > 0 {
@@ -57,14 +57,21 @@ func (m *Model) renderRequestPanel(w, h int) string {
body = m.textarea.View()
border = s.PanelFocused
} else {
body = m.requestViewport.View()
body = style.ViewportView(&m.requestViewport)
if m.focusedPanel == panelRequest {
border = s.PanelFocused
}
}
return style.RenderWithTitle(border, icons.I.Request+"Request", body, w, h)
}
func (m *Model) renderResponsePanel(w, h int) string {
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", style.ViewportView(&m.responseViewport), w, h)
}
func (m *Model) renderStatusBar() string {
@@ -81,13 +88,7 @@ func (m *Model) renderList() string {
}
s := style.S
start, end := m.pager.GetSliceBounds(len(m.entries))
if start < 0 {
start = 0
}
if end < start {
end = start
}
start, end := util.PageBounds(m.pager, len(m.entries))
var sb strings.Builder
for i, e := range m.entries[start:end] {
+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
}
+4 -1
View File
@@ -1,6 +1,7 @@
package util
import (
"log"
"os"
"os/exec"
@@ -27,7 +28,9 @@ func OpenExternalEditor(content string) tea.Cmd {
return nil
}
tmpPath := f.Name()
_, _ = f.WriteString(content)
if _, err := f.WriteString(content); err != nil {
log.Printf("editor: writing temp file: %v", err)
}
f.Close()
return tea.ExecProcess(exec.Command(editor, tmpPath), func(err error) tea.Msg {
defer os.Remove(tmpPath)
+13
View File
@@ -3,6 +3,7 @@ package util
import (
"strings"
"charm.land/bubbles/v2/paginator"
"charm.land/lipgloss/v2"
)
@@ -28,6 +29,18 @@ func CenterLines(lines ...string) string {
return strings.Join(centered, "\n")
}
// PageBounds returns clamped start/end indices for rendering a paginated list.
func PageBounds(p paginator.Model, total int) (start, end int) {
start, end = p.GetSliceBounds(total)
if start < 0 {
start = 0
}
if end < start {
end = start
}
return
}
// InferScheme returns "http" for port 80, "https" otherwise.
func InferScheme(host string) string {
if strings.HasSuffix(host, ":80") {
+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)
}
}
-19
View File
@@ -1,19 +0,0 @@
pre-commit:
piped: true
commands:
check-vendor-hash:
glob: "{go.mod,go.sum}"
run: .github/scripts/check-vendor-hash.sh
stage_fixed: true
inject-exec-basics:
glob: "{docs/basics.md,cmd/**}"
run: python3 .github/scripts/inject-exec.py docs/basics.md
stage_fixed: true
inject-exec:
glob: "{README.md,docs/basics.md,cmd/**}"
run: python3 .github/scripts/inject-exec.py README.md
stage_fixed: true
toc:
glob: "{README.md,docs/basics.md,cmd/**}"
run: doctoc --notitle README.md
stage_fixed: true
+242
View File
@@ -0,0 +1,242 @@
schema = 3
[mod]
[mod.'charm.land/bubbles/v2']
version = 'v2.1.0'
hash = 'sha256-2OmqpBrl+taOJzAhVM6OReLmoYRxZOXx9JqFNjQjsPA='
[mod.'charm.land/bubbletea/v2']
version = 'v2.0.6'
hash = 'sha256-1jxXmcnI4peUE0Xs3HGe57pIhRONx235aAaeqm2r434='
[mod.'charm.land/glamour/v2']
version = 'v2.0.0'
hash = 'sha256-CZYlNGw2MihqnSHf1Xxqz55NnqW9fVpLxyvLItryIw4='
[mod.'charm.land/lipgloss/v2']
version = 'v2.0.3'
hash = 'sha256-/RFkSUscU3NwymzT+PfizGf3XyQIdVGQlX7vxktCUGk='
[mod.'github.com/alecthomas/chroma/v2']
version = 'v2.24.1'
hash = 'sha256-DufsljWRKireFuLFcnPozuF0N3UoRYGlEfNFMD+z0ng='
[mod.'github.com/andybalholm/brotli']
version = 'v1.0.4'
hash = 'sha256-gAnPRdGP4yna4hiRIEDyBtDOVJqd7RU27wlPu96Rdf8='
[mod.'github.com/atotto/clipboard']
version = 'v0.1.4'
hash = 'sha256-ZZ7U5X0gWOu8zcjZcWbcpzGOGdycwq0TjTFh/eZHjXk='
[mod.'github.com/aymerick/douceur']
version = 'v0.2.0'
hash = 'sha256-NiBX8EfOvLXNiK3pJaZX4N73YgfzdrzRXdiBFe3X3sE='
[mod.'github.com/charmbracelet/colorprofile']
version = 'v0.4.3'
hash = 'sha256-y+QDUxGOKhugEMQLRUTZYT2C+wKqYHnMLJ44jbh7+JA='
[mod.'github.com/charmbracelet/ultraviolet']
version = 'v0.0.0-20260511121909-c840852527f3'
hash = 'sha256-oWEEaaetCmXvflD03MWy8qD105Kd62iI62+lxofE9Ns='
[mod.'github.com/charmbracelet/x/ansi']
version = 'v0.11.7'
hash = 'sha256-q8BZJq4K7NE5ETocN9/G/EoV0dUyD703ONSfHiUYzWQ='
[mod.'github.com/charmbracelet/x/exp/slice']
version = 'v0.0.0-20260517005351-920740d613be'
hash = 'sha256-bMsbEzP1gHA2OJx4zMYZUI3UFhcTG+mcFm8rRY+Khh8='
[mod.'github.com/charmbracelet/x/term']
version = 'v0.2.2'
hash = 'sha256-KF7IU1Luxl/sZP6XjomWB2e3lxSUS4/5AahhapGir/4='
[mod.'github.com/charmbracelet/x/termios']
version = 'v0.1.1'
hash = 'sha256-sri3LpHCBhGvnJldDzBxwbbZpeSGZVCJFOUL45uBFds='
[mod.'github.com/charmbracelet/x/windows']
version = 'v0.2.2'
hash = 'sha256-CvmE8kAC5wlPSeWjl2hc5xizvGS2FeOLHw84froldkk='
[mod.'github.com/clipperhouse/displaywidth']
version = 'v0.11.0'
hash = 'sha256-WokyTaofEy95xlshqK5YDzpemhXV5oaQifxS9YyfCXo='
[mod.'github.com/clipperhouse/uax29/v2']
version = 'v2.7.0'
hash = 'sha256-GO3az7WiGcwU0OvmocwdfR5ohGRL8NbjscIaMyhAdxE='
[mod.'github.com/dlclark/regexp2']
version = 'v1.12.0'
hash = 'sha256-PVX2rDCkiG0vyA1CbDi3bzLeZ2T8hcqJv3pZ5YwGzMI='
[mod.'github.com/dustin/go-humanize']
version = 'v1.0.1'
hash = 'sha256-yuvxYYngpfVkUg9yAmG99IUVmADTQA0tMbBXe0Fq0Mc='
[mod.'github.com/fsnotify/fsnotify']
version = 'v1.10.1'
hash = 'sha256-6LBLgsh4nKkMpgRKVsYFEaGDSU1fncBcWVSjKBdfgjU='
[mod.'github.com/go-viper/mapstructure/v2']
version = 'v2.5.0'
hash = 'sha256-LbrCBANBprVI84M0CWrXc7rriJL5ac5VKbh58LBTw7U='
[mod.'github.com/golang/groupcache']
version = 'v0.0.0-20210331224755-41bb18bfe9da'
hash = 'sha256-7Gs7CS9gEYZkbu5P4hqPGBpeGZWC64VDwraSKFF+VR0='
[mod.'github.com/google/uuid']
version = 'v1.6.0'
hash = 'sha256-VWl9sqUzdOuhW0KzQlv0gwwUQClYkmZwSydHG2sALYw='
[mod.'github.com/gorilla/css']
version = 'v1.0.1'
hash = 'sha256-6JwNHqlY2NpZ0pSQTyYPSpiNqjXOdFHqrUT10sv3y8A='
[mod.'github.com/gorilla/websocket']
version = 'v1.5.0'
hash = 'sha256-EYVgkSEMo4HaVrsWKqnsYRp8SSS8gNf7t+Elva02Ofc='
[mod.'github.com/klauspost/compress']
version = 'v1.17.8'
hash = 'sha256-8rgCCfHX29le8m6fyVn6gwFde5TPUHjwQqZqv9JIubs='
[mod.'github.com/lqqyt2423/go-mitmproxy']
version = 'v1.8.11'
hash = 'sha256-PoCL6TOt99JsenH4XFx+7MqIzzYnnLHYwUpEQ7kxxyg='
[mod.'github.com/lucasb-eyer/go-colorful']
version = 'v1.4.0'
hash = 'sha256-i/3GDHKEMLCy0kc3mtyk58UWYOPmKoUVaq6QCAWXKP0='
[mod.'github.com/mattn/go-isatty']
version = 'v0.0.22'
hash = 'sha256-6O/0jc33pKUzlzUGpH8Ekk54XgJvx6Qe7kJtbcNJAV4='
[mod.'github.com/mattn/go-runewidth']
version = 'v0.0.23'
hash = 'sha256-SmChZ2U1aR8pW3LPhdM7KcVF5TO6VcHgRzBtUXbBWJA='
[mod.'github.com/microcosm-cc/bluemonday']
version = 'v1.0.27'
hash = 'sha256-EZSya9FLPQ83CL7N2cZy21fdS35hViTkiMK5f3op8Es='
[mod.'github.com/muesli/cancelreader']
version = 'v0.2.2'
hash = 'sha256-uEPpzwRJBJsQWBw6M71FDfgJuR7n55d/7IV8MO+rpwQ='
[mod.'github.com/ncruces/go-strftime']
version = 'v1.0.0'
hash = 'sha256-GYIwYDONuv/yTE0AEugCHQbtV3oiBaco93xUNYFcVBQ='
[mod.'github.com/pelletier/go-toml/v2']
version = 'v2.3.1'
hash = 'sha256-5H8+UOtPOs+Yc+8oVT/3bugCCdbq3jFMH6eOW8dadyg='
[mod.'github.com/remyoudompheng/bigfft']
version = 'v0.0.0-20230129092748-24d4a6f8daec'
hash = 'sha256-vYmpyCE37eBYP/navhaLV4oX4/nu0Z/StAocLIFqrmM='
[mod.'github.com/rivo/uniseg']
version = 'v0.4.7'
hash = 'sha256-rDcdNYH6ZD8KouyyiZCUEy8JrjOQoAkxHBhugrfHjFo='
[mod.'github.com/sagikazarmark/locafero']
version = 'v0.12.0'
hash = 'sha256-EXk9S5Z5sYyApAzCgHIugsGMbt/pHWRfHYFZH5D+5Ws='
[mod.'github.com/sahilm/fuzzy']
version = 'v0.1.1'
hash = 'sha256-f2VsDI6G+V2w31tSDzbZPi9EI2E7jRV6Aq8yeOorSZY='
[mod.'github.com/satori/go.uuid']
version = 'v1.2.0'
hash = 'sha256-y/lSGbnZa7mYJCs30a3LTyjfCFQSaYp8GbVR8dwtmsg='
[mod.'github.com/sirupsen/logrus']
version = 'v1.9.4'
hash = 'sha256-ltRvmtM3XTCAFwY0IesfRqYIivyXPPuvkFjL4ARh1wg='
[mod.'github.com/spf13/afero']
version = 'v1.15.0'
hash = 'sha256-LhcezbOqfuBzacytbqck0hNUxi6NbWNhifUc5/9uHQ8='
[mod.'github.com/spf13/cast']
version = 'v1.10.0'
hash = 'sha256-dQ6Qqf26IZsa6XsGKP7GDuCj+WmSsBmkBwGTDfue/rk='
[mod.'github.com/spf13/pflag']
version = 'v1.0.10'
hash = 'sha256-uDPnWjHpSrzXr17KEYEA1yAbizfcsfo5AyztY2tS6ZU='
[mod.'github.com/spf13/viper']
version = 'v1.21.0'
hash = 'sha256-A9A8i7HH/ge4j3hw7G++HNj8BjhhpZKvxHhfY+QAxkI='
[mod.'github.com/subosito/gotenv']
version = 'v1.6.0'
hash = 'sha256-LspbjTniiq2xAICSXmgqP7carwlNaLqnCTQfw2pa80A='
[mod.'github.com/xo/terminfo']
version = 'v0.0.0-20220910002029-abceb7e1c41e'
hash = 'sha256-GyCDxxMQhXA3Pi/TsWXpA8cX5akEoZV7CFx4RO3rARU='
[mod.'github.com/yuin/goldmark']
version = 'v1.8.2'
hash = 'sha256-LoWfW1Tb6mNuMR7SoA/4SJv4pTKfsVXqeXEVm4uEQ7Q='
[mod.'github.com/yuin/goldmark-emoji']
version = 'v1.0.6'
hash = 'sha256-+d6bZzOPE+JSFsZbQNZMCWE+n3jgcQnkPETVk47mxSY='
[mod.'github.com/yuin/gopher-lua']
version = 'v1.1.2'
hash = 'sha256-2YMCxv7RO3uqq6OTjvE0kR9nTt+n2cF+MrLtGEW68po='
[mod.'go.uber.org/atomic']
version = 'v1.11.0'
hash = 'sha256-TyYws/cSPVqYNffFX0gbDml1bD4bBGcysrUWU7mHPIY='
[mod.'go.yaml.in/yaml/v3']
version = 'v3.0.4'
hash = 'sha256-NkGFiDPoCxbr3LFsI6OCygjjkY0rdmg5ggvVVwpyDQ4='
[mod.'golang.org/x/net']
version = 'v0.54.0'
hash = 'sha256-/EoIXzTQzK/yP/lxOyx0Z/bhns4FdPTIF4uyt4gIP80='
[mod.'golang.org/x/sync']
version = 'v0.20.0'
hash = 'sha256-ybcjhCfK6lroUM0yswUvWooW8MOQZBXyiSqoxG6Uy0Y='
[mod.'golang.org/x/sys']
version = 'v0.44.0'
hash = 'sha256-JDlj+PKsG6I6kjv5JyOUNreY51u5An0oZ5OZMHZSk+A='
[mod.'golang.org/x/text']
version = 'v0.37.0'
hash = 'sha256-8XDOnlPIybcDRy89fkjG5VqtIt5Ku+LmaqYhgKl7i1E='
[mod.'gopkg.in/yaml.v3']
version = 'v3.0.1'
hash = 'sha256-FqL9TKYJ0XkNwJFnq9j0VvJ5ZUU1RvH/52h/f5bkYAU='
[mod.'modernc.org/libc']
version = 'v1.72.3'
hash = 'sha256-eOCoqSnX/VzpG63nh1j3JpXRndnZZcn2cDTeutexXAI='
[mod.'modernc.org/mathutil']
version = 'v1.7.1'
hash = 'sha256-COZ5rF2GhQVR1r6a0DanJ8qwQ94JSKdQxTMWrDzE0Cc='
[mod.'modernc.org/memory']
version = 'v1.11.0'
hash = 'sha256-MkybF8vvrxXS5j7O8w3skwTo0aMo1yjWS0K440rYcHM='
[mod.'modernc.org/sqlite']
version = 'v1.50.1'
hash = 'sha256-JbGP3eerH/6mgzI0aq9SpLLwe6ld6PV/zJReG8mLQN0='
+21
View File
@@ -0,0 +1,21 @@
{
pkgs,
buildGoApplication,
}: let
pname = "spilltea";
version = "0.0.6";
ldflags = ["-s" "-w" "-X main.version=${version}"];
pkg = buildGoApplication {
inherit pname version ldflags;
src = ../.;
modules = ./gomod2nix.toml;
meta = with pkgs.lib; {
description = "A minimal, terminal-based HTTP(S) proxy for pentesters and CTF players.";
homepage = "https://github.com/anotherhadi/spilltea";
platforms = platforms.unix;
};
};
in {
"${pname}" = pkg;
default = pkg;
}
+62
View File
@@ -0,0 +1,62 @@
{
pkgs,
gitHooksLib,
gomod2nixPkgs,
}: let
hooks = gitHooksLib.run {
src = ../.;
hooks = {
gofmt.enable = true;
govet.enable = true;
gomod2nix = {
enable = true;
name = "gomod2nix";
entry = "gomod2nix --outdir ./nix";
language = "system";
files = "go\\.(mod|sum)$";
pass_filenames = false;
};
inject-exec-basics = {
enable = true;
name = "inject-exec-basics";
entry = "python3 .github/scripts/inject-exec.py docs/basics.md";
language = "system";
files = "(docs/basics\\.md|cmd/)";
pass_filenames = false;
};
inject-exec = {
enable = true;
name = "inject-exec";
entry = "python3 .github/scripts/inject-exec.py README.md";
language = "system";
files = "(README\\.md|docs/basics\\.md|cmd/)";
pass_filenames = false;
};
doctoc = {
enable = true;
name = "doctoc";
entry = "doctoc --notitle README.md";
language = "system";
files = "(README\\.md|docs/basics\\.md|cmd/)";
pass_filenames = false;
};
};
};
in
pkgs.mkShell {
packages = with pkgs;
[
go
python3
doctoc
trufflehog
gomod2nixPkgs.gomod2nix
]
++ hooks.enabledPackages;
shellHook = hooks.shellHook;
}
+10 -4
View File
@@ -3,22 +3,28 @@ Plugin = {
description = [[
Inject custom headers into every intercepted request.
**Config**:
- one 'Header-Name: value' per line.
**Config** (YAML):
```yaml
headers:
- "X-My-Header: myvalue"
```
]],
on_request = { sync = true },
}
local headers = {}
function on_config(config_text)
function on_config()
headers = {}
for line in config_text:gmatch("[^\n]+") do
local cfg = get_config()
if cfg and cfg.headers then
for _, line in ipairs(cfg.headers) do
local name, value = line:match("^([^:]+):%s*(.+)$")
if name and value then
table.insert(headers, { name = name, value = value })
end
end
end
end
function on_request(req)
+19 -24
View File
@@ -3,11 +3,13 @@ Plugin = {
description = [[
Checks that the proxy's outbound IP is in an allowed list on startup.
**Config**:
- one IP per line
- prefix with `!` for a blacklist entry (blocked)
- prefix with `#` to comment it out (ignored)
- if no IPs are configured, the check is skipped
**Config** (YAML):
```yaml
ips:
- "1.2.3.4" # whitelist entry
- "!5.6.7.8" # blacklist entry (blocked)
```
- If no IPs are configured, the check is skipped.
]],
on_start = { sync = false },
disable_by_default = true,
@@ -16,23 +18,22 @@ Checks that the proxy's outbound IP is in an allowed list on startup.
local whitelist = {}
local blacklist = {}
function on_config(config_text)
whitelist = {}
blacklist = {}
for line in config_text:gmatch("[^\n]+") do
local trimmed = line:match("^%s*(.-)%s*$")
if trimmed ~= "" and trimmed:sub(1, 1) ~= "#" then
function on_config()
whitelist, blacklist = {}, {}
local cfg = get_config()
if cfg and cfg.ips then
for _, entry in ipairs(cfg.ips) do
local trimmed = entry:match("^%s*(.-)%s*$")
if trimmed ~= "" then
if trimmed:sub(1, 1) == "!" then
local ip = trimmed:sub(2):match("^%s*(.-)%s*$")
if ip ~= "" then
table.insert(blacklist, ip)
end
if ip ~= "" then table.insert(blacklist, ip) end
else
table.insert(whitelist, trimmed)
end
end
end
end
end
function on_start()
@@ -40,16 +41,10 @@ function on_start()
return
end
-- Fetch the current outbound IP via a public API.
local ok, result = pcall(function()
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)
local result, err = shell_pipe("curl -sf https://api.ipify.org 2>/dev/null")
result = result and result:match("^%s*(.-)%s*$") or nil
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")
notif("IP Filter", "Could not determine outbound IP, skipping check", "warning")
return
+27 -28
View File
@@ -3,38 +3,35 @@ Plugin = {
description = [[
Auto-forward requests and exclude them from history based on patterns.
**Config**:
- `pattern` - whitelist: only intercept matching requests/responses and history entries
- `!pattern` - blacklist: skip matching requests/responses and history entries
- `r:pattern` - whitelist for requests/responses only (history unaffected)
- `r:!pattern` - blacklist for requests/responses only
- `h:pattern` - whitelist for history entries only (requests unaffected)
- `h:!pattern` - blacklist for history entries only
- lines starting with `#` are comments
**Config** (YAML):
```yaml
patterns:
- "pattern" # whitelist: only intercept matching requests/responses and history
- "!pattern" # blacklist: skip matching requests/responses and history
- "r:pattern" # whitelist for requests/responses only
- "r:!pattern" # blacklist for requests/responses only
- "h:pattern" # whitelist for history only
- "h:!pattern" # blacklist for history only
```
Example (ignore static assets):
```
!%.css$
!%.js$
!%.png$
```yaml
patterns:
- "!%.css$"
- "!%.js$"
- "!%.png$"
```
Example (focus on mytarget.com, skip everything else):
```
mytarget%.com/
Example (focus on mytarget.com):
```yaml
patterns:
- "mytarget%.com/"
```
Example (intercept mytarget.com except its static assets):
```
mytarget%.com/
!%.css$
!%.js$
!%.png$
```
Example (disable history: whitelist never matches any real URL):
```
h:^$
Example (disable history):
```yaml
patterns:
- "h:^$"
```
]],
priority = 100,
@@ -50,11 +47,13 @@ local whitelist_req = {}
local blacklist_hist = {}
local whitelist_hist = {}
function on_config(config_text)
function on_config()
blacklist, whitelist = {}, {}
blacklist_req, whitelist_req = {}, {}
blacklist_hist, whitelist_hist = {}, {}
for line in config_text:gmatch("[^\n]+") do
local cfg = get_config()
if not cfg or not cfg.patterns then return end
for _, line in ipairs(cfg.patterns) do
local trimmed = line:match("^%s*(.-)%s*$")
if trimmed ~= "" and trimmed:sub(1, 1) ~= "#" then
local scope = trimmed:match("^([rh]):")
+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
+61
View File
@@ -0,0 +1,61 @@
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 result, _ = shell_pipe("command -v trufflehog 2>/dev/null")
if not result or result:match("^%s*$") then
log("trufflehog is not installed or not in PATH")
notif("TruffleHog", "trufflehog is not installed or not in PATH, plugin disabled", "error")
return false
end
end
local function scan(label, content, host, path)
if not content or content == "" then return end
local out, err = shell_pipe("f=$(mktemp) && cat > \"$f\" && trufflehog filesystem --no-color \"$f\"; rc=$?; rm -f \"$f\"; exit $rc", content)
if err and err ~= "" then
log("trufflehog error on " .. label .. ": " .. err)
return
end
if not out or out == "" then return end
local blocks = {}
local current = nil
for line in out:gmatch("[^\n]+") do
if line:match("^Found ") then
if current then table.insert(blocks, current) end
current = line
elseif current then
current = current .. "\n" .. line
end
end
if current then table.insert(blocks, current) end
for _, block in ipairs(blocks) do
create_finding({
title = "Secret detected in " .. label .. " (" .. host .. ")",
description = "**Host:** `" .. host .. "` \n**Path:** `" .. path .. "`\n\n```\n" .. block .. "\n```",
key = host .. "|" .. path .. "|" .. label .. "|" .. block,
severity = "high",
})
end
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
+3
View File
@@ -8,6 +8,9 @@ import (
"path/filepath"
)
//go:embed docs
var DocsFS embed.FS
//go:embed plugins/*.lua
var PluginsFS embed.FS