diff options
| -rw-r--r-- | PLAN.md | 21 | ||||
| -rw-r--r-- | README.md | 18 | ||||
| -rw-r--r-- | cmd/mitmux/compare.go | 105 | ||||
| -rw-r--r-- | cmd/mitmux/main.go | 118 | ||||
| -rw-r--r-- | go.mod | 1 | ||||
| -rw-r--r-- | go.sum | 2 |
6 files changed, 252 insertions, 13 deletions
@@ -90,13 +90,22 @@ structured search filters (status:, source:, flagged:) alongside the existing FTS5 text search; a flagged marker (★) for "revisit this" - deliberately simpler than full free-text notes/comments, which would need their own text-input overlay for comparatively modest extra value -over a boolean; noted as a real follow-up, not dropped silently. +over a boolean; noted as a real follow-up, not dropped silently; a +Comparer tool - mark an entry with 'c' (from history list or detail +view), 'c' again on a different entry opens a unified diff (git-diff +style, colored) of either side's request or response. Unified rather +than Burp's side-by-side: a two-column layout fights terminal width for +anything but a narrow window, and unified reuses the same scrollable- +viewport pattern already used everywhere else in the TUI. CRLF is +normalized to LF before diffing (display-only, same reasoning as the +JSON pretty-printer) so an HTTP/1.1 exact capture doesn't show every +line as changed from an invisible trailing \r. -Still open from "worth considering": a Comparer (diff) tool, a -standalone encoder/decoder utility, multiple concurrent Repeater tabs, -Intruder payload processing (encoding/case rules) and grep-match/ -grep-extract on results, CA install UX per OS, multiple proxy listeners -and upstream proxy chaining. None of these are started yet. +Still open from "worth considering": a standalone encoder/decoder +utility, multiple concurrent Repeater tabs, Intruder payload processing +(encoding/case rules) and grep-match/grep-extract on results, CA install +UX per OS, multiple proxy listeners and upstream proxy chaining. None +of these are started yet. Skipped deliberately (from the research, matches this tool's stated scope): active/passive vulnerability scanning, plugin marketplace, @@ -48,6 +48,8 @@ list of what's deliberately not implemented (and why), see audit trail. - **Flagging**: mark an entry to revisit later (★), filterable via `flagged:true`. +- **Comparer**: mark one entry (`c`), then `c` on a different entry to + see a colored unified diff of either side's request or response. - **Vi-modal editing**: the raw request editors (Repeater, Intruder) are real modal editors - normal mode by default, `i`/`a`/`o`/etc. to insert, `hjkl`, `dd`/`yy`/`p`, word motions, `gg`/`G`. See @@ -121,6 +123,7 @@ below is enough to get going. | `r` | open in Repeater | | `i` | open in Intruder | | `f` | toggle flag | +| `c` | mark for comparison - press `c` on another entry to diff | | `/` | search | | `m` | match-and-replace rules | | `q` | quit | @@ -128,9 +131,18 @@ below is enough to get going. ### Detail view `tab` switches request/response, `p` toggles pretty-printed JSON on the -response (display-only - never touches the stored or resent bytes), -`r`/`i` jump straight to Repeater/Intruder seeded from this entry, `esc` -back. +response (display-only - never touches the stored or resent bytes), `c` +mark/compare (same as the history list), `r`/`i` jump straight to +Repeater/Intruder seeded from this entry, `esc` back. + +### Comparer + +Reachable by pressing `c` on two different history entries (from either +the list or detail view). Shows a colored unified diff - `diff -u` +style, `+`/`-` lines - of the two entries' requests or responses, +`tab` to switch between them. CRLF is normalized before diffing so an +exact HTTP/1.1 capture doesn't show every line as changed purely from +the invisible `\r`. ### Search syntax diff --git a/cmd/mitmux/compare.go b/cmd/mitmux/compare.go new file mode 100644 index 0000000..34bb684 --- /dev/null +++ b/cmd/mitmux/compare.go @@ -0,0 +1,105 @@ +package main + +import ( + "fmt" + "strings" + + "github.com/pmezard/go-difflib/difflib" +) + +// unifiedDiff renders a colored unified diff (git/diff -u style) between +// aText and bText, labeled aLabel/bLabel. CRLF is normalized to LF +// first - this is a display transform only (mirrors prettyResponse's +// approach), otherwise every line in an HTTP/1.1 exact capture would +// show as changed purely from an invisible trailing \r, which would +// bury the differences that actually matter under noise. +func unifiedDiff(aLabel, bLabel, aText, bText string) string { + aText = strings.ReplaceAll(aText, "\r\n", "\n") + bText = strings.ReplaceAll(bText, "\r\n", "\n") + + if aText == bText { + return helpStyle.Render("(identical)") + } + + diff := difflib.UnifiedDiff{ + A: difflib.SplitLines(aText), + B: difflib.SplitLines(bText), + FromFile: aLabel, + ToFile: bLabel, + Context: 3, + } + text, err := difflib.GetUnifiedDiffString(diff) + if err != nil { + return "(diff error: " + err.Error() + ")" + } + return colorizeDiff(text) +} + +func colorizeDiff(text string) string { + var b strings.Builder + lines := strings.Split(strings.TrimSuffix(text, "\n"), "\n") + for i, line := range lines { + switch { + case strings.HasPrefix(line, "+++") || strings.HasPrefix(line, "---"): + b.WriteString(diffHeaderStyle.Render(line)) + case strings.HasPrefix(line, "@@"): + b.WriteString(diffHunkStyle.Render(line)) + case strings.HasPrefix(line, "+"): + b.WriteString(diffAddStyle.Render(line)) + case strings.HasPrefix(line, "-"): + b.WriteString(diffDelStyle.Render(line)) + default: + b.WriteString(line) + } + if i < len(lines)-1 { + b.WriteString("\n") + } + } + return b.String() +} + +// compareContent is what's actually shown in the comparer viewport: a +// unified diff of the request or response bodies of the two marked +// entries, depending on compareTab. +func (m *model) compareContent() string { + if m.compareA == nil || m.compareB == nil { + return "" + } + var aText, bText string + if m.compareTab == tabRequest { + aText, bText = string(m.compareA.RequestRaw), string(m.compareB.RequestRaw) + } else { + aText, bText = string(m.compareA.ResponseRaw), string(m.compareB.ResponseRaw) + } + return unifiedDiff(fmt.Sprintf("#%d", m.compareA.ID), fmt.Sprintf("#%d", m.compareB.ID), aText, bText) +} + +func (m *model) compareView() string { + var b strings.Builder + if m.compareA == nil || m.compareB == nil { + b.WriteString("loading...\n") + return b.String() + } + title := fmt.Sprintf(" comparer - #%d (%s %s%s -> %d) vs #%d (%s %s%s -> %d) ", + m.compareA.ID, m.compareA.Method, m.compareA.Host, m.compareA.Path, m.compareA.StatusCode, + m.compareB.ID, m.compareB.Method, m.compareB.Host, m.compareB.Path, m.compareB.StatusCode) + b.WriteString(titleStyle.Render(title)) + b.WriteString("\n") + + if m.compareTab == tabRequest { + b.WriteString(tabActive.Render("Request")) + b.WriteString(tabInactive.Render("Response")) + } else { + b.WriteString(tabInactive.Render("Request")) + b.WriteString(tabActive.Render("Response")) + } + b.WriteString("\n") + b.WriteString(m.compareViewport.View()) + b.WriteString("\n") + if m.statusMsg != "" { + b.WriteString(statusStyle.Render(m.statusMsg)) + b.WriteString("\n") + } + b.WriteString(helpStyle.Render("tab switch request/response · ↑/↓ scroll · esc back · q quit")) + return b.String() +} diff --git a/cmd/mitmux/main.go b/cmd/mitmux/main.go index 1f0177c..ebcd666 100644 --- a/cmd/mitmux/main.go +++ b/cmd/mitmux/main.go @@ -73,6 +73,7 @@ const ( viewRepeater viewRules viewIntruder + viewCompare viewHelp ) @@ -163,6 +164,12 @@ type model struct { daemonStatus *ipc.StatusMsg prevMode viewMode // for the ? help screen's "esc back" target + compareBaseID int64 // marked via 'c', 0 = none marked + compareA *ipc.EntryDetail + compareB *ipc.EntryDetail + compareTab detailTab + compareViewport viewport.Model + statusMsg string width int height int @@ -324,6 +331,47 @@ func (m *model) loadDetail(id int64, dest string) tea.Cmd { } } +// markOrCompare implements 'c': the first press on an entry marks it as +// the comparison base (no fetch yet - cheap, no round trip until there's +// actually something to compare). A second press on a *different* entry +// fetches both and opens the comparer; pressing it again on the same +// entry is a no-op with a hint, not a silent clear. +func (m *model) markOrCompare(id int64) tea.Cmd { + switch { + case m.compareBaseID == 0: + m.compareBaseID = id + m.statusMsg = fmt.Sprintf("marked #%d for comparison - press c on another entry to diff", id) + return nil + case m.compareBaseID == id: + m.statusMsg = fmt.Sprintf("#%d is already marked - press c on a different entry to diff", id) + return nil + default: + baseID := m.compareBaseID + m.compareBaseID = 0 + m.statusMsg = "loading comparison..." + return m.loadCompare(baseID, id) + } +} + +type compareLoadedMsg struct { + a, b *ipc.EntryDetail + err error +} + +func (m *model) loadCompare(idA, idB int64) tea.Cmd { + return func() tea.Msg { + a, err := m.client.Get(idA) + if err != nil { + return compareLoadedMsg{err: err} + } + b, err := m.client.Get(idB) + if err != nil { + return compareLoadedMsg{err: err} + } + return compareLoadedMsg{a: a, b: b} + } +} + func (m *model) sendRepeat() tea.Cmd { scheme, host := m.repeaterScheme, m.repeaterHost // The textarea only understands LF; HTTP/1.1 requires CRLF. Restoring @@ -520,6 +568,7 @@ func (m *model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.table.SetHeight(h - 5) m.searchInput.Width = msg.Width - 2 m.viewport = viewport.New(msg.Width, h-5) + m.compareViewport = viewport.New(msg.Width, h-5) reqHeight := (h - 6) / 2 m.reqArea.SetWidth(msg.Width) @@ -603,6 +652,20 @@ func (m *model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.viewport.GotoTop() return m, nil + case compareLoadedMsg: + if msg.err != nil { + m.statusMsg = "compare error: " + msg.err.Error() + return m, nil + } + m.compareA = msg.a + m.compareB = msg.b + m.compareTab = tabRequest + m.mode = viewCompare + m.statusMsg = "" + m.compareViewport.SetContent(m.compareContent()) + m.compareViewport.GotoTop() + return m, nil + case repeatSentMsg: m.sending = false if msg.err != nil { @@ -708,6 +771,10 @@ func (m *model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.table.SetRows(rowsFor(m.entries)) return m, m.setFlagged(m.entries[row].ID, m.entries[row].Flagged) } + case "c": + if row := m.table.Cursor(); row >= 0 && row < len(m.entries) { + return m, m.markOrCompare(m.entries[row].ID) + } case "/": m.searching = true m.searchInput.SetValue(m.query) @@ -756,6 +823,10 @@ func (m *model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.viewport.SetContent(m.detailContent()) } return m, nil + case "c": + if m.detail != nil { + return m, m.markOrCompare(m.detail.ID) + } case "tab": if m.activeTab == tabRequest { m.activeTab = tabResponse @@ -967,6 +1038,31 @@ func (m *model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } return m, cmd + case viewCompare: + switch msg.String() { + case "q", "esc": + m.mode = viewList + return m, nil + case "ctrl+c": + return m, tea.Quit + case "?": + m.prevMode = viewCompare + m.mode = viewHelp + return m, nil + case "tab": + if m.compareTab == tabRequest { + m.compareTab = tabResponse + } else { + m.compareTab = tabRequest + } + m.compareViewport.SetContent(m.compareContent()) + m.compareViewport.GotoTop() + return m, nil + } + var cmd tea.Cmd + m.compareViewport, cmd = m.compareViewport.Update(msg) + return m, cmd + case viewHelp: switch msg.String() { case "ctrl+c": @@ -1002,6 +1098,8 @@ func (m *model) View() string { } case viewIntruder: body = m.intruderView() + case viewCompare: + body = m.compareView() default: body = m.listView() } @@ -1021,7 +1119,7 @@ func (m *model) statusBar() string { } view := map[viewMode]string{ viewList: "history", viewDetail: "detail", viewRepeater: "repeater", - viewRules: "rules", viewIntruder: "intruder", + viewRules: "rules", viewIntruder: "intruder", viewCompare: "comparer", }[m.mode] return statusBarStyle.Render(fmt.Sprintf(" mitmux · proxy %s%s · %s ", proxy, count, view)) } @@ -1050,6 +1148,7 @@ func (m *model) helpView() string { "r open in Repeater", "i open in Intruder", "f toggle flag (★ mark this, revisit later)", + "c mark for comparison, then press c on another entry to diff", "/ search: plain text, host:value, AND/OR/NOT,", " status:404 / status:4xx / status:>=400,", " source:repeater, flagged:true", @@ -1061,9 +1160,15 @@ func (m *model) helpView() string { "tab switch request/response", "↑/↓ or j/k scroll (also g/G, ctrl+u/d - same as history list)", "p toggle pretty-printed JSON (response only, display-only)", + "c mark/compare (same as history list)", "r / i open in Repeater / Intruder", "esc / q back to history", ) + section("Comparer", + "tab switch request/response diff", + "↑/↓ or j/k scroll (also g/G, ctrl+u/d - same as history list)", + "esc / q back to history", + ) section("Repeater / Intruder editors - vi-modal", "Starts in NORMAL mode (not insert) - press i to type, esc to stop.", "h j k l left/down/up/right 0 / $ line start/end", @@ -1095,6 +1200,11 @@ var ( tabInactive = lipgloss.NewStyle().Foreground(lipgloss.Color("240")).Padding(0, 1) statusBarStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("0")).Background(lipgloss.Color("240")) + + diffAddStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("42")) + diffDelStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("203")) + diffHunkStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("39")) + diffHeaderStyle = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("240")) ) func (m *model) listView() string { @@ -1115,9 +1225,9 @@ func (m *model) listView() string { b.WriteString(statusStyle.Render(m.statusMsg)) b.WriteString("\n") } - help := "↑/↓ navigate · enter view · r repeater · i intruder · f flag · / search · m rules · q quit" + help := "enter view · r repeater · i intruder · f flag · c compare · / search · m rules · ? help · q quit" if m.query != "" { - help = "↑/↓ navigate · enter view · r repeater · i intruder · f flag · / search · m rules · esc clear filter · q quit" + help = "enter view · r repeater · i intruder · f flag · c compare · / search · esc clear filter · ? help · q quit" } b.WriteString(helpStyle.Render(help)) return b.String() @@ -1152,7 +1262,7 @@ func (m *model) detailView() string { b.WriteString("\n") b.WriteString(m.viewport.View()) b.WriteString("\n") - b.WriteString(helpStyle.Render("tab switch · p pretty-print JSON · ↑/↓ scroll · r repeater · i intruder · esc back · q quit")) + b.WriteString(helpStyle.Render("tab switch · p pretty-print · c compare · r repeater · i intruder · esc back · ? help · q quit")) return b.String() } @@ -6,6 +6,7 @@ require ( github.com/charmbracelet/bubbles v1.0.0 github.com/charmbracelet/bubbletea v1.3.10 github.com/charmbracelet/lipgloss v1.1.0 + github.com/pmezard/go-difflib v1.0.0 golang.org/x/net v0.58.0 modernc.org/sqlite v1.56.0 ) @@ -54,6 +54,8 @@ github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk= github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w= github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= |