mirror of
https://github.com/anotherhadi/spilltea.git
synced 2026-05-20 17:52:33 +02:00
Compare commits
16 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 6e673f5c11 | |||
| 2c63cdbeff | |||
| 0e982c6ade | |||
| 04ba32cbd5 | |||
| f7e9da94cc | |||
| 9253d85c81 | |||
| 1bb547870e | |||
| 4251e4fb2a | |||
| b547a79d6e | |||
| fe58468abf | |||
| 3542098905 | |||
| f78b3f7174 | |||
| 722021ba02 | |||
| e18f660e83 | |||
| 67fe8eb911 | |||
| af872afbe8 |
@@ -23,6 +23,6 @@ jobs:
|
|||||||
- uses: goreleaser/goreleaser-action@v6
|
- uses: goreleaser/goreleaser-action@v6
|
||||||
with:
|
with:
|
||||||
version: "~> v2"
|
version: "~> v2"
|
||||||
args: release --clean
|
args: release --clean --config .github/.goreleaser.yaml
|
||||||
env:
|
env:
|
||||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
.claude/
|
.claude/
|
||||||
CLAUDE.md
|
CLAUDE.md
|
||||||
result/
|
result/
|
||||||
|
.pre-commit-config.yaml
|
||||||
|
|||||||
@@ -1,6 +0,0 @@
|
|||||||
package spilltea
|
|
||||||
|
|
||||||
import "embed"
|
|
||||||
|
|
||||||
//go:embed docs
|
|
||||||
var DocsFS embed.FS
|
|
||||||
+27
@@ -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.
|
||||||
+54
-51
@@ -1,5 +1,7 @@
|
|||||||
# Plugins
|
# 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.
|
Spilltea supports Lua plugins that can intercept, modify, and analyze HTTP traffic.
|
||||||
You can found some pre-built plugins [here](../../plugins/).
|
You can found some pre-built plugins [here](../../plugins/).
|
||||||
|
|
||||||
@@ -31,14 +33,14 @@ Plugin = {
|
|||||||
|
|
||||||
### Hook reference
|
### Hook reference
|
||||||
|
|
||||||
| Hook | When called | Sync/async | Return value |
|
| Hook | When called | Sync/async | Return value |
|
||||||
| ------------------------- | ------------------------------------- | ------------ | ----------------------------------------------- |
|
| ------------------------- | ------------------------------------- | ------------ | ----------------------------------------------------------------------------------------- |
|
||||||
| `on_config(config_text)` | At startup and on config save | always sync | ignored |
|
| `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_start()` | Once at startup, after `on_config` | configurable | `false` to self-disable the plugin, otherwise ignored |
|
||||||
| `on_quit()` | When the app exits | always sync | ignored |
|
| `on_quit()` | When the app exits | always sync | ignored |
|
||||||
| `on_request(req)` | Every request, before auto-forward | configurable | `"drop"`, `"forward"`, or `nil` (sync only) |
|
| `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` (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 |
|
| `on_history_entry(entry)` | Sync: before DB insert / Async: after | configurable | `"skip"` (don't save), `"keep"` or `nil` (save) (sync only) |
|
||||||
|
|
||||||
## Request and response objects
|
## Request and response objects
|
||||||
|
|
||||||
@@ -120,17 +122,54 @@ if err then
|
|||||||
else
|
else
|
||||||
log("output: " .. out)
|
log("output: " .. out)
|
||||||
end
|
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
|
### 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
|
## 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
|
## Sync vs async
|
||||||
|
|
||||||
@@ -139,49 +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.
|
`on_config` and `on_quit` are always synchronous regardless of the Plugin table declaration.
|
||||||
|
|
||||||
### Return values for sync hooks
|
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.
|
||||||
**`on_start`:**
|
|
||||||
|
|
||||||
| Return value | Effect |
|
|
||||||
| ------------ | -------------------------------------------------------------------------------------------- |
|
|
||||||
| `false` | The plugin is disabled immediately and the state is persisted (equivalent to toggling it off). |
|
|
||||||
| anything else | Ignored. |
|
|
||||||
|
|
||||||
This is useful for prerequisite checks (binary not found, config invalid, etc.) so the plugin does not silently run in a broken state:
|
|
||||||
|
|
||||||
```lua
|
|
||||||
function on_start()
|
|
||||||
local h = io.popen("command -v mytool 2>/dev/null")
|
|
||||||
local result = h and h:read("*a") or ""
|
|
||||||
if h then h:close() end
|
|
||||||
if result:match("^%s*$") then
|
|
||||||
notif("MyPlugin", "mytool not found, plugin disabled", "error")
|
|
||||||
return false
|
|
||||||
end
|
|
||||||
end
|
|
||||||
```
|
|
||||||
|
|
||||||
**`on_request` and `on_response`:**
|
|
||||||
|
|
||||||
| 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.
|
|
||||||
|
|
||||||
## Priority
|
## 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
|
```lua
|
||||||
Plugin = {
|
Plugin = {
|
||||||
|
|||||||
Generated
+115
@@ -1,5 +1,103 @@
|
|||||||
{
|
{
|
||||||
"nodes": {
|
"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": {
|
"nixpkgs": {
|
||||||
"locked": {
|
"locked": {
|
||||||
"lastModified": 1777954456,
|
"lastModified": 1777954456,
|
||||||
@@ -18,8 +116,25 @@
|
|||||||
},
|
},
|
||||||
"root": {
|
"root": {
|
||||||
"inputs": {
|
"inputs": {
|
||||||
|
"git-hooks": "git-hooks",
|
||||||
|
"gomod2nix": "gomod2nix",
|
||||||
"nixpkgs": "nixpkgs"
|
"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",
|
"root": "root",
|
||||||
|
|||||||
@@ -1,55 +1,41 @@
|
|||||||
{
|
{
|
||||||
description = "Spilltea: A minimal, terminal-based HTTP(S) proxy for pentesters and CTF players.";
|
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 = {
|
outputs = {
|
||||||
self,
|
self,
|
||||||
nixpkgs,
|
nixpkgs,
|
||||||
|
gomod2nix,
|
||||||
|
git-hooks,
|
||||||
}: let
|
}: let
|
||||||
supportedSystems = ["x86_64-linux" "aarch64-linux"];
|
supportedSystems = ["x86_64-linux" "aarch64-linux"];
|
||||||
|
|
||||||
forAllSystems = f:
|
forAllSystems = f:
|
||||||
nixpkgs.lib.genAttrs supportedSystems
|
nixpkgs.lib.genAttrs supportedSystems
|
||||||
(system: f system (import nixpkgs {inherit system;}));
|
(system: f system (import nixpkgs {inherit system;}));
|
||||||
|
|
||||||
pname = "spilltea";
|
|
||||||
version = "0.0.5";
|
|
||||||
|
|
||||||
ldflags = ["-s" "-w" "-X main.version=${version}"];
|
|
||||||
in {
|
in {
|
||||||
packages = forAllSystems (system: pkgs: let
|
packages = forAllSystems (system: pkgs:
|
||||||
pkg = pkgs.buildGoModule {
|
import ./nix/package.nix {
|
||||||
inherit pname version ldflags;
|
inherit pkgs;
|
||||||
|
buildGoApplication = gomod2nix.legacyPackages.${system}.buildGoApplication;
|
||||||
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;
|
|
||||||
});
|
|
||||||
|
|
||||||
devShells = forAllSystems (system: pkgs: {
|
devShells = forAllSystems (system: pkgs: {
|
||||||
default = pkgs.mkShell {
|
default = import ./nix/shell.nix {
|
||||||
packages = with pkgs; [
|
inherit pkgs;
|
||||||
go
|
gitHooksLib = git-hooks.lib.${system};
|
||||||
python3
|
gomod2nixPkgs = gomod2nix.legacyPackages.${system};
|
||||||
lefthook
|
|
||||||
doctoc
|
|
||||||
];
|
|
||||||
|
|
||||||
shellHook = ''
|
|
||||||
lefthook install
|
|
||||||
'';
|
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -41,6 +41,7 @@ type Config struct {
|
|||||||
DefaultInterceptEnabled bool `mapstructure:"default_intercept_enabled"`
|
DefaultInterceptEnabled bool `mapstructure:"default_intercept_enabled"`
|
||||||
DefaultCaptureResponse bool `mapstructure:"default_capture_response"`
|
DefaultCaptureResponse bool `mapstructure:"default_capture_response"`
|
||||||
AutoForwardRegex []string `mapstructure:"auto_forward_regex"`
|
AutoForwardRegex []string `mapstructure:"auto_forward_regex"`
|
||||||
|
QueueSize int `mapstructure:"queue_size"`
|
||||||
} `mapstructure:"intercept"`
|
} `mapstructure:"intercept"`
|
||||||
|
|
||||||
Replay struct {
|
Replay struct {
|
||||||
@@ -82,7 +83,7 @@ func WriteDefaultConfig(path string) error {
|
|||||||
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
|
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
|
||||||
return fmt.Errorf("create config dir: %w", err)
|
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 fmt.Errorf("write config: %w", err)
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
|
|||||||
@@ -5,13 +5,14 @@ app:
|
|||||||
project_dir: ~/.local/share/spilltea
|
project_dir: ~/.local/share/spilltea
|
||||||
plugins_dir: ~/.config/spilltea/plugins
|
plugins_dir: ~/.config/spilltea/plugins
|
||||||
upstream_proxy: "" # e.g. http://corporate-proxy:8888 or http://user:pass@host:8888
|
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)
|
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)
|
external_editor: "" # override $EDITOR for external editing (e.g. nvim, code --wait)
|
||||||
|
|
||||||
intercept:
|
intercept:
|
||||||
default_intercept_enabled: true
|
default_intercept_enabled: true
|
||||||
default_capture_response: false
|
default_capture_response: false
|
||||||
|
queue_size: 64 # max pending intercepted requests/responses before the proxy blocks
|
||||||
auto_forward_regex:
|
auto_forward_regex:
|
||||||
- '\.(js|css|png|gif|ico|woff2?|ttf|svg)(\?.*)?$'
|
- '\.(js|css|png|gif|ico|woff2?|ttf|svg)(\?.*)?$'
|
||||||
|
|
||||||
|
|||||||
+14
-6
@@ -9,6 +9,7 @@ import (
|
|||||||
|
|
||||||
type DB struct {
|
type DB struct {
|
||||||
conn *sql.DB
|
conn *sql.DB
|
||||||
|
roConn *sql.DB
|
||||||
path string
|
path string
|
||||||
dedupMu sync.Mutex
|
dedupMu sync.Mutex
|
||||||
}
|
}
|
||||||
@@ -27,6 +28,17 @@ func Open(path string) (*DB, error) {
|
|||||||
conn.Close()
|
conn.Close()
|
||||||
return nil, err
|
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
|
return d, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -60,12 +72,7 @@ CREATE TABLE IF NOT EXISTS replay_entries (
|
|||||||
status_code INTEGER NOT NULL,
|
status_code INTEGER NOT NULL,
|
||||||
error_msg TEXT NOT NULL
|
error_msg TEXT NOT NULL
|
||||||
);
|
);
|
||||||
CREATE TABLE IF NOT EXISTS plugins (
|
CREATE TABLE IF NOT EXISTS findings (
|
||||||
name TEXT PRIMARY KEY,
|
|
||||||
enabled INTEGER NOT NULL DEFAULT 1,
|
|
||||||
config_text TEXT NOT NULL DEFAULT ''
|
|
||||||
);
|
|
||||||
CREATE TABLE IF NOT EXISTS findings (
|
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
plugin_name TEXT NOT NULL,
|
plugin_name TEXT NOT NULL,
|
||||||
dedup_key TEXT NOT NULL,
|
dedup_key TEXT NOT NULL,
|
||||||
@@ -94,6 +101,7 @@ func (d *DB) Close() error {
|
|||||||
if d == nil {
|
if d == nil {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
_ = d.roConn.Close()
|
||||||
return d.conn.Close()
|
return d.conn.Close()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+9
-12
@@ -4,6 +4,7 @@ import (
|
|||||||
"crypto/sha256"
|
"crypto/sha256"
|
||||||
"database/sql"
|
"database/sql"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"log"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
@@ -63,7 +64,11 @@ func (d *DB) InsertEntry(e Entry, body string) (Entry, error) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return e, err
|
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
|
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
|
// QueryEntries runs a WHERE expression supplied by the user against the entries
|
||||||
// table (e.g. "status_code = 404" or "host LIKE '%example.com%'").
|
// 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
|
// Uses the persistent read-only connection (PRAGMA query_only=ON) so that any
|
||||||
// user-supplied expression is rejected by SQLite before it can execute.
|
// DML or DDL in the user-supplied expression is rejected by SQLite before it executes.
|
||||||
func (d *DB) QueryEntries(where string) ([]Entry, error) {
|
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)
|
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 {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -35,7 +35,7 @@ func (d *DB) UpsertFinding(f Finding) (bool, error) {
|
|||||||
func (d *DB) LoadFindings() ([]Finding, error) {
|
func (d *DB) LoadFindings() ([]Finding, error) {
|
||||||
rows, err := d.conn.Query(
|
rows, err := d.conn.Query(
|
||||||
`SELECT id, plugin_name, dedup_key, title, description, severity, created_at
|
`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 {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
|
|||||||
@@ -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()
|
|
||||||
}
|
|
||||||
@@ -58,9 +58,13 @@ func (b *Broker) SetOnNewEntry(cb func(db.Entry)) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func NewBroker() *Broker {
|
func NewBroker() *Broker {
|
||||||
|
size := config.Global.Intercept.QueueSize
|
||||||
|
if size <= 0 {
|
||||||
|
size = 64
|
||||||
|
}
|
||||||
b := &Broker{
|
b := &Broker{
|
||||||
Incoming: make(chan *PendingRequest, 64),
|
Incoming: make(chan *PendingRequest, size),
|
||||||
IncomingResponse: make(chan *PendingResponse, 64),
|
IncomingResponse: make(chan *PendingResponse, size),
|
||||||
}
|
}
|
||||||
b.SetAutoForwardRegex(config.Global.Intercept.AutoForwardRegex)
|
b.SetAutoForwardRegex(config.Global.Intercept.AutoForwardRegex)
|
||||||
return b
|
return b
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import (
|
|||||||
"github.com/anotherhadi/spilltea/internal/db"
|
"github.com/anotherhadi/spilltea/internal/db"
|
||||||
goproxy "github.com/lqqyt2423/go-mitmproxy/proxy"
|
goproxy "github.com/lqqyt2423/go-mitmproxy/proxy"
|
||||||
lua "github.com/yuin/gopher-lua"
|
lua "github.com/yuin/gopher-lua"
|
||||||
|
"gopkg.in/yaml.v3"
|
||||||
)
|
)
|
||||||
|
|
||||||
func newLuaState(mgr *Manager, p *Plugin) *lua.LState {
|
func newLuaState(mgr *Manager, p *Plugin) *lua.LState {
|
||||||
@@ -175,6 +176,27 @@ func registerUtilities(L *lua.LState, mgr *Manager, p *Plugin) {
|
|||||||
return 0
|
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 {
|
L.SetGlobal("shell_pipe", L.NewFunction(func(L *lua.LState) int {
|
||||||
cmd := L.CheckString(1)
|
cmd := L.CheckString(1)
|
||||||
input := L.OptString(2, "")
|
input := L.OptString(2, "")
|
||||||
@@ -201,6 +223,35 @@ func registerUtilities(L *lua.LState, mgr *Manager, p *Plugin) {
|
|||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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 {
|
func luaTableString(t *lua.LTable, key string) string {
|
||||||
v := t.RawGetString(key)
|
v := t.RawGetString(key)
|
||||||
if s, ok := v.(lua.LString); ok {
|
if s, ok := v.(lua.LString); ok {
|
||||||
|
|||||||
+48
-54
@@ -19,8 +19,9 @@ type Manager struct {
|
|||||||
mu sync.RWMutex
|
mu sync.RWMutex
|
||||||
plugins []*Plugin
|
plugins []*Plugin
|
||||||
|
|
||||||
db *db.DB
|
db *db.DB
|
||||||
broker *intercept.Broker
|
pluginsFile *PluginsFile
|
||||||
|
broker *intercept.Broker
|
||||||
|
|
||||||
Notifs chan PluginNotifMsg
|
Notifs chan PluginNotifMsg
|
||||||
Quit chan string
|
Quit chan string
|
||||||
@@ -43,6 +44,10 @@ func (m *Manager) SetDB(d *db.DB) {
|
|||||||
m.db = d
|
m.db = d
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (m *Manager) SetPluginsFile(pf *PluginsFile) {
|
||||||
|
m.pluginsFile = pf
|
||||||
|
}
|
||||||
|
|
||||||
func (m *Manager) LoadFromDir(dir string) error {
|
func (m *Manager) LoadFromDir(dir string) error {
|
||||||
entries, err := os.ReadDir(dir)
|
entries, err := os.ReadDir(dir)
|
||||||
if os.IsNotExist(err) {
|
if os.IsNotExist(err) {
|
||||||
@@ -52,17 +57,6 @@ func (m *Manager) LoadFromDir(dir string) error {
|
|||||||
return err
|
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 {
|
for _, e := range entries {
|
||||||
if e.IsDir() || !strings.HasSuffix(e.Name(), ".lua") {
|
if e.IsDir() || !strings.HasSuffix(e.Name(), ".lua") {
|
||||||
continue
|
continue
|
||||||
@@ -73,9 +67,13 @@ func (m *Manager) LoadFromDir(dir string) error {
|
|||||||
log.Printf("plugin load error %s: %v", path, err)
|
log.Printf("plugin load error %s: %v", path, err)
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if s, ok := states[p.Name]; ok {
|
if m.pluginsFile != nil {
|
||||||
p.Enabled = s.Enabled
|
if enabled, configText, found := m.pluginsFile.get(p.ID); found {
|
||||||
p.ConfigText = s.ConfigText
|
p.Enabled = enabled
|
||||||
|
p.ConfigText = configText
|
||||||
|
} else {
|
||||||
|
m.pluginsFile.register(p.ID, p.Enabled)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
m.mu.Lock()
|
m.mu.Lock()
|
||||||
m.plugins = append(m.plugins, p)
|
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) {
|
func (m *Manager) loadPlugin(path string) (*Plugin, error) {
|
||||||
p := &Plugin{
|
p := &Plugin{
|
||||||
|
ID: strings.TrimSuffix(filepath.Base(path), ".lua"),
|
||||||
FilePath: path,
|
FilePath: path,
|
||||||
Enabled: true,
|
Enabled: true,
|
||||||
hooks: make(map[string]HookConfig),
|
hooks: make(map[string]HookConfig),
|
||||||
@@ -109,7 +108,7 @@ func (m *Manager) loadPlugin(path string) (*Plugin, error) {
|
|||||||
p.Name = string(s)
|
p.Name = string(s)
|
||||||
}
|
}
|
||||||
if p.Name == "" {
|
if p.Name == "" {
|
||||||
p.Name = strings.TrimSuffix(filepath.Base(path), ".lua")
|
p.Name = p.ID
|
||||||
}
|
}
|
||||||
|
|
||||||
if s, ok := pluginTable.RawGetString("description").(lua.LString); ok {
|
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_response": false,
|
||||||
"on_history_entry": 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 {
|
for hookName, defaultSync := range configurableHooks {
|
||||||
if tbl, ok := pluginTable.RawGetString(hookName).(*lua.LTable); ok {
|
if tbl, ok := pluginTable.RawGetString(hookName).(*lua.LTable); ok {
|
||||||
p.hooks[hookName] = HookConfig{Sync: tbl.RawGetString("sync") == lua.LTrue}
|
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}
|
p.hooks[hookName] = HookConfig{Sync: defaultSync}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
for hookName := range fixedSyncHooks {
|
for _, fixedSync := range []string{"on_config", "on_quit"} {
|
||||||
if p.L.GetGlobal(hookName) != lua.LNil {
|
if p.L.GetGlobal(fixedSync) != lua.LNil {
|
||||||
p.hooks[hookName] = HookConfig{Sync: true}
|
p.hooks[fixedSync] = HookConfig{Sync: true}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -163,11 +156,11 @@ func (m *Manager) GetPlugins() []*Plugin {
|
|||||||
return out
|
return out
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *Manager) TogglePlugin(name string) {
|
func (m *Manager) TogglePlugin(id string) {
|
||||||
m.mu.RLock()
|
m.mu.RLock()
|
||||||
var found *Plugin
|
var found *Plugin
|
||||||
for _, p := range m.plugins {
|
for _, p := range m.plugins {
|
||||||
if p.Name == name {
|
if p.ID == id {
|
||||||
found = p
|
found = p
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
@@ -179,10 +172,11 @@ func (m *Manager) TogglePlugin(name string) {
|
|||||||
found.mu.Lock()
|
found.mu.Lock()
|
||||||
found.Enabled = !found.Enabled
|
found.Enabled = !found.Enabled
|
||||||
enabled := found.Enabled
|
enabled := found.Enabled
|
||||||
configText := found.ConfigText
|
|
||||||
found.mu.Unlock()
|
found.mu.Unlock()
|
||||||
if m.db != nil {
|
if m.pluginsFile != nil {
|
||||||
_ = m.db.SavePluginState(name, enabled, configText)
|
if err := m.pluginsFile.setEnabled(id, enabled); err != nil {
|
||||||
|
log.Printf("plugin %s: save state: %v", id, err)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if !enabled {
|
if !enabled {
|
||||||
return
|
return
|
||||||
@@ -194,8 +188,10 @@ func (m *Manager) TogglePlugin(name string) {
|
|||||||
disableIfFalse := func(p *Plugin, ret lua.LValue) {
|
disableIfFalse := func(p *Plugin, ret lua.LValue) {
|
||||||
if ret == lua.LFalse {
|
if ret == lua.LFalse {
|
||||||
p.Enabled = false
|
p.Enabled = false
|
||||||
if m.db != nil {
|
if m.pluginsFile != nil {
|
||||||
_ = m.db.SavePluginState(p.Name, false, p.ConfigText)
|
if err := m.pluginsFile.setEnabled(p.ID, false); err != nil {
|
||||||
|
log.Printf("plugin %s: save state: %v", p.ID, err)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -222,11 +218,11 @@ func (m *Manager) TogglePlugin(name string) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *Manager) SaveConfig(name, configText string) {
|
func (m *Manager) SaveConfig(id, configText string) {
|
||||||
m.mu.RLock()
|
m.mu.RLock()
|
||||||
var found *Plugin
|
var found *Plugin
|
||||||
for _, p := range m.plugins {
|
for _, p := range m.plugins {
|
||||||
if p.Name == name {
|
if p.ID == id {
|
||||||
found = p
|
found = p
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
@@ -237,39 +233,35 @@ func (m *Manager) SaveConfig(name, configText string) {
|
|||||||
}
|
}
|
||||||
found.mu.Lock()
|
found.mu.Lock()
|
||||||
found.ConfigText = configText
|
found.ConfigText = configText
|
||||||
enabled := found.Enabled
|
|
||||||
_, hasOnConfig := found.hooks["on_config"]
|
|
||||||
found.mu.Unlock()
|
found.mu.Unlock()
|
||||||
if m.db != nil {
|
if m.pluginsFile != nil {
|
||||||
_ = m.db.SavePluginState(name, enabled, configText)
|
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
|
return
|
||||||
}
|
}
|
||||||
// on_config is always sync.
|
|
||||||
found.mu.Lock()
|
found.mu.Lock()
|
||||||
if _, err := callHook(found, "on_config", lua.LString(configText)); err != nil {
|
if _, err := callHook(found, "on_config"); err != nil {
|
||||||
log.Printf("plugin %s on_config (config reload): %v", name, err)
|
log.Printf("plugin %s on_config: %v", id, err)
|
||||||
}
|
}
|
||||||
found.mu.Unlock()
|
found.mu.Unlock()
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *Manager) RunOnStart() {
|
func (m *Manager) RunOnStart() {
|
||||||
// on_config runs first, always sync, for every enabled plugin that has it.
|
|
||||||
for _, p := range m.GetPlugins() {
|
for _, p := range m.GetPlugins() {
|
||||||
if !p.Enabled {
|
if !p.Enabled {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if _, ok := p.hooks["on_config"]; !ok {
|
if _, ok := p.hooks["on_config"]; ok {
|
||||||
continue
|
p.mu.Lock()
|
||||||
|
if _, err := callHook(p, "on_config"); err != nil {
|
||||||
|
log.Printf("plugin %s on_config: %v", p.Name, err)
|
||||||
|
}
|
||||||
|
p.mu.Unlock()
|
||||||
}
|
}
|
||||||
p.mu.Lock()
|
|
||||||
if _, err := callHook(p, "on_config", lua.LString(p.ConfigText)); 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() {
|
for _, p := range m.GetPlugins() {
|
||||||
if !p.Enabled {
|
if !p.Enabled {
|
||||||
continue
|
continue
|
||||||
@@ -281,8 +273,10 @@ func (m *Manager) RunOnStart() {
|
|||||||
disableIfFalse := func(p *Plugin, ret lua.LValue) {
|
disableIfFalse := func(p *Plugin, ret lua.LValue) {
|
||||||
if ret == lua.LFalse {
|
if ret == lua.LFalse {
|
||||||
p.Enabled = false
|
p.Enabled = false
|
||||||
if m.db != nil {
|
if m.pluginsFile != nil {
|
||||||
_ = m.db.SavePluginState(p.Name, false, p.ConfigText)
|
if err := m.pluginsFile.setEnabled(p.ID, false); err != nil {
|
||||||
|
log.Printf("plugin %s: save state: %v", p.ID, err)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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()
|
||||||
|
}
|
||||||
@@ -11,6 +11,7 @@ type HookConfig struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type Plugin struct {
|
type Plugin struct {
|
||||||
|
ID string
|
||||||
Name string
|
Name string
|
||||||
Description string
|
Description string
|
||||||
FilePath string
|
FilePath string
|
||||||
@@ -37,6 +38,7 @@ func (p *Plugin) HookConfig(name string) (HookConfig, bool) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type Info struct {
|
type Info struct {
|
||||||
|
ID string
|
||||||
Name string
|
Name string
|
||||||
Description string
|
Description string
|
||||||
FilePath string
|
FilePath string
|
||||||
@@ -57,6 +59,7 @@ func (p *Plugin) Info() Info {
|
|||||||
hooks[k] = v
|
hooks[k] = v
|
||||||
}
|
}
|
||||||
return Info{
|
return Info{
|
||||||
|
ID: p.ID,
|
||||||
Name: p.Name,
|
Name: p.Name,
|
||||||
Description: p.Description,
|
Description: p.Description,
|
||||||
FilePath: p.FilePath,
|
FilePath: p.FilePath,
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package proxy
|
package proxy
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"crypto/subtle"
|
||||||
"encoding/base64"
|
"encoding/base64"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
@@ -46,7 +47,6 @@ func (a *interceptAddon) Request(f *goproxy.Flow) {
|
|||||||
switch a.plugins.RunSyncOnRequest(f) {
|
switch a.plugins.RunSyncOnRequest(f) {
|
||||||
case intercept.Drop:
|
case intercept.Drop:
|
||||||
f.Response = dropResponse()
|
f.Response = dropResponse()
|
||||||
go a.plugins.RunAsyncOnRequest(f)
|
|
||||||
return
|
return
|
||||||
case intercept.Forward:
|
case intercept.Forward:
|
||||||
go a.plugins.RunAsyncOnRequest(f)
|
go a.plugins.RunAsyncOnRequest(f)
|
||||||
@@ -133,7 +133,9 @@ func Start(broker *intercept.Broker, mgr *plugins.Manager) error {
|
|||||||
wantUser, wantPass := parts[0], parts[1]
|
wantUser, wantPass := parts[0], parts[1]
|
||||||
p.SetAuthProxy(func(res http.ResponseWriter, req *http.Request) (bool, error) {
|
p.SetAuthProxy(func(res http.ResponseWriter, req *http.Request) (bool, error) {
|
||||||
user, pass, ok := parseBasicProxyAuth(req.Header.Get("Proxy-Authorization"))
|
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"`)
|
res.Header().Set("Proxy-Authenticate", `Basic realm="spilltea"`)
|
||||||
return false, fmt.Errorf("invalid credentials")
|
return false, fmt.Errorf("invalid credentials")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,6 +15,25 @@ func NewViewport() viewport.Model {
|
|||||||
return vp
|
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 {
|
func NewPaginator() paginator.Model {
|
||||||
p := paginator.New()
|
p := paginator.New()
|
||||||
p.Type = paginator.Dots
|
p.Type = paginator.Dots
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ func Paint(c color.Color, s string) string {
|
|||||||
func HighlightHTTP(raw string) string {
|
func HighlightHTTP(raw string) string {
|
||||||
raw = strings.ReplaceAll(raw, "\r\n", "\n")
|
raw = strings.ReplaceAll(raw, "\r\n", "\n")
|
||||||
raw = strings.ReplaceAll(raw, "\r", "\n")
|
raw = strings.ReplaceAll(raw, "\r", "\n")
|
||||||
|
raw = strings.ReplaceAll(raw, "\t", " ")
|
||||||
idx := strings.Index(raw, "\n\n")
|
idx := strings.Index(raw, "\n\n")
|
||||||
if idx == -1 {
|
if idx == -1 {
|
||||||
return highlightHeaders(raw)
|
return highlightHeaders(raw)
|
||||||
|
|||||||
+18
-6
@@ -28,6 +28,12 @@ type Styles struct {
|
|||||||
|
|
||||||
PagerDotActive string
|
PagerDotActive string
|
||||||
PagerDotInactive string
|
PagerDotInactive string
|
||||||
|
|
||||||
|
methodGet lipgloss.Style
|
||||||
|
methodPost lipgloss.Style
|
||||||
|
methodPutPatch lipgloss.Style
|
||||||
|
methodDelete lipgloss.Style
|
||||||
|
methodDefault lipgloss.Style
|
||||||
}
|
}
|
||||||
|
|
||||||
var S *Styles
|
var S *Styles
|
||||||
@@ -46,6 +52,7 @@ func Init(cfg *config.Config) {
|
|||||||
primary := lipgloss.Color("#" + c.Base0D) // Accent: primary
|
primary := lipgloss.Color("#" + c.Base0D) // Accent: primary
|
||||||
purple := lipgloss.Color("#" + c.Base0E) // Purple: editing
|
purple := lipgloss.Color("#" + c.Base0E) // Purple: editing
|
||||||
|
|
||||||
|
methodBase := lipgloss.NewStyle().Bold(true).Width(7)
|
||||||
S = &Styles{
|
S = &Styles{
|
||||||
Primary: primary,
|
Primary: primary,
|
||||||
Success: success,
|
Success: success,
|
||||||
@@ -74,6 +81,12 @@ func Init(cfg *config.Config) {
|
|||||||
|
|
||||||
PagerDotActive: lipgloss.NewStyle().Foreground(primary).SetString("•").String(),
|
PagerDotActive: lipgloss.NewStyle().Foreground(primary).SetString("•").String(),
|
||||||
PagerDotInactive: lipgloss.NewStyle().Foreground(subtle).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 {
|
func (s *Styles) Method(method string) lipgloss.Style {
|
||||||
base := lipgloss.NewStyle().Bold(true).Width(7)
|
|
||||||
switch method {
|
switch method {
|
||||||
case "GET":
|
case "GET":
|
||||||
return base.Foreground(s.Success)
|
return s.methodGet
|
||||||
case "POST":
|
case "POST":
|
||||||
return base.Foreground(s.Warning)
|
return s.methodPost
|
||||||
case "PUT", "PATCH":
|
case "PUT", "PATCH":
|
||||||
return base.Foreground(s.Primary)
|
return s.methodPutPatch
|
||||||
case "DELETE":
|
case "DELETE":
|
||||||
return base.Foreground(s.Error)
|
return s.methodDelete
|
||||||
default:
|
default:
|
||||||
return base.Foreground(s.Text)
|
return s.methodDefault
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -106,6 +106,12 @@ func New(broker *intercept.Broker, name, path string) Model {
|
|||||||
m.findingsPage.SetDB(d)
|
m.findingsPage.SetDB(d)
|
||||||
mgr.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)
|
pluginsDir := config.ExpandPath(cfg.App.PluginsDir)
|
||||||
if err := mgr.LoadFromDir(pluginsDir); err != nil {
|
if err := mgr.LoadFromDir(pluginsDir); err != nil {
|
||||||
log.Printf("plugins: %v", err)
|
log.Printf("plugins: %v", err)
|
||||||
|
|||||||
@@ -37,6 +37,8 @@ type pageEntry struct {
|
|||||||
isEditing func(m *Model) bool
|
isEditing func(m *Model) bool
|
||||||
// resize propagates a new (w, h) to the page model.
|
// resize propagates a new (w, h) to the page model.
|
||||||
resize func(m *Model, w, h int)
|
resize func(m *Model, w, h int)
|
||||||
|
// hasUpdate reports whether the page has unseen updates.
|
||||||
|
hasUpdate func(m *Model) bool
|
||||||
}
|
}
|
||||||
|
|
||||||
var pageRegistry = []pageEntry{
|
var pageRegistry = []pageEntry{
|
||||||
@@ -52,6 +54,7 @@ var pageRegistry = []pageEntry{
|
|||||||
},
|
},
|
||||||
isEditing: func(m *Model) bool { return m.intercept.IsEditing() },
|
isEditing: func(m *Model) bool { return m.intercept.IsEditing() },
|
||||||
resize: func(m *Model, w, h int) { m.intercept.SetSize(w, h) },
|
resize: func(m *Model, w, h int) { m.intercept.SetSize(w, h) },
|
||||||
|
hasUpdate: func(m *Model) bool { return m.intercept.HasUnread() },
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: pageHistory,
|
id: pageHistory,
|
||||||
@@ -114,7 +117,8 @@ var pageRegistry = []pageEntry{
|
|||||||
m.findingsPage = updated.(findingsUI.Model)
|
m.findingsPage = updated.(findingsUI.Model)
|
||||||
return cmd
|
return cmd
|
||||||
},
|
},
|
||||||
resize: func(m *Model, w, h int) { m.findingsPage.SetSize(w, h) },
|
resize: func(m *Model, w, h int) { m.findingsPage.SetSize(w, h) },
|
||||||
|
hasUpdate: func(m *Model) bool { return m.findingsPage.HasUnread() },
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: pageDocs,
|
id: pageDocs,
|
||||||
|
|||||||
@@ -61,11 +61,15 @@ func (m *Model) renderSidebar() string {
|
|||||||
lineStyle := lipgloss.NewStyle().Width(inner).Padding(0, 1)
|
lineStyle := lipgloss.NewStyle().Width(inner).Padding(0, 1)
|
||||||
|
|
||||||
var items strings.Builder
|
var items strings.Builder
|
||||||
|
badgeUnread := lipgloss.NewStyle().Foreground(s.Warning).Bold(true)
|
||||||
|
|
||||||
for i, entry := range sidebarEntries {
|
for i, entry := range sidebarEntries {
|
||||||
selected := entry.id == m.page
|
selected := entry.id == m.page
|
||||||
badgeStyle, textStyle := badgeNormal, textNormal
|
badgeStyle, textStyle := badgeNormal, textNormal
|
||||||
if selected {
|
if selected {
|
||||||
badgeStyle, textStyle = badgeSelected, textSelected
|
badgeStyle, textStyle = badgeSelected, textSelected
|
||||||
|
} else if entry.hasUpdate != nil && entry.hasUpdate(m) {
|
||||||
|
badgeStyle = badgeUnread
|
||||||
}
|
}
|
||||||
icon := ""
|
icon := ""
|
||||||
if entry.icon != nil {
|
if entry.icon != nil {
|
||||||
|
|||||||
@@ -37,6 +37,9 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||||||
case intercept.RequestArrivedMsg:
|
case intercept.RequestArrivedMsg:
|
||||||
updated, cmd := m.intercept.Update(msg)
|
updated, cmd := m.intercept.Update(msg)
|
||||||
m.intercept = updated.(interceptUI.Model)
|
m.intercept = updated.(interceptUI.Model)
|
||||||
|
if m.page == pageIntercept {
|
||||||
|
m.intercept.ClearUnread()
|
||||||
|
}
|
||||||
return m, tea.Batch(cmd, intercept.WaitForRequest(m.broker))
|
return m, tea.Batch(cmd, intercept.WaitForRequest(m.broker))
|
||||||
case intercept.ResponseArrivedMsg:
|
case intercept.ResponseArrivedMsg:
|
||||||
updated, cmd := m.intercept.Update(msg)
|
updated, cmd := m.intercept.Update(msg)
|
||||||
@@ -129,6 +132,9 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||||||
case findingsUI.FindingsLoadedMsg:
|
case findingsUI.FindingsLoadedMsg:
|
||||||
updated, cmd := m.findingsPage.Update(msg)
|
updated, cmd := m.findingsPage.Update(msg)
|
||||||
m.findingsPage = updated.(findingsUI.Model)
|
m.findingsPage = updated.(findingsUI.Model)
|
||||||
|
if m.page == pageFindings {
|
||||||
|
m.findingsPage.ClearUnread()
|
||||||
|
}
|
||||||
return m, cmd
|
return m, cmd
|
||||||
|
|
||||||
case replayUI.SendToReplayMsg:
|
case replayUI.SendToReplayMsg:
|
||||||
@@ -258,8 +264,12 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||||||
return m, m.history.RefreshCmd()
|
return m, m.history.RefreshCmd()
|
||||||
}
|
}
|
||||||
if p == pageFindings {
|
if p == pageFindings {
|
||||||
|
m.findingsPage.ClearUnread()
|
||||||
return m, findingsUI.RefreshCmd(m.database)
|
return m, findingsUI.RefreshCmd(m.database)
|
||||||
}
|
}
|
||||||
|
if p == pageIntercept {
|
||||||
|
m.intercept.ClearUnread()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,7 +14,6 @@ const (
|
|||||||
popupH = 20
|
popupH = 20
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
type OpenMsg struct {
|
type OpenMsg struct {
|
||||||
RawRequest string
|
RawRequest string
|
||||||
Scheme string
|
Scheme string
|
||||||
|
|||||||
@@ -12,7 +12,6 @@ const (
|
|||||||
popupH = 20
|
popupH = 20
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
type OpenMsg struct {
|
type OpenMsg struct {
|
||||||
RawRequest string
|
RawRequest string
|
||||||
Scheme string
|
Scheme string
|
||||||
|
|||||||
@@ -58,8 +58,8 @@ func (m *Model) renderPanels(panelH int) string {
|
|||||||
rightBorder = s.PanelFocused
|
rightBorder = s.PanelFocused
|
||||||
}
|
}
|
||||||
|
|
||||||
left := style.RenderWithTitle(leftBorder, leftTitle, m.leftViewport.View(), leftW, panelH)
|
left := style.RenderWithTitle(leftBorder, leftTitle, style.ViewportView(&m.leftViewport), leftW, panelH)
|
||||||
right := style.RenderWithTitle(rightBorder, rightTitle, m.rightViewport.View(), rightW, panelH)
|
right := style.RenderWithTitle(rightBorder, rightTitle, style.ViewportView(&m.rightViewport), rightW, panelH)
|
||||||
|
|
||||||
return lipgloss.JoinHorizontal(lipgloss.Top, left, right)
|
return lipgloss.JoinHorizontal(lipgloss.Top, left, right)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,9 +19,11 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
type Model struct {
|
type Model struct {
|
||||||
database *db.DB
|
database *db.DB
|
||||||
findings []db.Finding
|
findings []db.Finding
|
||||||
cursor int
|
cursor int
|
||||||
|
hasUnread bool
|
||||||
|
knownCount int
|
||||||
|
|
||||||
listViewport viewport.Model
|
listViewport viewport.Model
|
||||||
bodyViewport viewport.Model
|
bodyViewport viewport.Model
|
||||||
@@ -46,6 +48,9 @@ func New() Model {
|
|||||||
|
|
||||||
func (m Model) Init() tea.Cmd { return nil }
|
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 {
|
func (m *Model) CurrentMarkdown() string {
|
||||||
if len(m.findings) == 0 {
|
if len(m.findings) == 0 {
|
||||||
return ""
|
return ""
|
||||||
|
|||||||
@@ -21,6 +21,9 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||||||
prevID = m.findings[m.cursor].ID
|
prevID = m.findings[m.cursor].ID
|
||||||
}
|
}
|
||||||
m.findings = msg.Findings
|
m.findings = msg.Findings
|
||||||
|
if len(m.findings) > m.knownCount {
|
||||||
|
m.hasUnread = true
|
||||||
|
}
|
||||||
if m.cursor >= len(m.findings) {
|
if m.cursor >= len(m.findings) {
|
||||||
m.cursor = max(0, len(m.findings)-1)
|
m.cursor = max(0, len(m.findings)-1)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -45,7 +45,7 @@ func (m *Model) renderBodyPanel(h int) string {
|
|||||||
if len(m.findings) > 0 {
|
if len(m.findings) > 0 {
|
||||||
title = m.findings[m.cursor].Title
|
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 {
|
func (m *Model) renderList() string {
|
||||||
@@ -58,13 +58,7 @@ func (m *Model) renderList() string {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
start, end := m.pager.GetSliceBounds(len(m.findings))
|
start, end := util.PageBounds(m.pager, len(m.findings))
|
||||||
if start < 0 {
|
|
||||||
start = 0
|
|
||||||
}
|
|
||||||
if end < start {
|
|
||||||
end = start
|
|
||||||
}
|
|
||||||
|
|
||||||
var sb strings.Builder
|
var sb strings.Builder
|
||||||
for i, f := range m.findings[start:end] {
|
for i, f := range m.findings[start:end] {
|
||||||
|
|||||||
@@ -46,7 +46,7 @@ func (m *Model) renderBodyPanel(h int) string {
|
|||||||
if m.focusedPanel == panelResponse {
|
if m.focusedPanel == panelResponse {
|
||||||
title = icons.I.Response + "Response"
|
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 {
|
func (m *Model) renderStatusBar() string {
|
||||||
@@ -96,13 +96,7 @@ func (m *Model) renderList() string {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
start, end := m.pager.GetSliceBounds(len(m.entries))
|
start, end := util.PageBounds(m.pager, len(m.entries))
|
||||||
if start < 0 {
|
|
||||||
start = 0
|
|
||||||
}
|
|
||||||
if end < start {
|
|
||||||
end = start
|
|
||||||
}
|
|
||||||
|
|
||||||
var sb strings.Builder
|
var sb strings.Builder
|
||||||
for i, e := range m.entries[start:end] {
|
for i, e := range m.entries[start:end] {
|
||||||
|
|||||||
@@ -138,14 +138,14 @@ func sanitizeName(s string) string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func IsValidProjectName(s string) bool {
|
func IsValidProjectName(s string) bool {
|
||||||
if s == "tmp" {
|
if s == "tmp" || s == "temp" || s == "temporary" {
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
return s != "" && s == sanitizeName(s)
|
return s != "" && s == sanitizeName(s)
|
||||||
}
|
}
|
||||||
|
|
||||||
func OpenProject(projectDir, name string) (*Project, error) {
|
func OpenProject(projectDir, name string) (*Project, error) {
|
||||||
if name == "tmp" {
|
if name == "tmp" || name == "temp" || name == "temporary" {
|
||||||
dir := tempDir()
|
dir := tempDir()
|
||||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ type Model struct {
|
|||||||
|
|
||||||
editing bool
|
editing bool
|
||||||
interceptEnabled bool
|
interceptEnabled bool
|
||||||
|
hasUnread bool
|
||||||
pendingEdits map[*intercept.PendingRequest]string
|
pendingEdits map[*intercept.PendingRequest]string
|
||||||
pendingResponseEdits map[*intercept.PendingResponse]string
|
pendingResponseEdits map[*intercept.PendingResponse]string
|
||||||
|
|
||||||
@@ -76,6 +77,9 @@ func New(broker *intercept.Broker) Model {
|
|||||||
|
|
||||||
func (m Model) Init() tea.Cmd { return nil }
|
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) IsEditing() bool { return m.editing }
|
||||||
|
|
||||||
func (m Model) IsResponseFocused() bool {
|
func (m Model) IsResponseFocused() bool {
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||||||
}
|
}
|
||||||
wasEmpty := len(m.queue) == 0
|
wasEmpty := len(m.queue) == 0
|
||||||
m.queue = append(m.queue, msg.Req)
|
m.queue = append(m.queue, msg.Req)
|
||||||
|
m.hasUnread = true
|
||||||
m.refreshListViewport()
|
m.refreshListViewport()
|
||||||
if wasEmpty && (!m.captureResponse || m.focusedPanel == panelRequests) {
|
if wasEmpty && (!m.captureResponse || m.focusedPanel == panelRequests) {
|
||||||
m.refreshBody()
|
m.refreshBody()
|
||||||
|
|||||||
@@ -87,7 +87,7 @@ func (m *Model) renderBodyPanel(h int) string {
|
|||||||
if m.editing {
|
if m.editing {
|
||||||
body = m.textarea.View()
|
body = m.textarea.View()
|
||||||
} else {
|
} else {
|
||||||
body = m.bodyViewport.View()
|
body = style.ViewportView(&m.bodyViewport)
|
||||||
}
|
}
|
||||||
|
|
||||||
border := s.Panel
|
border := s.Panel
|
||||||
@@ -109,13 +109,7 @@ func (m *Model) renderList() string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
s := style.S
|
s := style.S
|
||||||
start, end := m.pager.GetSliceBounds(len(m.queue))
|
start, end := util.PageBounds(m.pager, len(m.queue))
|
||||||
if start < 0 {
|
|
||||||
start = 0
|
|
||||||
}
|
|
||||||
if end < start {
|
|
||||||
end = start
|
|
||||||
}
|
|
||||||
|
|
||||||
var sb strings.Builder
|
var sb strings.Builder
|
||||||
for i, req := range m.queue[start:end] {
|
for i, req := range m.queue[start:end] {
|
||||||
@@ -165,13 +159,7 @@ func (m *Model) renderResponseList() string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
s := style.S
|
s := style.S
|
||||||
start, end := m.responsePager.GetSliceBounds(len(m.responseQueue))
|
start, end := util.PageBounds(m.responsePager, len(m.responseQueue))
|
||||||
if start < 0 {
|
|
||||||
start = 0
|
|
||||||
}
|
|
||||||
if end < start {
|
|
||||||
end = start
|
|
||||||
}
|
|
||||||
|
|
||||||
var sb strings.Builder
|
var sb strings.Builder
|
||||||
for i, resp := range m.responseQueue[start:end] {
|
for i, resp := range m.responseQueue[start:end] {
|
||||||
|
|||||||
@@ -59,11 +59,11 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||||||
m.textarea.Blur()
|
m.textarea.Blur()
|
||||||
if info, ok := m.selected(); ok && m.manager != nil {
|
if info, ok := m.selected(); ok && m.manager != nil {
|
||||||
val := m.textarea.Value()
|
val := m.textarea.Value()
|
||||||
m.manager.SaveConfig(info.Name, val)
|
m.manager.SaveConfig(info.ID, val)
|
||||||
// Update cached info.
|
// Update cached info.
|
||||||
m.filtered[m.cursor].ConfigText = val
|
m.filtered[m.cursor].ConfigText = val
|
||||||
for i := range m.items {
|
for i := range m.items {
|
||||||
if m.items[i].Name == info.Name {
|
if m.items[i].ID == info.ID {
|
||||||
m.items[i].ConfigText = val
|
m.items[i].ConfigText = val
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
@@ -107,10 +107,10 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||||||
|
|
||||||
case key.Matches(msg, pk.Toggle):
|
case key.Matches(msg, pk.Toggle):
|
||||||
if info, ok := m.selected(); ok && m.manager != nil {
|
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
|
m.filtered[m.cursor].Enabled = !info.Enabled
|
||||||
for i := range m.items {
|
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
|
m.items[i].Enabled = !info.Enabled
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -73,7 +73,7 @@ func (m *Model) renderDetailPanel(h int) string {
|
|||||||
s.Faint.Render(filepath.Base(info.FilePath)),
|
s.Faint.Render(filepath.Base(info.FilePath)),
|
||||||
)
|
)
|
||||||
|
|
||||||
parts := []string{header, m.detailViewport.View()}
|
parts := []string{header, style.ViewportView(&m.detailViewport)}
|
||||||
|
|
||||||
if m.hasConfig() {
|
if m.hasConfig() {
|
||||||
var configLabel string
|
var configLabel string
|
||||||
@@ -143,13 +143,7 @@ func (m *Model) renderList() string {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
start, end := m.pager.GetSliceBounds(len(m.filtered))
|
start, end := util.PageBounds(m.pager, len(m.filtered))
|
||||||
if start < 0 {
|
|
||||||
start = 0
|
|
||||||
}
|
|
||||||
if end < start {
|
|
||||||
end = start
|
|
||||||
}
|
|
||||||
|
|
||||||
var sb strings.Builder
|
var sb strings.Builder
|
||||||
for i, p := range m.filtered[start:end] {
|
for i, p := range m.filtered[start:end] {
|
||||||
|
|||||||
@@ -20,8 +20,8 @@ import (
|
|||||||
"github.com/anotherhadi/spilltea/internal/db"
|
"github.com/anotherhadi/spilltea/internal/db"
|
||||||
"github.com/anotherhadi/spilltea/internal/keys"
|
"github.com/anotherhadi/spilltea/internal/keys"
|
||||||
"github.com/anotherhadi/spilltea/internal/style"
|
"github.com/anotherhadi/spilltea/internal/style"
|
||||||
"github.com/anotherhadi/spilltea/internal/util"
|
|
||||||
diffUI "github.com/anotherhadi/spilltea/internal/ui/diff"
|
diffUI "github.com/anotherhadi/spilltea/internal/ui/diff"
|
||||||
|
"github.com/anotherhadi/spilltea/internal/util"
|
||||||
"github.com/klauspost/compress/zstd"
|
"github.com/klauspost/compress/zstd"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -57,7 +57,7 @@ func (m *Model) renderRequestPanel(w, h int) string {
|
|||||||
body = m.textarea.View()
|
body = m.textarea.View()
|
||||||
border = s.PanelFocused
|
border = s.PanelFocused
|
||||||
} else {
|
} else {
|
||||||
body = m.requestViewport.View()
|
body = style.ViewportView(&m.requestViewport)
|
||||||
if m.focusedPanel == panelRequest {
|
if m.focusedPanel == panelRequest {
|
||||||
border = s.PanelFocused
|
border = s.PanelFocused
|
||||||
}
|
}
|
||||||
@@ -71,7 +71,7 @@ func (m *Model) renderResponsePanel(w, h int) string {
|
|||||||
if !m.editing && m.focusedPanel == panelResponse {
|
if !m.editing && m.focusedPanel == panelResponse {
|
||||||
border = s.PanelFocused
|
border = s.PanelFocused
|
||||||
}
|
}
|
||||||
return style.RenderWithTitle(border, icons.I.Response+"Response", m.responseViewport.View(), w, h)
|
return style.RenderWithTitle(border, icons.I.Response+"Response", style.ViewportView(&m.responseViewport), w, h)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *Model) renderStatusBar() string {
|
func (m *Model) renderStatusBar() string {
|
||||||
@@ -88,13 +88,7 @@ func (m *Model) renderList() string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
s := style.S
|
s := style.S
|
||||||
start, end := m.pager.GetSliceBounds(len(m.entries))
|
start, end := util.PageBounds(m.pager, len(m.entries))
|
||||||
if start < 0 {
|
|
||||||
start = 0
|
|
||||||
}
|
|
||||||
if end < start {
|
|
||||||
end = start
|
|
||||||
}
|
|
||||||
|
|
||||||
var sb strings.Builder
|
var sb strings.Builder
|
||||||
for i, e := range m.entries[start:end] {
|
for i, e := range m.entries[start:end] {
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package util
|
package util
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"log"
|
||||||
"os"
|
"os"
|
||||||
"os/exec"
|
"os/exec"
|
||||||
|
|
||||||
@@ -27,7 +28,9 @@ func OpenExternalEditor(content string) tea.Cmd {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
tmpPath := f.Name()
|
tmpPath := f.Name()
|
||||||
_, _ = f.WriteString(content)
|
if _, err := f.WriteString(content); err != nil {
|
||||||
|
log.Printf("editor: writing temp file: %v", err)
|
||||||
|
}
|
||||||
f.Close()
|
f.Close()
|
||||||
return tea.ExecProcess(exec.Command(editor, tmpPath), func(err error) tea.Msg {
|
return tea.ExecProcess(exec.Command(editor, tmpPath), func(err error) tea.Msg {
|
||||||
defer os.Remove(tmpPath)
|
defer os.Remove(tmpPath)
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package util
|
|||||||
import (
|
import (
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
|
"charm.land/bubbles/v2/paginator"
|
||||||
"charm.land/lipgloss/v2"
|
"charm.land/lipgloss/v2"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -28,6 +29,18 @@ func CenterLines(lines ...string) string {
|
|||||||
return strings.Join(centered, "\n")
|
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.
|
// InferScheme returns "http" for port 80, "https" otherwise.
|
||||||
func InferScheme(host string) string {
|
func InferScheme(host string) string {
|
||||||
if strings.HasSuffix(host, ":80") {
|
if strings.HasSuffix(host, ":80") {
|
||||||
|
|||||||
@@ -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
|
|
||||||
@@ -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='
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -3,20 +3,26 @@ Plugin = {
|
|||||||
description = [[
|
description = [[
|
||||||
Inject custom headers into every intercepted request.
|
Inject custom headers into every intercepted request.
|
||||||
|
|
||||||
**Config**:
|
**Config** (YAML):
|
||||||
- one 'Header-Name: value' per line.
|
```yaml
|
||||||
|
headers:
|
||||||
|
- "X-My-Header: myvalue"
|
||||||
|
```
|
||||||
]],
|
]],
|
||||||
on_request = { sync = true },
|
on_request = { sync = true },
|
||||||
}
|
}
|
||||||
|
|
||||||
local headers = {}
|
local headers = {}
|
||||||
|
|
||||||
function on_config(config_text)
|
function on_config()
|
||||||
headers = {}
|
headers = {}
|
||||||
for line in config_text:gmatch("[^\n]+") do
|
local cfg = get_config()
|
||||||
local name, value = line:match("^([^:]+):%s*(.+)$")
|
if cfg and cfg.headers then
|
||||||
if name and value then
|
for _, line in ipairs(cfg.headers) do
|
||||||
table.insert(headers, { name = name, value = value })
|
local name, value = line:match("^([^:]+):%s*(.+)$")
|
||||||
|
if name and value then
|
||||||
|
table.insert(headers, { name = name, value = value })
|
||||||
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
+20
-19
@@ -3,33 +3,34 @@ Plugin = {
|
|||||||
description = [[
|
description = [[
|
||||||
Checks that the proxy's outbound IP is in an allowed list on startup.
|
Checks that the proxy's outbound IP is in an allowed list on startup.
|
||||||
|
|
||||||
**Config**:
|
**Config** (YAML):
|
||||||
- one IP per line
|
```yaml
|
||||||
- prefix with `!` for a blacklist entry (blocked)
|
ips:
|
||||||
- prefix with `#` to comment it out (ignored)
|
- "1.2.3.4" # whitelist entry
|
||||||
- if no IPs are configured, the check is skipped
|
- "!5.6.7.8" # blacklist entry (blocked)
|
||||||
|
```
|
||||||
|
- If no IPs are configured, the check is skipped.
|
||||||
]],
|
]],
|
||||||
on_start = { sync = false },
|
on_start = { sync = false },
|
||||||
disable_by_default = true,
|
disable_by_default = true,
|
||||||
}
|
}
|
||||||
|
|
||||||
local whitelist = {}
|
local whitelist = {}
|
||||||
local blacklist = {}
|
local blacklist = {}
|
||||||
|
|
||||||
function on_config(config_text)
|
function on_config()
|
||||||
whitelist = {}
|
whitelist, blacklist = {}, {}
|
||||||
blacklist = {}
|
local cfg = get_config()
|
||||||
|
if cfg and cfg.ips then
|
||||||
for line in config_text:gmatch("[^\n]+") do
|
for _, entry in ipairs(cfg.ips) do
|
||||||
local trimmed = line:match("^%s*(.-)%s*$")
|
local trimmed = entry:match("^%s*(.-)%s*$")
|
||||||
if trimmed ~= "" and trimmed:sub(1, 1) ~= "#" then
|
if trimmed ~= "" then
|
||||||
if trimmed:sub(1, 1) == "!" then
|
if trimmed:sub(1, 1) == "!" then
|
||||||
local ip = trimmed:sub(2):match("^%s*(.-)%s*$")
|
local ip = trimmed:sub(2):match("^%s*(.-)%s*$")
|
||||||
if ip ~= "" then
|
if ip ~= "" then table.insert(blacklist, ip) end
|
||||||
table.insert(blacklist, ip)
|
else
|
||||||
|
table.insert(whitelist, trimmed)
|
||||||
end
|
end
|
||||||
else
|
|
||||||
table.insert(whitelist, trimmed)
|
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
+28
-29
@@ -3,43 +3,40 @@ Plugin = {
|
|||||||
description = [[
|
description = [[
|
||||||
Auto-forward requests and exclude them from history based on patterns.
|
Auto-forward requests and exclude them from history based on patterns.
|
||||||
|
|
||||||
**Config**:
|
**Config** (YAML):
|
||||||
- `pattern` - whitelist: only intercept matching requests/responses and history entries
|
```yaml
|
||||||
- `!pattern` - blacklist: skip matching requests/responses and history entries
|
patterns:
|
||||||
- `r:pattern` - whitelist for requests/responses only (history unaffected)
|
- "pattern" # whitelist: only intercept matching requests/responses and history
|
||||||
- `r:!pattern` - blacklist for requests/responses only
|
- "!pattern" # blacklist: skip matching requests/responses and history
|
||||||
- `h:pattern` - whitelist for history entries only (requests unaffected)
|
- "r:pattern" # whitelist for requests/responses only
|
||||||
- `h:!pattern` - blacklist for history entries only
|
- "r:!pattern" # blacklist for requests/responses only
|
||||||
- lines starting with `#` are comments
|
- "h:pattern" # whitelist for history only
|
||||||
|
- "h:!pattern" # blacklist for history only
|
||||||
|
```
|
||||||
|
|
||||||
Example (ignore static assets):
|
Example (ignore static assets):
|
||||||
```
|
```yaml
|
||||||
!%.css$
|
patterns:
|
||||||
!%.js$
|
- "!%.css$"
|
||||||
!%.png$
|
- "!%.js$"
|
||||||
|
- "!%.png$"
|
||||||
```
|
```
|
||||||
|
|
||||||
Example (focus on mytarget.com, skip everything else):
|
Example (focus on mytarget.com):
|
||||||
```
|
```yaml
|
||||||
mytarget%.com/
|
patterns:
|
||||||
|
- "mytarget%.com/"
|
||||||
```
|
```
|
||||||
|
|
||||||
Example (intercept mytarget.com except its static assets):
|
Example (disable history):
|
||||||
```
|
```yaml
|
||||||
mytarget%.com/
|
patterns:
|
||||||
!%.css$
|
- "h:^$"
|
||||||
!%.js$
|
|
||||||
!%.png$
|
|
||||||
```
|
|
||||||
|
|
||||||
Example (disable history: whitelist never matches any real URL):
|
|
||||||
```
|
|
||||||
h:^$
|
|
||||||
```
|
```
|
||||||
]],
|
]],
|
||||||
priority = 100,
|
priority = 100,
|
||||||
on_request = { sync = true },
|
on_request = { sync = true },
|
||||||
on_response = { sync = true },
|
on_response = { sync = true },
|
||||||
on_history_entry = { sync = true },
|
on_history_entry = { sync = true },
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -50,11 +47,13 @@ local whitelist_req = {}
|
|||||||
local blacklist_hist = {}
|
local blacklist_hist = {}
|
||||||
local whitelist_hist = {}
|
local whitelist_hist = {}
|
||||||
|
|
||||||
function on_config(config_text)
|
function on_config()
|
||||||
blacklist, whitelist = {}, {}
|
blacklist, whitelist = {}, {}
|
||||||
blacklist_req, whitelist_req = {}, {}
|
blacklist_req, whitelist_req = {}, {}
|
||||||
blacklist_hist, whitelist_hist = {}, {}
|
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*$")
|
local trimmed = line:match("^%s*(.-)%s*$")
|
||||||
if trimmed ~= "" and trimmed:sub(1, 1) ~= "#" then
|
if trimmed ~= "" and trimmed:sub(1, 1) ~= "#" then
|
||||||
local scope = trimmed:match("^([rh]):")
|
local scope = trimmed:match("^([rh]):")
|
||||||
|
|||||||
@@ -15,9 +15,7 @@ Findings are deduplicated per host+path+body content so repeated requests do not
|
|||||||
}
|
}
|
||||||
|
|
||||||
function on_start()
|
function on_start()
|
||||||
local handle = io.popen("command -v trufflehog 2>/dev/null")
|
local result, _ = shell_pipe("command -v trufflehog 2>/dev/null")
|
||||||
local result = handle and handle:read("*a") or ""
|
|
||||||
if handle then handle:close() end
|
|
||||||
if not result or result:match("^%s*$") then
|
if not result or result:match("^%s*$") then
|
||||||
log("trufflehog is not installed or not in PATH")
|
log("trufflehog is not installed or not in PATH")
|
||||||
notif("TruffleHog", "trufflehog is not installed or not in PATH, plugin disabled", "error")
|
notif("TruffleHog", "trufflehog is not installed or not in PATH, plugin disabled", "error")
|
||||||
|
|||||||
@@ -8,6 +8,9 @@ import (
|
|||||||
"path/filepath"
|
"path/filepath"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
//go:embed docs
|
||||||
|
var DocsFS embed.FS
|
||||||
|
|
||||||
//go:embed plugins/*.lua
|
//go:embed plugins/*.lua
|
||||||
var PluginsFS embed.FS
|
var PluginsFS embed.FS
|
||||||
|
|
||||||
Reference in New Issue
Block a user