diff options
Diffstat (limited to 'cmd')
| -rw-r--r-- | cmd/mitmux/main.go | 157 | ||||
| -rw-r--r-- | cmd/mitmux/mouse.go | 30 |
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 } |