diff options
| author | srdusr <[email protected]> | 2026-08-04 09:35:00 +0200 |
|---|---|---|
| committer | srdusr <[email protected]> | 2026-08-04 09:35:00 +0200 |
| commit | dde73a349a68f942a5bd89b27ac980d7973148e0 (patch) | |
| tree | cec48acc8d65d2610f74752eab4db0705a2d38a1 | |
| parent | 4c4c8dd4983092ffa5c6602ed070f401bbbf74f1 (diff) | |
| download | mitmux-dde73a349a68f942a5bd89b27ac980d7973148e0.tar.gz mitmux-dde73a349a68f942a5bd89b27ac980d7973148e0.zip | |
Plugin protocol foundation: tag_entry, tag search/sort, TUI tag view
The prerequisite for the plugin ecosystem: any external process - any
language - that can reach the control socket can now tag a history
entry with a short string marker and an opaque JSON data blob, stored
in a new entry_tags table rather than requiring the plugin stay
connected for a later live round-trip. A plugin does its analysis
once; the data it attaches is what a human reviewing the entry later
actually sees.
internal/ipc: new "tag_entry" request (id, tag_plugin, tag, tag_data)
and EntryDetail.Tags (the full record for one entry, populated by
"get"). internal/store: entry_tags table, EntryTag struct, AddEntryTag/
ListEntryTags, a comma-joined Tags aggregate added to List/Search via
a correlated subquery (cheap enough per row that showing a tag badge
in the history list needs no N+1 query), and a new tag: search filter
alongside the existing status:/source:/flagged:.
TUI: a Tags column in the history table (sortable via o/O, the eighth
sort column), T from detail view opens a tag list (mirroring the
WebSocket-messages view's table-then-detail-viewport pattern), enter
on one shows its data - JSON-colorized via the existing jsoncolor.go
if it parses as JSON, sanitized plain text otherwise. Also fixed a
pre-existing gap while touching this: the WebSocket-messages view
never got mouse wheel support when it shipped; wired both it and the
new tags view up together.
PLUGINS.md documents the wire protocol for non-Go plugin authors -
connection model (subscribe vs request/response), the handful of
request types a plugin actually needs, and the trust boundary (the
socket has no auth beyond OS file permissions, same as the TUI's own
access). PLAN.md records the architecture decision (external process
over an embedded scripting language - mirrors the daemon/TUI split
already in place, no interpreter to sandbox, any language) and groups
~20 researched Burp extensions/Pro features into what Phase 1 already
covers (Autorize, Param Miner, Backslash Powered Scanner, Retire.js -
all just subscribe+repeat+tag, no new capability needed), what needs a
second protocol addition (JWT Editor, SAML Raider - live RPC to a
specific connected plugin for interactive actions like re-signing),
and what deserves its own separate project rather than a plugin
(active vulnerability scanning, Collaborator/OAST, a crawler).
Verified live end to end against a real daemon: a throwaway program
simulating a real plugin tagged a captured entry with structured JWT
data over the actual wire protocol; confirmed the tag badge, tag:
search filter, and full tag record all round-tripped correctly through
List/Search/Get. Confirmed in the TUI itself (tmux, real keystrokes):
the Tags column renders, T opens the tag list, entering it shows the
JSON data with real ANSI-verified syntax highlighting (not just
eyeballed), and tag: search filtering works from the history list.
| -rw-r--r-- | PLAN.md | 93 | ||||
| -rw-r--r-- | PLUGINS.md | 117 | ||||
| -rw-r--r-- | README.md | 9 | ||||
| -rw-r--r-- | cmd/mitmux/main.go | 157 | ||||
| -rw-r--r-- | cmd/mitmux/mouse.go | 30 | ||||
| -rw-r--r-- | internal/ipc/ipc.go | 46 | ||||
| -rw-r--r-- | internal/ipc/server.go | 25 | ||||
| -rw-r--r-- | internal/store/store.go | 96 | ||||
| -rw-r--r-- | internal/store/store_test.go | 16 |
9 files changed, 578 insertions, 11 deletions
@@ -775,3 +775,96 @@ real Actions run to execute) against a real `make release` output - confirmed all 6 platform archives (2 macOS, 2 Linux, 1 Windows, 1 FreeBSD) build with correct internal structure, `zip` selected only for the Windows target, and `SHA256SUMS` covers all of them. + +## Plugin ecosystem + +The goal, per direct request: parity with the extensions/Pro features +working Burp users actually rely on - "the best plugins, especially the +paid ones" - not a single proof-of-concept. Researched the current +BApp Store landscape and Burp Pro's paid-tier feature set to ground +this rather than guess from memory; see the grouping below. + +### Architecture decision + +Considered two shapes: an embedded scripting language (Lua/Starlark +interpreter in mitmuxd, plugins as hooked scripts) versus external +processes speaking mitmux's own existing IPC protocol. Chose the +latter, explicitly: any language (a plugin doing crypto work might +want Python's `jwcrypto`, a fast passive scanner might want Go - no +reason to lock authors into one embedded language), no new interpreter +dependency or sandboxing model to design and maintain, and it mirrors +the daemon/TUI split already in place - a plugin is architecturally +just another TUI, watching and acting on the same socket. Documented +in full, protocol-first (any-language authors need a real spec, not Go +doc comments), in `PLUGINS.md`. + +### Phase 1: tag + stored data (shipped) + +The prerequisite for everything else: a `tag_entry` IPC request lets +any connected process mark a history entry with a short string tag and +an opaque, plugin-defined JSON data blob, stored in a new `entry_tags` +table. Deliberately NOT a live round-trip to a still-connected plugin +process for every view - a plugin does its analysis once, tags the +entry with whatever data a human will want to see later, and the panel +just reads what got stored. Simpler, and works even if the plugin has +long since exited by the time someone reviews the entry. + +This one capability, paired with the IPC surface that already existed +(`subscribe` for live traffic, `repeat` for sending probe requests, +`list`/`search`/`get` for reading history), is enough for an entire +tier of plugins with no further protocol work: Autorize (replay with a +different session token, diff, tag likely IDORs), Param Miner (probe +for hidden parameters/cache poisoning, tag hits), Backslash Powered +Scanner (payload-variant diffing for injection), Retire.js (passive +scan of response bodies against a known-vulnerable-JS-library list). +Logger++/Content-Type-Converter/JSON-Beautifier-class extensions were +considered and skipped - mitmux's own History+FTS5 search and Decoder +tool already outperform what they'd add. + +TUI integration: `Summary.Tags` (a comma-joined, correlated-subquery +aggregate - cheap enough per row that List/Search need no N+1 query) +renders as a new `Tags` column in the history table, sortable via +`o`/`O` like any other column, and matchable via a new `tag:name` +search filter (same `extractStructured` mechanism as `status:`/ +`source:`/`flagged:`). `EntryDetail.Tags` carries the full record +(plugin name + tag + data) for the entry currently open; `T` from +detail view opens a list of them (mirroring the WebSocket-messages +view's own table-then-detail-viewport pattern, added earlier), `enter` +on one shows its data - JSON-colorized via the same `jsoncolor.go` +already built for pretty-printed response bodies if it parses as JSON, +plain sanitized text otherwise. Tag data goes through the same +sanitizeBlock treatment as any other externally-sourced text reaching +the real terminal - a plugin can echo back attacker-influenced content +(e.g. a header value it parsed), so it's not implicitly trusted just +because it came from a plugin rather than raw traffic. + +### What's grouped where + +- **Cheap IPC-plugin wins** (Phase 1 covers these fully): Autorize, + Param Miner, Backslash Powered Scanner, Retire.js. +- **Needs a second protocol addition** - a live round-trip to a + specific still-connected plugin, for an interactive action rather + than a passive tag (JWT Editor's "re-sign this with a different key, + right now" button; SAML Raider's equivalent XML re-signing): JWT + Editor, SAML Raider. Not yet built - Phase 1's tag+data display + already covers the *decode and view* half of what these do; only the + *live re-sign* half needs the new capability. +- **Needs real UI beyond a data panel**: InQL/GraphQL Raider (a schema + browser/query explorer, not just a decode view) - bigger lift, later. +- **Deserves its own separate project, not a plugin**: Burp Scanner and + Burp Bounty's custom active-check DSL (active vulnerability scanning + is a whole subsystem - check-diffing engines, hundreds of injection + variants - and already explicitly out of scope per this README's own + "a manual-testing tool, not a scanner"); Collaborator/OAST (needs + real internet-reachable DNS/HTTP infrastructure running somewhere, + not something an in-process plugin can be); a crawler (spidering is + its own substantial subsystem). Turbo Intruder's actual value is raw + throughput - mitmux's own Intruder already covers all four of Burp's + attack modes, so if extreme concurrency is ever wanted, that's a + core-engine change to how requests get dispatched, not something a + plugin protocol should be shaped around. + +Next: Phase 1's plugins themselves (Autorize- and Param-Miner- +equivalents first, since they validate the simple subscribe-act-tag +path before anything gets built on top of it), then the live-RPC +protocol addition for JWT Editor/SAML Raider. diff --git a/PLUGINS.md b/PLUGINS.md new file mode 100644 index 0000000..bb46461 --- /dev/null +++ b/PLUGINS.md @@ -0,0 +1,117 @@ +# mitmux plugins + +A plugin is any external process, in any language, that connects to +`mitmuxd`'s control socket and speaks the same JSON protocol the `mitmux` +TUI itself uses - there's no separate "plugin API," no interpreter +embedded in mitmuxd, and no in-process extension mechanism to trust. +This is deliberate: it keeps the daemon simple, lets a plugin be written +in whatever language actually suits the job (a JWT plugin doing crypto +work might want Python's `jwcrypto`; a fast passive scanner might want +Go), and means a plugin can be developed and tested against the exact +same socket the TUI is already using, with `mitmux` itself open in +another terminal watching what happens in real time. + +## Connecting + +The socket path is the same one `mitmux -socket` and `mitmuxd -socket` +use - `$XDG_RUNTIME_DIR/mitmux.sock` by default, else +`<config-dir>/mitmux.sock` (see the README's Platforms section for the +per-OS config directory). A plugin needs **two connections**, for two +different jobs: + +1. A **subscribe connection** - long-lived, one-way, the daemon pushes + newly-captured entries to it as they happen. Send one `{"type": + "subscribe"}` request, then just keep reading `Response` objects off + the connection; no more requests are ever sent on it. +2. A **request/response connection** - send one JSON request object, + read back exactly one JSON response object, repeat. This is how a + plugin looks up entries, resends requests, and tags things - anything + that isn't the live feed. + +Both are the same wire format: JSON values written directly to the +socket, no length prefix or newline delimiter required (Go's +`encoding/json` reads a stream and extracts one balanced JSON value at a +time; if your language's JSON library needs an explicit framing, treat +each object as ending where its braces balance - using a proper +streaming/incremental JSON parser, not naive line-splitting, is the +reliable way to consume this). + +## Requests a plugin actually needs + +The full protocol (`internal/ipc/ipc.go`'s `Request`/`Response` structs) +also covers everything the TUI itself does - search, export, rules, +scope, client certs, Intruder attacks, and so on. A plugin only needs a +handful of these: + +- **`subscribe`** - see above. No fields. Response stream: each object + has `"type": "new"` and a `"new"` field (a `store.Summary`) for one + freshly-captured entry. **Slow consumers silently drop entries** - the + daemon never blocks proxying to wait for a plugin to keep up. If your + plugin needs to guarantee it never misses one, don't rely on the live + feed alone; periodically reconcile against `list`/`search` too. +- **`get`** - `{"type": "get", "id": <entry id>}`. Returns the full + entry (raw request/response bytes, exact/truncated flags, existing + tags) as `"detail"`. +- **`repeat`** - `{"type": "repeat", "scheme": "https", "host": + "example.com", "raw": <base64 raw bytes>}`. Sends `raw` to + `scheme://host` exactly as given - no normalization, no header + injection, no auto-fixed `Content-Length` - and records the exchange + to history with `source: "repeater"`. This is what an Autorize- or + Param-Miner-style plugin uses to send its own probe requests. +- **`list`** / **`search`** - read existing history, same filters the + TUI's own `/` search uses (`status:`, `source:`, `flagged:`, `tag:`, + free text). Useful for a plugin that reconciles past traffic on + startup rather than only watching what happens from here on. +- **`tag_entry`** - the actual plugin-integration primitive: + + ```json + { + "type": "tag_entry", + "id": 42, + "tag_plugin": "jwt-decoder", + "tag": "jwt", + "tag_data": "{\"header\":{...},\"payload\":{...}}" + } + ``` + + `id` is the history entry being marked. `tag_plugin` is a + human-readable name your plugin picks for itself - purely + informational, not an identity or auth mechanism, since anything that + can reach the socket can claim any name (the socket itself is the + trust boundary - see Security below). `tag` is the short marker shown + as a badge in the TUI's history list and searchable via `tag:jwt`. + `tag_data` is an opaque string - conventionally JSON, since the TUI's + tag-detail view syntax-highlights it automatically if it parses as + JSON (same highlighter the pretty-printed response body view uses), + falling back to plain text otherwise - that a human reviewing the + entry later can open and read. Response: `{"type": "tag_entry", + "tag_id": <new tag's id>}`, or `{"type": "error", "error": "..."}`. + +A tagged entry shows its tags as a comma-joined badge in the TUI's +history table (a new `Tags` column) and sortable via `o`/`O` like any +other column; from the detail view, `T` opens the full tag list, `enter` +on one shows its data. + +## What's not here yet + +Everything above covers a plugin that **observes and marks** traffic - +enough for a JWT decoder, an authorization-bypass checker (replay with +a different session token, diff the response, tag it if it looks like +an IDOR), a hidden-parameter prober, or a known-vulnerable-JS-library +scanner. It does not yet cover a plugin **acting interactively** on +demand from the TUI - e.g. JWT Editor's "re-sign this token with a +different key, right now, from the panel" - which needs a live +round-trip to a specific still-connected plugin process, not just +stored data. That's a deliberately separate, second protocol addition, +not yet built; see `PLAN.md`'s plugin section for where it's headed. + +## Security + +The control socket has no authentication of its own beyond whatever the +OS's Unix domain socket file permissions grant - the same trust +boundary the `mitmux` TUI itself already operates inside. A plugin +connected to it has the same level of access the TUI has: it can read +every captured request and response (which may contain credentials, +session tokens, anything else that crossed the proxy), resend traffic, +and tag entries. Only run plugins you trust, the same way you'd only +run any other local tool with access to your intercepted traffic. @@ -13,7 +13,8 @@ reopen it later, the proxy never stopped. For the build history, the architectural decisions behind it, and a list of what's deliberately not implemented (and why), see -[`PLAN.md`](PLAN.md). +[`PLAN.md`](PLAN.md). To write a plugin - any language, connects over +the same socket the TUI itself uses - see [`PLUGINS.md`](PLUGINS.md). ## Features @@ -63,6 +64,12 @@ list of what's deliberately not implemented (and why), see proxied traffic and Repeater/Intruder resends alike. - **Flagging**: mark an entry to revisit later (★), filterable via `flagged:true`. +- **Plugins**: any external process, any language, can connect over the + same socket the TUI uses and tag entries with structured data - a + JWT decoder, an authorization-bypass checker, anything. Tags show as + a badge in the history list, searchable via `tag:name`, viewable + (`T` from detail view) with JSON data syntax-highlighted the same way + a pretty-printed response is. See [`PLUGINS.md`](PLUGINS.md). - **Comparer**: mark one entry (`c`), then `c` on a different entry to see a colored unified diff of either side's request or response. - **Decoder**: standalone URL/Base64/Hex/HTML encode and decode (`d`), diff --git a/cmd/mitmux/main.go b/cmd/mitmux/main.go index 30b71e7..4664a6b 100644 --- a/cmd/mitmux/main.go +++ b/cmd/mitmux/main.go @@ -118,6 +118,7 @@ const ( viewScope viewClientCerts viewWebSocket + viewTags viewHelp ) @@ -143,6 +144,7 @@ const ( sortByMethod sortByHost sortByPath + sortByTags ) func (c sortColumn) String() string { @@ -159,6 +161,8 @@ func (c sortColumn) String() string { return "host" case sortByPath: return "path" + case sortByTags: + return "tags" default: return "captured" } @@ -288,6 +292,15 @@ type model struct { wsShowingDetail bool wsDetailViewport viewport.Model + // Plugin-contributed tags on the currently viewed entry (see + // internal/ipc's "tag_entry") - already loaded as part of + // ipc.EntryDetail by the time detail view opens, no separate fetch + // needed. Reached from the detail view via 'T'. + tagsTable table.Model + tagsRows []store.EntryTag + tagsShowingDetail bool + tagsDetailViewport viewport.Model + intruderScheme string intruderHost string intruderTemplate viTextarea @@ -376,6 +389,7 @@ func newModel(client *ipc.Client, subCh <-chan store.Summary, socketPath string) {Title: "Method", Width: 7}, {Title: "Host", Width: 27}, {Title: "Path", Width: 31}, + {Title: "Tags", Width: 12}, {Title: "Status", Width: 6}, {Title: "Size", Width: 10}, {Title: "Time", Width: 8}, @@ -451,6 +465,14 @@ func newModel(client *ipc.Client, subCh <-chan store.Summary, socketPath string) wsTbl := table.New(table.WithColumns(wsCols), table.WithFocused(true)) wsTbl.SetStyles(st) + tagsCols := []table.Column{ + {Title: "Plugin", Width: 16}, + {Title: "Tag", Width: 20}, + {Title: "Preview", Width: 40}, + } + tagsTbl := table.New(table.WithColumns(tagsCols), table.WithFocused(true)) + tagsTbl.SetStyles(st) + itmpl := newViTextarea() itmpl.ta.Placeholder = "raw request bytes - wrap positions to fuzz in § markers, e.g. /users/§123§" itmpl.ta.ShowLineNumbers = false @@ -504,6 +526,7 @@ func newModel(client *ipc.Client, subCh <-chan store.Summary, socketPath string) clientCertCertPath: ccCertPathIn, clientCertKeyPath: ccKeyPathIn, wsTable: wsTbl, + tagsTable: tagsTbl, ruleName: nameIn, ruleMatch: matchIn, ruleReplace: replaceIn, @@ -1233,6 +1256,9 @@ func (m *model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.wsTable.SetWidth(msg.Width) m.wsTable.SetHeight(h - 5) m.wsDetailViewport = viewport.New(msg.Width, h-5) + m.tagsTable.SetWidth(msg.Width) + m.tagsTable.SetHeight(h - 5) + m.tagsDetailViewport = viewport.New(msg.Width, h-5) decInHeight := (h - 8) / 2 m.decoderInput.SetWidth(msg.Width) @@ -1706,7 +1732,7 @@ func (m *model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.statusMsg = "" return m, m.loadClientCerts case "o": - m.sortColumn = (m.sortColumn + 1) % 7 + m.sortColumn = (m.sortColumn + 1) % 8 m.sortDesc = false m.refreshTable() if m.sortColumn == sortByTime { @@ -1819,6 +1845,19 @@ func (m *model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m, m.loadWSMessages(m.detail.ID) } return m, nil + case "T": + if m.detail != nil { + if len(m.detail.Tags) == 0 { + m.statusMsg = "no plugin tags on this entry" + return m, nil + } + m.tagsRows = m.detail.Tags + m.tagsShowingDetail = false + setTableRows(&m.tagsTable, tagsRowsFor(m.tagsRows)) + m.mode = viewTags + m.statusMsg = "" + } + return m, nil case "tab": if m.activeTab == tabRequest { m.activeTab = tabResponse @@ -2137,6 +2176,41 @@ func (m *model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.wsTable, cmd = m.wsTable.Update(msg) return m, cmd + case viewTags: + if m.tagsShowingDetail { + switch msg.String() { + case "q", "esc": + m.tagsShowingDetail = false + return m, nil + case "ctrl+c": + return m, tea.Quit + } + var cmd tea.Cmd + m.tagsDetailViewport, cmd = m.tagsDetailViewport.Update(msg) + return m, cmd + } + switch msg.String() { + case "q", "esc": + m.mode = viewDetail + return m, nil + case "ctrl+c": + return m, tea.Quit + case "?": + m.prevMode = viewTags + m.mode = viewHelp + return m, nil + case "enter": + if row := m.tagsTable.Cursor(); row >= 0 && row < len(m.tagsRows) { + m.tagsShowingDetail = true + m.tagsDetailViewport.SetContent(tagDetailContent(m.tagsRows[row])) + m.tagsDetailViewport.GotoTop() + } + return m, nil + } + var cmd tea.Cmd + m.tagsTable, cmd = m.tagsTable.Update(msg) + return m, cmd + case viewIntruder: // Editing a grep pattern is a modal overlay on top of the // normal template/payloads/results panes, same pattern as @@ -2400,6 +2474,12 @@ func (m *model) View() string { } else { body = m.wsView() } + case viewTags: + if m.tagsShowingDetail { + body = m.tagsDetailView() + } else { + body = m.tagsView() + } case viewIntruder: body = m.intruderView() case viewCompare: @@ -2429,7 +2509,7 @@ func (m *model) statusBar() string { view := map[viewMode]string{ viewList: "history", viewDetail: "detail", viewRepeater: "repeater", viewRules: "rules", viewIntruder: "intruder", viewCompare: "comparer", viewDecoder: "decoder", - viewScope: "scope", viewClientCerts: "client certs", viewWebSocket: "websocket", + viewScope: "scope", viewClientCerts: "client certs", viewWebSocket: "websocket", viewTags: "tags", }[m.mode] return statusBarStyle.Render(fmt.Sprintf(" mitmux · proxy %s%s · %s ", proxy, count, view)) } @@ -2477,7 +2557,7 @@ func (m *model) helpView() string { " source:repeater, flagged:true", "esc clear active search filter", "o cycle sort column (captured/status/size/time taken/", - " method/host/path) - sorts the currently loaded page", + " method/host/path/tags) - sorts the currently loaded page", "O reverse the current sort column's direction", "m match-and-replace rules", "s target scope (what gets recorded)", @@ -2492,6 +2572,7 @@ func (m *model) helpView() string { "r / i open in Repeater / Intruder", "e export this entry - .txt (raw request+response) or .sh/.curl (curl command)", "w view captured WebSocket messages (only for an upgraded connection)", + "T view plugin tags on this entry, if any", "esc / q back to history", ) section("WebSocket messages", @@ -2501,6 +2582,14 @@ func (m *model) helpView() string { "enter view this frame's full decoded payload", "esc / q back (from payload view: back to the message list)", ) + section("Plugin tags", + "Markers a connected plugin attached to this entry (see", + "PLAN.md's plugin protocol) - e.g. \"jwt\" from a JWT-decoding", + "plugin, with the decoded token as that tag's data.", + "↑/↓ or j/k navigate (also g/G, ctrl+u/d)", + "enter view this tag's full data (JSON-colored if it is JSON)", + "esc / q back (from data view: back to the tag list)", + ) section("Comparer", "tab switch request/response diff", "↑/↓ or j/k scroll (also g/G, ctrl+u/d - same as history list)", @@ -2678,7 +2767,7 @@ func (m *model) detailView() string { b.WriteString(statusStyle.Render(sanitizeLine(m.statusMsg))) b.WriteString("\n") } - b.WriteString(helpStyle.Render("tab switch · p pretty-print · c compare · r repeater · i intruder · e export · w websocket · esc back · ? help · q quit")) + b.WriteString(helpStyle.Render("tab switch · p pretty-print · c compare · r repeater · i intruder · e export · w websocket · T tags · esc back · ? help · q quit")) return b.String() } @@ -3008,6 +3097,63 @@ func wsMessageDetail(m store.WSMessage) string { dir, wsOpcodeName(m.Opcode), humanBytes(len(m.Payload)), sanitizeBlock(string(m.Payload))) } +func (m *model) tagsView() string { + var b strings.Builder + title := fmt.Sprintf(" plugin tags (%d) - entry #%d ", len(m.tagsRows), m.detail.ID) + b.WriteString(titleStyle.Render(title)) + b.WriteString("\n") + b.WriteString(m.tagsTable.View()) + b.WriteString("\n") + if m.statusMsg != "" { + b.WriteString(statusStyle.Render(sanitizeLine(m.statusMsg))) + b.WriteString("\n") + } + b.WriteString(helpStyle.Render("enter view tag data · esc back · q quit")) + return b.String() +} + +func (m *model) tagsDetailView() string { + var b strings.Builder + b.WriteString(titleStyle.Render(" tag data ")) + b.WriteString("\n") + b.WriteString(m.tagsDetailViewport.View()) + b.WriteString("\n") + b.WriteString(helpStyle.Render("↑/↓ scroll · esc back · q quit")) + return b.String() +} + +func tagsRowsFor(tags []store.EntryTag) []table.Row { + rows := make([]table.Row, len(tags)) + for i, t := range tags { + rows[i] = table.Row{ + sanitizeLine(t.Plugin), + sanitizeLine(t.Tag), + sanitizeLine(t.Data), + } + } + return rows +} + +// tagDetailContent is the full content shown when viewing one tag's +// data: JSON-colorized (reusing jsoncolor.go, same as a pretty-printed +// response body) if it parses as JSON - the common case, since a +// decoding plugin like a JWT parser has structured data to show - or +// plain sanitized text otherwise. Either way this is plugin-supplied, +// not necessarily attacker-controlled, but a plugin can echo back +// attacker-influenced content (e.g. a header value it parsed), so it +// gets the same sanitizeBlock/sanitizeControl treatment as any other +// text reaching the real terminal from outside this process. +func tagDetailContent(t store.EntryTag) string { + header := fmt.Sprintf("plugin: %s · tag: %s", sanitizeLine(t.Plugin), sanitizeLine(t.Tag)) + if t.Data == "" { + return header + "\n\n(no data attached)" + } + if colored, ok := colorizeJSON([]byte(t.Data)); ok { + return header + "\n\n" + colored + } + return header + "\n\n" + sanitizeBlock(t.Data) +} + // nextAttackMode cycles Sniper -> BatteringRam -> Pitchfork -> ClusterBomb // -> Sniper. func nextAttackMode(mode proxy.AttackMode) proxy.AttackMode { @@ -3266,6 +3412,8 @@ func (m *model) sortedEntries() []store.Summary { return a.Host < b.Host case sortByPath: return a.Path < b.Path + case sortByTags: + return a.Tags < b.Tags default: return false } @@ -3297,6 +3445,7 @@ func rowsFor(entries []store.Summary) []table.Row { sanitizeLine(e.Method), sanitizeLine(e.Host), sanitizeLine(e.Path), + sanitizeLine(e.Tags), status, size, e.Duration.Round(time.Millisecond).String(), diff --git a/cmd/mitmux/mouse.go b/cmd/mitmux/mouse.go index e2d4936..b1c435b 100644 --- a/cmd/mitmux/mouse.go +++ b/cmd/mitmux/mouse.go @@ -81,6 +81,36 @@ func (m *model) handleMouse(msg tea.MouseMsg) (tea.Model, tea.Cmd) { return m.handleScopeMouse(msg) case viewClientCerts: return m.handleClientCertMouse(msg) + case viewWebSocket: + if isWheel(msg) { + var cmd tea.Cmd + if m.wsShowingDetail { + m.wsDetailViewport, cmd = m.wsDetailViewport.Update(msg) + } else { + switch msg.Button { + case tea.MouseButtonWheelUp: + m.wsTable.MoveUp(3) + case tea.MouseButtonWheelDown: + m.wsTable.MoveDown(3) + } + } + return m, cmd + } + case viewTags: + if isWheel(msg) { + var cmd tea.Cmd + if m.tagsShowingDetail { + m.tagsDetailViewport, cmd = m.tagsDetailViewport.Update(msg) + } else { + switch msg.Button { + case tea.MouseButtonWheelUp: + m.tagsTable.MoveUp(3) + case tea.MouseButtonWheelDown: + m.tagsTable.MoveDown(3) + } + } + return m, cmd + } } return m, nil } diff --git a/internal/ipc/ipc.go b/internal/ipc/ipc.go index dc7a1df..f5d5452 100644 --- a/internal/ipc/ipc.go +++ b/internal/ipc/ipc.go @@ -77,6 +77,18 @@ type Request struct { ClientCert *clientcert.Cert `json:"client_cert,omitempty"` ClientCertID int64 `json:"client_cert_id,omitempty"` + // For "tag_entry": ID identifies the history entry (same field + // "get"/"set_flagged"/"delete_entry" use). Plugin names who's + // tagging it - informational only, not an identity or auth + // mechanism, since anything that can reach the socket can claim any + // name. Tag is the short marker itself (e.g. "jwt", "authz-bypass"). + // Data is an opaque, plugin-defined JSON blob a panel view renders + // later without needing this plugin still connected - empty is + // fine for a plugin that only needs the tag itself, no extra detail. + TagPlugin string `json:"tag_plugin,omitempty"` + Tag string `json:"tag,omitempty"` + TagData string `json:"tag_data,omitempty"` + // For "set_flagged" and "delete_entry": ID identifies the history // entry. "clear_history" needs no fields at all. Flagged bool `json:"flagged,omitempty"` @@ -121,6 +133,9 @@ type Response struct { // per-entry insert failure is skipped, not fatal to the batch). Imported int `json:"imported,omitempty"` + // For "tag_entry": the new tag row's assigned ID. + TagID int64 `json:"tag_id,omitempty"` + // For "intrude_result": one completed attack request. IntrudeResult *IntrudeResultMsg `json:"intrude_result,omitempty"` @@ -164,6 +179,12 @@ type EntryDetail struct { // a false *Exact. RequestTruncated bool `json:"request_truncated,omitempty"` ResponseTruncated bool `json:"response_truncated,omitempty"` + // Tags is every plugin-contributed marker on this entry - see the + // "tag_entry" request. Summary.Tags (from List/Search) is just the + // comma-joined names for a compact list-view badge; this is the + // full record, including each tag's plugin and opaque Data blob, for + // a panel view to render. + Tags []store.EntryTag `json:"tags,omitempty"` } // Client talks to a mitmuxd instance for request/response queries @@ -199,6 +220,31 @@ func (c *Client) Close() error { // SetFlagged sets the flagged marker on a history entry - a simple // "mark this, revisit later" bit, filterable via flagged:true/false in // Search. +// TagEntry marks history entry id with tag, attributed to plugin (any +// non-empty name a plugin chooses to identify itself by - informational +// only), with an optional opaque data blob a panel view can render +// later. This is the core plugin-integration primitive: any process +// that can reach the control socket - the reference Go client here, or +// a plugin in any other language following the same JSON wire protocol +// (see PLAN.md) - can tag entries it finds interesting without mitmux +// needing to know anything about it in advance. Returns the new tag's +// assigned ID. +func (c *Client) TagEntry(entryID int64, plugin, tag, data string) (int64, error) { + c.mu.Lock() + defer c.mu.Unlock() + if err := c.enc.Encode(Request{Type: "tag_entry", ID: entryID, TagPlugin: plugin, Tag: tag, TagData: data}); err != nil { + return 0, err + } + var resp Response + if err := c.dec.Decode(&resp); err != nil { + return 0, err + } + if resp.Type == "error" { + return 0, errors.New(resp.Error) + } + return resp.TagID, nil +} + func (c *Client) SetFlagged(id int64, flagged bool) error { c.mu.Lock() defer c.mu.Unlock() diff --git a/internal/ipc/server.go b/internal/ipc/server.go index 378c1a9..98c2c22 100644 --- a/internal/ipc/server.go +++ b/internal/ipc/server.go @@ -141,7 +141,13 @@ func (s *Server) handleConn(conn net.Conn) { enc.Encode(Response{Type: "error", Error: err.Error()}) continue } - enc.Encode(Response{Type: "get", Detail: detailFromEntry(e)}) + detail := detailFromEntry(e) + if tags, err := s.db.ListEntryTags(req.ID); err != nil { + log.Printf("list entry tags for #%d: %v", req.ID, err) + } else { + detail.Tags = tags + } + enc.Encode(Response{Type: "get", Detail: detail}) case "ws_messages": msgs, err := s.db.ListWSMessages(req.ID) @@ -220,6 +226,23 @@ func (s *Server) handleConn(conn net.Conn) { } enc.Encode(Response{Type: "intrude_done"}) + case "tag_entry": + if req.Tag == "" { + enc.Encode(Response{Type: "error", Error: "tag_entry: missing tag"}) + continue + } + id, err := s.db.AddEntryTag(store.EntryTag{ + EntryID: req.ID, + Plugin: req.TagPlugin, + Tag: req.Tag, + Data: req.TagData, + }) + if err != nil { + enc.Encode(Response{Type: "error", Error: err.Error()}) + continue + } + enc.Encode(Response{Type: "tag_entry", TagID: id}) + case "set_flagged": if err := s.db.SetFlagged(req.ID, req.Flagged); err != nil { enc.Encode(Response{Type: "error", Error: err.Error()}) diff --git a/internal/store/store.go b/internal/store/store.go index e0f27d1..ecc3854 100644 --- a/internal/store/store.go +++ b/internal/store/store.go @@ -85,6 +85,24 @@ CREATE TABLE IF NOT EXISTS ws_messages ( ); CREATE INDEX IF NOT EXISTS ws_messages_entry_id ON ws_messages(entry_id); + +- Plugin-contributed markers on a history entry - see internal/ipc's +- "tag_entry" request. plugin identifies who added it (informational, +- not an identity/auth mechanism: any client connected to the socket +- can tag as anyone). data is an opaque, plugin-defined JSON blob a +- panel view can render later - e.g. a decoded JWT header/payload - +- without needing that plugin still connected. +CREATE TABLE IF NOT EXISTS entry_tags ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + entry_id INTEGER NOT NULL, + plugin TEXT NOT NULL, + tag TEXT NOT NULL, + data TEXT NOT NULL DEFAULT '', + created_at INTEGER NOT NULL +); + +CREATE INDEX IF NOT EXISTS entry_tags_entry_id ON entry_tags(entry_id); +CREATE INDEX IF NOT EXISTS entry_tags_tag ON entry_tags(tag); ` // Store is a handle to the history database. Safe for concurrent use. @@ -184,6 +202,63 @@ type Summary struct { Error string Source string Flagged bool + // Tags is every distinct plugin tag on this entry, comma-joined - + // cheap enough to compute per row (a correlated subquery, see List/ + // Search) that a plugin-tagged entry shows a badge in the history + // list itself, not just in its detail view. + Tags string +} + +// EntryTag is one plugin-contributed marker on a history entry - see +// the "tag_entry" IPC request and entry_tags' own schema comment for +// what Plugin/Data mean. +type EntryTag struct { + ID int64 + EntryID int64 + Plugin string + Tag string + Data string + CreatedAt time.Time +} + +// AddEntryTag stores t and returns its assigned ID. +func (s *Store) AddEntryTag(t EntryTag) (int64, error) { + if t.CreatedAt.IsZero() { + t.CreatedAt = time.Now() + } + res, err := s.db.Exec( + `INSERT INTO entry_tags (entry_id, plugin, tag, data, created_at) VALUES (?, ?, ?, ?, ?)`, + t.EntryID, t.Plugin, t.Tag, t.Data, t.CreatedAt.UnixMilli(), + ) + if err != nil { + return 0, fmt.Errorf("add entry tag: %w", err) + } + return res.LastInsertId() +} + +// ListEntryTags returns every tag on entryID, in the order they were +// added. +func (s *Store) ListEntryTags(entryID int64) ([]EntryTag, error) { + rows, err := s.db.Query( + `SELECT id, entry_id, plugin, tag, data, created_at FROM entry_tags WHERE entry_id = ? ORDER BY id`, + entryID, + ) + if err != nil { + return nil, fmt.Errorf("list entry tags: %w", err) + } + defer rows.Close() + + var out []EntryTag + for rows.Next() { + var t EntryTag + var createdAt int64 + if err := rows.Scan(&t.ID, &t.EntryID, &t.Plugin, &t.Tag, &t.Data, &createdAt); err != nil { + return nil, fmt.Errorf("scan entry tag row: %w", err) + } + t.CreatedAt = time.UnixMilli(createdAt) + out = append(out, t) + } + return out, rows.Err() } // Insert stores e (and indexes it for search) and returns its assigned ID. @@ -255,7 +330,8 @@ func (s *Store) List(limit int, beforeID int64) ([]Summary, error) { } rows, err := s.db.Query( `SELECT id, started_at, duration_ms, method, scheme, host, path, - COALESCE(status_code, 0), length(request_raw), COALESCE(length(response_raw), 0), error, source, flagged + COALESCE(status_code, 0), length(request_raw), COALESCE(length(response_raw), 0), error, source, flagged, + COALESCE((SELECT group_concat(DISTINCT tag) FROM entry_tags WHERE entry_tags.entry_id = history.id), '') FROM history WHERE id < ? ORDER BY id DESC LIMIT ?`, beforeID, limit, ) @@ -270,7 +346,7 @@ func (s *Store) List(limit int, beforeID int64) ([]Summary, error) { var startedAt, durationMs int64 var flagged int if err := rows.Scan(&sum.ID, &startedAt, &durationMs, &sum.Method, &sum.Scheme, &sum.Host, &sum.Path, - &sum.StatusCode, &sum.ReqSize, &sum.RespSize, &sum.Error, &sum.Source, &flagged); err != nil { + &sum.StatusCode, &sum.ReqSize, &sum.RespSize, &sum.Error, &sum.Source, &flagged, &sum.Tags); err != nil { return nil, fmt.Errorf("scan history row: %w", err) } sum.Flagged = flagged != 0 @@ -321,6 +397,12 @@ func (s *Store) Search(query string, limit int, beforeID int64) ([]Summary, erro where = append(where, "h.flagged = ?") args = append(args, boolToInt(*pred.flagged)) } + if pred.tag != "" { + where = append(where, "EXISTS (SELECT 1 FROM entry_tags WHERE entry_tags.entry_id = h.id AND entry_tags.tag = ?)") + args = append(args, pred.tag) + } + + const tagsCol = `COALESCE((SELECT group_concat(DISTINCT tag) FROM entry_tags WHERE entry_tags.entry_id = h.id), '')` var q string if remaining == "" { @@ -328,7 +410,7 @@ func (s *Store) Search(query string, limit int, beforeID int64) ([]Summary, erro // FTS5 join or ranking needed. q = `SELECT h.id, h.started_at, h.duration_ms, h.method, h.scheme, h.host, h.path, COALESCE(h.status_code, 0), length(h.request_raw), COALESCE(length(h.response_raw), 0), - h.error, h.source, h.flagged + h.error, h.source, h.flagged, ` + tagsCol + ` FROM history h WHERE ` + strings.Join(where, " AND ") + ` ORDER BY h.id DESC LIMIT ?` @@ -341,7 +423,7 @@ func (s *Store) Search(query string, limit int, beforeID int64) ([]Summary, erro args = append([]any{prepareFTSQuery(remaining)}, args...) q = `SELECT h.id, h.started_at, h.duration_ms, h.method, h.scheme, h.host, h.path, COALESCE(h.status_code, 0), length(h.request_raw), COALESCE(length(h.response_raw), 0), - h.error, h.source, h.flagged + h.error, h.source, h.flagged, ` + tagsCol + ` FROM history_fts JOIN history h ON h.id = history_fts.rowid WHERE ` + strings.Join(where, " AND ") + ` @@ -361,7 +443,7 @@ func (s *Store) Search(query string, limit int, beforeID int64) ([]Summary, erro var startedAt, durationMs int64 var flagged int if err := rows.Scan(&sum.ID, &startedAt, &durationMs, &sum.Method, &sum.Scheme, &sum.Host, &sum.Path, - &sum.StatusCode, &sum.ReqSize, &sum.RespSize, &sum.Error, &sum.Source, &flagged); err != nil { + &sum.StatusCode, &sum.ReqSize, &sum.RespSize, &sum.Error, &sum.Source, &flagged, &sum.Tags); err != nil { return nil, fmt.Errorf("scan search row: %w", err) } sum.Flagged = flagged != 0 @@ -379,6 +461,7 @@ type structuredPredicate struct { statusArgs []any source string flagged *bool + tag string } var ( @@ -427,6 +510,9 @@ func extractStructured(query string) (remaining string, pred structuredPredicate pred.flagged = &b continue } + case strings.HasPrefix(lower, "tag:"): + pred.tag = strings.TrimPrefix(f, "tag:") + continue } kept = append(kept, f) } diff --git a/internal/store/store_test.go b/internal/store/store_test.go index 7fa985a..5daa73a 100644 --- a/internal/store/store_test.go +++ b/internal/store/store_test.go @@ -13,6 +13,7 @@ func TestExtractStructured(t *testing.T) { statusArgs []any source string flagged *bool + tag string }{ { name: "plain text only", @@ -71,6 +72,18 @@ func TestExtractStructured(t *testing.T) { source: "repeater", }, { + name: "tag filter", + query: "tag:jwt", + remaining: "", + tag: "jwt", + }, + { + name: "tag filter preserves case", + query: "tag:JWT", + remaining: "", + tag: "JWT", + }, + { name: "combined with free text", query: "admin status:>=400 source:proxy", remaining: "admin", @@ -110,6 +123,9 @@ func TestExtractStructured(t *testing.T) { if pred.source != tt.source { t.Errorf("source = %q, want %q", pred.source, tt.source) } + if pred.tag != tt.tag { + t.Errorf("tag = %q, want %q", pred.tag, tt.tag) + } switch { case pred.flagged == nil && tt.flagged == nil: case pred.flagged == nil || tt.flagged == nil: |