srdusr
aboutsummaryrefslogtreecommitdiffstats
path: root/cmd
diff options
context:
space:
mode:
authorsrdusr <[email protected]>2026-08-04 09:35:00 +0200
committersrdusr <[email protected]>2026-08-04 09:35:00 +0200
commitdde73a349a68f942a5bd89b27ac980d7973148e0 (patch)
treecec48acc8d65d2610f74752eab4db0705a2d38a1 /cmd
parent4c4c8dd4983092ffa5c6602ed070f401bbbf74f1 (diff)
downloadmitmux-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.
Diffstat (limited to 'cmd')
-rw-r--r--cmd/mitmux/main.go157
-rw-r--r--cmd/mitmux/mouse.go30
2 files changed, 183 insertions, 4 deletions
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
}