From 66d00f513e30d0746c1d5b4cd3b0c4a34a243928 Mon Sep 17 00:00:00 2001 From: srdusr <99972264+srdusr@users.noreply.github.com> Date: Tue, 24 Feb 2026 19:30:00 +0200 Subject: Comparer: unified diff between two history entries Next item off the "worth considering" list from the Burp/ZAP/Caido gap research. Mark an entry with 'c' (from the history list or detail view - no fetch yet, just remembers the ID), then 'c' on a different entry fetches both and opens a colored unified diff of either side's request or response, tab to switch between them. Unified (git-diff style: +/- prefixed lines) rather than Burp's side-by-side two-pane layout - a two-column view fights terminal width for anything but a wide window, and unified reuses the same scrollable viewport pattern already used everywhere else in this TUI rather than needing new layout machinery. Uses github.com/pmezard/go-difflib (SequenceMatcher-based, a tested port of Python's difflib) rather than hand-rolling LCS/Myers diff, which has real edge cases worth not reinventing. 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 single line as changed purely from an invisible trailing \r. Verified live against two real, distinctly different captured POST requests (different form bodies, different Content-Length): the request diff correctly isolated exactly the two changed lines with the unchanged headers shown as context, colors confirmed via raw ANSI codes in the captured pane output (red 203 for removed, green 42 for added) rather than assumed from the code, and the response tab showed a correct independent diff of the two responses (Date header, JSON body). Also confirmed the "same entry marked twice" path shows a hint rather than silently doing something confusing. --- PLAN.md | 21 ++++++--- README.md | 18 ++++++-- cmd/mitmux/compare.go | 105 ++++++++++++++++++++++++++++++++++++++++++++ cmd/mitmux/main.go | 118 ++++++++++++++++++++++++++++++++++++++++++++++++-- go.mod | 1 + go.sum | 2 + 6 files changed, 252 insertions(+), 13 deletions(-) create mode 100644 cmd/mitmux/compare.go diff --git a/PLAN.md b/PLAN.md index ca379e9..22ab6a4 100644 --- a/PLAN.md +++ b/PLAN.md @@ -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, diff --git a/README.md b/README.md index 70375c7..c3f2d92 100644 --- a/README.md +++ b/README.md @@ -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() } diff --git a/go.mod b/go.mod index f80d9ad..325a6f4 100644 --- a/go.mod +++ b/go.mod @@ -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 ) diff --git a/go.sum b/go.sum index e37c484..bb14566 100644 --- a/go.sum +++ b/go.sum @@ -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= -- cgit v1.2.3