diff options
| -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: |