srdusr
aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-rw-r--r--PLAN.md93
-rw-r--r--PLUGINS.md117
-rw-r--r--README.md9
-rw-r--r--cmd/mitmux/main.go157
-rw-r--r--cmd/mitmux/mouse.go30
-rw-r--r--internal/ipc/ipc.go46
-rw-r--r--internal/ipc/server.go25
-rw-r--r--internal/store/store.go96
-rw-r--r--internal/store/store_test.go16
9 files changed, 578 insertions, 11 deletions
diff --git a/PLAN.md b/PLAN.md
index bf30b0c..68b6e56 100644
--- a/PLAN.md
+++ b/PLAN.md
@@ -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.
diff --git a/README.md b/README.md
index edb62e4..e469706 100644
--- a/README.md
+++ b/README.md
@@ -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: