From 9b630b2cbf9206855534668ebaaaee255cabedd4 Mon Sep 17 00:00:00 2001 From: srdusr <99972264+srdusr@users.noreply.github.com> Date: Fri, 31 Jul 2026 16:07:00 +0200 Subject: History sorting, status color-coding, and JSON syntax highlighting Filtering already existed (FTS5 search plus status:/source:/flagged: and column filters). Sorting and highlighting didn't, at all. Sorting: o/O cycle and reverse the sort column (status, size, time taken, method, host, path) applied client-side on top of whatever order List/Search already returned. refreshTable() reorders m.entries itself, not just what's rendered - every "act on the selected row" key handler indexes m.table.Cursor() straight into m.entries with no indirection, so keeping the two in identical order sidesteps an entire class of "highlighted row and actual target silently disagree" bugs rather than updating every one of those call sites. JSON syntax highlighting (cmd/mitmux/jsoncolor.go): walks the token stream via json.Decoder.Token() with an explicit stack, not recursive calls, so depth is bounded by memory rather than Go's call stack for adversarial nesting. Every string re-escaped via json.Marshal before writing, which is also why the colorized output is deliberately never run through sanitizeControl afterward (unlike every other raw-text view here): JSON's own encoding rules already forbid a literal control character in a string, so re-marshaling neutralizes one as a side effect of producing valid JSON - running sanitizeControl on top would instead corrupt the ANSI codes this adds. Status-code color-coding (2xx green through 5xx red) does not live in the history/Intruder tables, despite an initial attempt to put it there. Confirmed live: bubbles/table v1.0.0 (the newest available) fits cell text to its column width via go-runewidth's Truncate, which has no ANSI awareness - it counts every character of a color escape sequence as real display width. Coloring the Status cell silently deleted the status text from the row; the width-fitting truncation cut into the escape sequence itself. styledStatus is used once instead, in detailView's title, a plain string rendered whole with no width constraint. Verified live in tmux against a running daemon: ascending/descending sort by status across six real entries: pretty-printed JSON confirmed correctly colored and indented via raw ANSI capture, not just eyeballed; the detail title's status color confirmed red for a 500 entry the same way; the request tab (never JSON) confirmed unaffected. --- PLAN.md | 73 ++++++++++++++++ README.md | 27 +++++- cmd/mitmux/jsoncolor.go | 134 +++++++++++++++++++++++++++++ cmd/mitmux/jsoncolor_test.go | 155 +++++++++++++++++++++++++++++++++ cmd/mitmux/main.go | 201 +++++++++++++++++++++++++++++++++++++++++-- cmd/mitmux/pretty.go | 34 +++++--- go.mod | 2 +- 7 files changed, 601 insertions(+), 25 deletions(-) create mode 100644 cmd/mitmux/jsoncolor.go create mode 100644 cmd/mitmux/jsoncolor_test.go diff --git a/PLAN.md b/PLAN.md index 2b94b81..e95622c 100644 --- a/PLAN.md +++ b/PLAN.md @@ -674,3 +674,76 @@ after use - never part of the build) confirmed the captured messages in `ws_messages` via `ipc.Client.ListWSMessages`, matching what the test client actually sent and received, correctly attributed to direction and opcode. + +## Sorting and highlighting + +Filtering already existed (FTS5 search plus `status:`/`source:`/ +`flagged:` and column filters - see Search above); sorting and +highlighting didn't, at all, until now. + +Sorting is client-side, applied on top of whatever order List/Search +already returned (newest-first, or FTS5 relevance) - `o` cycles the +sort column (the default "captured" - no override - then status, +size, time taken, method, host, path), `O` reverses it. +`refreshTable()` doesn't just re-render sorted rows, it reorders +`m.entries` itself to match: every "act on the selected row" key +handler (enter/r/i/f/c/x, and the mouse equivalents) reads +`m.table.Cursor()` and indexes straight into `m.entries` at that +position, with no indirection layer. If the table's rendered order and +`m.entries`'s order ever diverged, the highlighted row and the entry +an action actually targets would silently disagree. Keeping them +identical sidesteps that whole bug class rather than updating every +one of those call sites to go through a lookup. + +Highlighting has two parts, and they needed genuinely different +solutions because they render through different paths: + +- **JSON syntax highlighting** (`cmd/mitmux/jsoncolor.go`): walks the + token stream via `json.Decoder.Token()` with an explicit stack + rather than recursive calls, so depth is bounded by available memory + rather than Go's call stack for deeply nested attacker-controlled + JSON. Every string value and object key gets re-escaped via + `json.Marshal` before being written - which is also the entire + safety argument for *not* running the colorized output through + `sanitizeControl` afterward the way every other raw-text view in + this codebase does: JSON's own encoding rules forbid a literal + control character (ESC included) in a string, so re-marshaling one + neutralizes it as a side effect of just producing valid JSON text. + Running the already-colored output through `sanitizeControl` + afterward would instead corrupt the ANSI codes this function adds - + it treats ESC like any other control byte, correctly, for content + that hasn't been through this treatment. `prettyResponse` sanitizes + the header/status block (still server-controlled, still raw text) + separately from the colorized body for exactly this reason. This + path renders into a `viewport`, a plain scrolling text pane with no + fixed-width cell model, so ANSI content survives untouched. +- **Status-code color-coding** (2xx green through 5xx red, Burp/ + Caido's own convention) does *not* live in the history or Intruder- + results tables, despite an initial attempt to put it there. Confirmed + live, not guessed: `bubbles/table` v1.0.0 - the newest version + available, there is no newer one to upgrade to - fits cell text to + its column width via `go-runewidth`'s `Truncate`, which has zero ANSI + awareness; it counts every visible character of a + `"\x1b[38;5;42m"` escape sequence as real display width. Coloring the + Status cell didn't misalign the table, it silently deleted the status + text from the row entirely - the width-fitting truncation cut into + the escape sequence itself. `styledStatus` exists but is deliberately + unused inside any `table.Row`; it's used once, in `detailView`'s + title, which is a plain string lipgloss-renders whole with no width + constraint - nesting one nested `Render()` call inside another works + correctly there (confirmed live via raw escape-code inspection), at + the cost of one trailing space losing the outer title's bold/color + after the inner reset code, placed at the very end of the string + specifically to keep that cost minimal. + +Verified live in tmux against a running daemon with six real captured +entries spanning 200/302/404/500 responses: `o` sorted ascending by +status (200, 200, 200, 302, 404, 500), `O` reversed it; the detail +view's title rendered the status code in the correct color per class +(confirmed red for the 500 entry via raw escape-code capture, not just +visually); pretty-printing a real JSON response produced correctly +colored, correctly indented output - keys blue, string values green, +numbers orange, booleans/null magenta, punctuation gray, confirmed +against the raw captured ANSI codes, not just eyeballed - and the +request tab (never JSON, never pretty-printed) rendered as plain +unstyled text, unaffected. diff --git a/README.md b/README.md index 25fb28d..7acd262 100644 --- a/README.md +++ b/README.md @@ -36,7 +36,8 @@ list of what's deliberately not implemented (and why), see text just works (`example.com`, `x-forwarded-for`, `192.168.1.1` - no quoting needed), plus structured filters: `status:404`, `status:4xx`, `status:>=400`, `source:repeater`, `flagged:true`, - column filters like `host:example.com`, and `AND`/`OR`/`NOT`. + column filters like `host:example.com`, and `AND`/`OR`/`NOT`. Sort + the loaded page by any column (`o`/`O`). - **Repeater**: edit and resend a raw request. What you type is what goes on the wire - no normalization, no auto-fixed `Content-Length`, no "helpful" reformatting. That's the point of a Repeater. Multiple @@ -247,18 +248,36 @@ below is enough to get going. | `I` | import a HAR file's entries into history | | `d` | Decoder | | `/` | search | +| `o` | cycle sort column (captured/status/size/time taken/method/host/path) | +| `O` | reverse the current sort column's direction | | `m` | match-and-replace rules | | `s` | target scope (what gets recorded) | +| `t` | client (mutual-TLS) certificates | | `q` | quit | Also mouse-driven - wheel to scroll, right-click a row for a context menu of the same actions. See [Mouse](#mouse) below. +`o` cycles which column sorts the currently loaded page (captured order +- the default, newest-first or search-relevance order - then status, +size, time taken, method, host, path); `O` reverses whichever column is +active. Client-side, on top of whatever List/Search already returned: +loading more or re-searching keeps the same sort applied. Status-code +color-coding (2xx green through 5xx red, the same convention Burp and +Caido use) isn't in the table itself - `bubbles/table`, the terminal +table widget this UI is built on, has no way to color one cell without +corrupting the whole row's layout (confirmed, not guessed: its column- +width fitting counts every character of a color code as visible text). +It's in the detail view's title instead, where that constraint doesn't +apply. + ### Detail view -`tab` switches request/response, `p` toggles pretty-printed JSON on the -response (display-only - never touches the stored or resent bytes), `c` -mark/compare (same as the history list), `r`/`i` jump straight to +`tab` switches request/response, `p` toggles pretty-printed, +syntax-highlighted JSON on the response (keys/strings/numbers/booleans +colored, matching most editors - 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, `e` exports the entry (request and response, raw bytes, plain text - type a path and press enter), `w` views captured WebSocket messages if this entry's diff --git a/cmd/mitmux/jsoncolor.go b/cmd/mitmux/jsoncolor.go new file mode 100644 index 0000000..9c224e3 --- /dev/null +++ b/cmd/mitmux/jsoncolor.go @@ -0,0 +1,134 @@ +package main + +import ( + "bytes" + "encoding/json" + "io" +) + +// colorizeJSON renders data (must already be json.Valid) as indented, +// syntax-highlighted JSON - keys blue, strings green, numbers orange, +// true/false/null magenta, punctuation gray - matching the convention +// most editors and Burp/Caido's own JSON viewers use. Walks the token +// stream via json.Decoder.Token() with an explicit stack rather than +// recursive calls, so depth is bounded by available memory rather than +// Go's call stack regardless of how deeply nested attacker-controlled +// JSON is. +// +// Every string value and object key is re-escaped via json.Marshal +// before being written out, which is what makes this safe to render +// directly to the terminal without a separate sanitizeControl pass +// afterward: JSON's own encoding rules forbid a raw control character +// (ESC included) in a string literal, so re-marshaling one converts it +// to a harmless \u-escape as a side effect of just producing valid JSON +// text - the same safety property sanitizeControl exists to enforce +// elsewhere, arrived at here for free instead. Running the already- +// colored output through sanitizeControl afterward would instead +// corrupt the ANSI escape codes this function adds - it treats ESC like +// any other control byte, by design, for content that hasn't been +// through this treatment. +func colorizeJSON(data []byte) (string, bool) { + dec := json.NewDecoder(bytes.NewReader(data)) + dec.UseNumber() + + var out bytes.Buffer + type frame struct { + isObject bool + count int // values written so far at this nesting level + } + var stack []frame + // pendingKey is true right after writing `"key": ` - the next token + // is that key's value and stays on the same line rather than getting + // its own comma/newline/indent treatment. + pendingKey := false + + indent := func(n int) { + for i := 0; i < n; i++ { + out.WriteString(" ") + } + } + beforeToken := func() { + if pendingKey { + pendingKey = false + return + } + if len(stack) == 0 { + return + } + top := &stack[len(stack)-1] + if top.count > 0 { + out.WriteString(jsonPunctStyle.Render(",")) + } + top.count++ + out.WriteByte('\n') + indent(len(stack)) + } + writeKey := func(s string) { + beforeToken() + esc, _ := json.Marshal(s) + out.WriteString(jsonKeyStyle.Render(string(esc))) + out.WriteString(jsonPunctStyle.Render(": ")) + pendingKey = true + } + writeStringValue := func(s string) { + beforeToken() + esc, _ := json.Marshal(s) + out.WriteString(jsonStringStyle.Render(string(esc))) + } + + for { + tok, err := dec.Token() + if err != nil { + if err == io.EOF { + break + } + return "", false + } + + if d, ok := tok.(json.Delim); ok { + switch d { + case '{', '[': + beforeToken() + out.WriteString(jsonPunctStyle.Render(string(d))) + stack = append(stack, frame{isObject: d == '{'}) + case '}', ']': + closed := stack[len(stack)-1] + stack = stack[:len(stack)-1] + if closed.count > 0 { + out.WriteByte('\n') + indent(len(stack)) + } + out.WriteString(jsonPunctStyle.Render(string(d))) + } + continue + } + + // A string is a key only when directly inside an object and not + // immediately following a key we just wrote (pendingKey) - that + // next string is this key's *value*, not a new key. + isKeyPosition := len(stack) > 0 && stack[len(stack)-1].isObject && !pendingKey + + switch t := tok.(type) { + case string: + if isKeyPosition { + writeKey(t) + } else { + writeStringValue(t) + } + case json.Number: + beforeToken() + out.WriteString(jsonNumberStyle.Render(t.String())) + case bool: + beforeToken() + s := "false" + if t { + s = "true" + } + out.WriteString(jsonBoolStyle.Render(s)) + case nil: + beforeToken() + out.WriteString(jsonBoolStyle.Render("null")) + } + } + return out.String(), true +} diff --git a/cmd/mitmux/jsoncolor_test.go b/cmd/mitmux/jsoncolor_test.go new file mode 100644 index 0000000..cccb9f8 --- /dev/null +++ b/cmd/mitmux/jsoncolor_test.go @@ -0,0 +1,155 @@ +package main + +import ( + "os" + "regexp" + "strings" + "testing" + + "github.com/charmbracelet/lipgloss" + "github.com/muesli/termenv" +) + +// TestMain forces lipgloss to always emit color codes for this package's +// tests. lipgloss auto-detects a color profile from the output stream by +// default, which is Ascii (no color at all) under `go test` - stdout +// isn't a real terminal there - so without this, every jsonXxxStyle.Render +// call in the code under test would silently produce plain text and +// TestColorizeJSONActuallyAddsColor would pass or fail based on how the +// tests happen to be invoked rather than on the code's real behavior in +// the TUI's actual terminal target. +func TestMain(m *testing.M) { + lipgloss.SetColorProfile(termenv.TrueColor) + os.Exit(m.Run()) +} + +var ansiRE = regexp.MustCompile(`\x1b\[[0-9;]*m`) + +func stripANSI(s string) string { return ansiRE.ReplaceAllString(s, "") } + +func TestColorizeJSONPlainTextMatchesStandardIndent(t *testing.T) { + in := `{"b":2,"a":[1,2,3],"s":"hello","t":true,"f":false,"n":null,"nested":{"x":1}}` + got, ok := colorizeJSON([]byte(in)) + if !ok { + t.Fatal("expected ok=true for valid JSON") + } + want := `{ + "b": 2, + "a": [ + 1, + 2, + 3 + ], + "s": "hello", + "t": true, + "f": false, + "n": null, + "nested": { + "x": 1 + } +}` + if plain := stripANSI(got); plain != want { + t.Errorf("stripped output = %q, want %q", plain, want) + } +} + +func TestColorizeJSONActuallyAddsColor(t *testing.T) { + got, ok := colorizeJSON([]byte(`{"key":"value"}`)) + if !ok { + t.Fatal("expected ok=true") + } + if !strings.Contains(got, "\x1b[") { + t.Error("expected the output to contain ANSI escape codes") + } + if got == stripANSI(got) { + t.Error("output should differ from its ANSI-stripped form") + } +} + +func TestColorizeJSONEmptyContainers(t *testing.T) { + got, ok := colorizeJSON([]byte(`{"a":{},"b":[]}`)) + if !ok { + t.Fatal("expected ok=true") + } + want := `{ + "a": {}, + "b": [] +}` + if plain := stripANSI(got); plain != want { + t.Errorf("stripped output = %q, want %q", plain, want) + } +} + +func TestColorizeJSONTopLevelScalar(t *testing.T) { + for _, tt := range []struct{ in, want string }{ + {`"just a string"`, `"just a string"`}, + {`42`, `42`}, + {`true`, `true`}, + {`null`, `null`}, + } { + got, ok := colorizeJSON([]byte(tt.in)) + if !ok { + t.Fatalf("colorizeJSON(%q): expected ok=true", tt.in) + } + if plain := stripANSI(got); plain != tt.want { + t.Errorf("colorizeJSON(%q) stripped = %q, want %q", tt.in, plain, tt.want) + } + } +} + +func TestColorizeJSONInvalidReturnsFalse(t *testing.T) { + if _, ok := colorizeJSON([]byte(`{not valid json`)); ok { + t.Error("expected ok=false for invalid JSON") + } +} + +// TestColorizeJSONNeutralizesControlCharactersInStrings pins down the +// entire safety argument behind skipping a separate sanitizeControl pass +// on colorizeJSON's output (see its doc comment). A raw, literal control +// byte inside a JSON string makes the JSON syntactically invalid per RFC +// 8259 - json.Valid (already checked by prettyResponse before this +// function ever runs) rejects that case outright, so it's not the real +// threat here. The real case is a *properly escaped* control character +// () in otherwise-valid JSON: json.Decoder.Token() unescapes it +// back into a literal ESC rune in the Go string it hands back, and that +// must never reach the terminal as a real escape sequence when this +// function writes it back out. encoding/json's own re-marshaling +// escapes it again, which is what neutralizes it here. +func TestColorizeJSONNeutralizesControlCharactersInStrings(t *testing.T) { + marker := "u00" + "1b" // built at runtime so this source file contains no literal backslash-u escape + in := []byte(`{"evil":"a\` + marker + `[31mFAKE\` + marker + `[0mb"}`) + + got, ok := colorizeJSON(in) + if !ok { + t.Fatal("expected ok=true for valid JSON containing an escaped control character") + } + // The lipgloss color codes this function legitimately adds contain + // plenty of real ESC bytes of their own - strip those first so this + // check is only about whatever came from the JSON *content*. + plain := stripANSI(got) + if strings.ContainsRune(plain, 0x1b) { + t.Errorf("raw ESC byte from JSON content survived into output: %q", plain) + } + if !strings.Contains(plain, marker) { + t.Errorf("expected the ESC byte to still be escaped as a unicode sequence containing %q, got: %q", marker, plain) + } +} + +func TestColorizeJSONArrayOfObjects(t *testing.T) { + in := `[{"id":1},{"id":2}]` + got, ok := colorizeJSON([]byte(in)) + if !ok { + t.Fatal("expected ok=true") + } + want := `[ + { + "id": 1 + }, + { + "id": 2 + } +]` + if plain := stripANSI(got); plain != want { + t.Errorf("stripped output = %q, want %q", plain, want) + } +} diff --git a/cmd/mitmux/main.go b/cmd/mitmux/main.go index afb345b..30b71e7 100644 --- a/cmd/mitmux/main.go +++ b/cmd/mitmux/main.go @@ -9,6 +9,7 @@ import ( "os" "path/filepath" "regexp" + "sort" "strings" "time" @@ -127,6 +128,42 @@ const ( tabResponse ) +// sortColumn selects how the history list's currently loaded page is +// ordered, client-side, on top of whatever order List/Search returned it +// in (newest-first, or FTS5 relevance). sortByTime means "no override" - +// the natural load order - rather than a real column to sort by, since +// that order already comes sorted from the daemon. +type sortColumn int + +const ( + sortByTime sortColumn = iota + sortByStatus + sortBySize + sortByDuration + sortByMethod + sortByHost + sortByPath +) + +func (c sortColumn) String() string { + switch c { + case sortByStatus: + return "status" + case sortBySize: + return "size" + case sortByDuration: + return "time taken" + case sortByMethod: + return "method" + case sortByHost: + return "host" + case sortByPath: + return "path" + default: + return "captured" + } +} + type repeaterFocus int const ( @@ -191,6 +228,11 @@ type model struct { query string // applied filter, "" means unfiltered pendingNew int // live entries captured while a filter hides them + // Client-side sort applied on top of whatever order List/Search + // already returned - see sortColumn. + sortColumn sortColumn + sortDesc bool + viewport viewport.Model detail *ipc.EntryDetail activeTab detailTab @@ -1241,7 +1283,7 @@ func (m *model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m, nil } m.entries = msg.entries - setTableRows(&m.table, rowsFor(m.entries)) + m.refreshTable() return m, nil case newEntryMsg: @@ -1261,7 +1303,7 @@ func (m *model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m, m.waitForEntry } m.entries = append([]store.Summary{msg.entry}, m.entries...) - setTableRows(&m.table, rowsFor(m.entries)) + m.refreshTable() return m, m.waitForEntry case statusLoadedMsg: @@ -1596,8 +1638,9 @@ func (m *model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { case "f": if row := m.table.Cursor(); row >= 0 && row < len(m.entries) { m.entries[row].Flagged = !m.entries[row].Flagged - setTableRows(&m.table, rowsFor(m.entries)) - return m, m.setFlagged(m.entries[row].ID, m.entries[row].Flagged) + id, flagged := m.entries[row].ID, m.entries[row].Flagged + m.refreshTable() + return m, m.setFlagged(id, flagged) } case "c": if row := m.table.Cursor(); row >= 0 && row < len(m.entries) { @@ -1662,6 +1705,27 @@ func (m *model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.mode = viewClientCerts m.statusMsg = "" return m, m.loadClientCerts + case "o": + m.sortColumn = (m.sortColumn + 1) % 7 + m.sortDesc = false + m.refreshTable() + if m.sortColumn == sortByTime { + m.statusMsg = "sort: captured (load order)" + } else { + m.statusMsg = fmt.Sprintf("sort: %s ascending (O to reverse)", m.sortColumn) + } + return m, nil + case "O": + if m.sortColumn != sortByTime { + m.sortDesc = !m.sortDesc + m.refreshTable() + dir := "ascending" + if m.sortDesc { + dir = "descending" + } + m.statusMsg = fmt.Sprintf("sort: %s %s", m.sortColumn, dir) + } + return m, nil case "esc": if m.query != "" { m.query = "" @@ -2412,6 +2476,9 @@ func (m *model) helpView() string { " status:404 / status:4xx / status:>=400,", " 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", + "O reverse the current sort column's direction", "m match-and-replace rules", "s target scope (what gets recorded)", "t client (mutual-TLS) certificates", @@ -2501,6 +2568,18 @@ var ( diffDelStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("203")) diffHunkStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("39")) diffHeaderStyle = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("240")) + + status2xxStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("42")) // green + status3xxStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("39")) // blue + status4xxStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("208")) // orange + status5xxStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("203")) // red + statusErrStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("203")) // red + + jsonKeyStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("39")) // blue + jsonStringStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("42")) // green + jsonNumberStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("208")) // orange + jsonBoolStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("170")) // magenta + jsonPunctStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("240")) // gray ) func (m *model) listView() string { @@ -2538,9 +2617,9 @@ func (m *model) listView() string { b.WriteString(statusStyle.Render(sanitizeLine(m.statusMsg))) b.WriteString("\n") } - help := "enter view · r/i/c/d tools · f flag · x delete · X clear all · E export · I import · / search · m rules · s scope · t client certs · ? help · q quit" + help := "enter view · r/i/c/d tools · f flag · x delete · X clear all · E export · I import · / search · o/O sort · m rules · s scope · t client certs · ? help · q quit" if m.query != "" { - help = "enter view · r/i/c/d tools · f flag · x delete · X clear all · E export (this filter) · I import · / search · esc clear filter · s scope · t client certs · ? help · q quit" + help = "enter view · r/i/c/d tools · f flag · x delete · X clear all · E export (this filter) · I import · / search · esc clear filter · o/O sort · s scope · t client certs · ? help · q quit" } b.WriteString(helpStyle.Render(help)) return b.String() @@ -2556,8 +2635,21 @@ func (m *model) detailView() string { if m.detail.Flagged { flagMark = "★ " } - title := fmt.Sprintf(" %s#%d %s %s%s -> %d ", flagMark, m.detail.ID, sanitizeLine(m.detail.Method), sanitizeLine(m.detail.Host), sanitizeLine(m.detail.Path), m.detail.StatusCode) - b.WriteString(titleStyle.Render(title)) + statusText := fmt.Sprintf("%d", m.detail.StatusCode) + if m.detail.StatusCode == 0 { + statusText = "ERR" + } + title := fmt.Sprintf(" %s#%d %s %s%s -> ", flagMark, m.detail.ID, sanitizeLine(m.detail.Method), sanitizeLine(m.detail.Host), sanitizeLine(m.detail.Path)) + // Status colored via a separate Render call nested inside titleStyle's + // own - placed last, right before titleStyle's own trailing space and + // reset, so the one cosmetic side effect of nesting styled text this + // way (the inner Render's reset code also ends titleStyle's bold/color + // for anything written after it) only costs a single space, not a + // visible chunk of the title going unstyled - bubbles/table's cell + // truncation would corrupt this the way it does in the history table + // (see styledStatus's doc comment), but this is a plain string built + // once and rendered whole, never routed through that. + b.WriteString(titleStyle.Render(title) + styledStatus(m.detail.StatusCode, statusText) + titleStyle.Render(" ")) b.WriteString("\n") reqLabel := fmt.Sprintf("Request (%d bytes%s)", len(m.detail.RequestRaw), exactSuffix(m.detail.RequestExact, m.detail.RequestTruncated)) @@ -3094,6 +3186,99 @@ func detailBody(d *ipc.EntryDetail, tab detailTab) string { return sanitizeBlock(string(d.ResponseRaw)) } +// styledStatus renders text (an already-formatted status display value - +// "200", "404", "ERR") colored by status class: 2xx green, 3xx blue, 4xx +// orange, 5xx/ERR red - the same convention Burp and Caido both use. +// statusCode is the raw numeric code (0 for ERR) so the class is derived +// directly rather than re-parsed from text. Safe to color unconditionally +// here: unlike a header value or body content, this text is always +// something this file formatted itself ("%d" or the literal "ERR"), never +// raw server-controlled bytes, so it needs no sanitizeControl pass first. +// +// Deliberately NOT used inside a table.Row cell: bubbles/table v1.0.0 +// (the newest available - confirmed no per-cell styling was ever added) +// fits cell text to its column width via go-runewidth's Truncate, which +// has no ANSI awareness at all - it counts every visible character of a +// "\x1b[38;5;42m" escape sequence as real display width. Confirmed live: +// coloring the Status cell this way didn't just fail to align, it +// silently deleted the status text from the rendered row entirely +// (truncation cut into the escape sequence itself). Safe anywhere a +// plain string gets built once and lipgloss-rendered whole with no width +// constraint - e.g. detailView's title - just not through table.Row. +func styledStatus(statusCode int, text string) string { + switch { + case statusCode == 0: + return statusErrStyle.Render(text) + case statusCode < 300: + return status2xxStyle.Render(text) + case statusCode < 400: + return status3xxStyle.Render(text) + case statusCode < 500: + return status4xxStyle.Render(text) + default: + return status5xxStyle.Render(text) + } +} + +// refreshTable reorders m.entries to match the current sort (a no-op +// when sortByTime, the "no override" state) and re-renders the table +// from it - call this instead of setTableRows(&m.table, rowsFor(...)) +// directly anywhere m.entries changes, so a sort stays applied across a +// reload or a live-captured entry arriving. +// +// Reordering m.entries itself, not just what's rendered, matters beyond +// display: every "row" key handler (enter/r/i/f/c/x and the mouse +// equivalents) reads m.table.Cursor() and indexes straight into +// m.entries at that position. If the table's rendered order and +// m.entries's order ever diverged, the row highlighted on screen and +// the entry an action actually targets would silently disagree - +// pressing enter on what looks like entry #12 could open #47. Keeping +// them identical sidesteps that whole class of bug rather than needing +// every one of those call sites to go through a level of indirection. +func (m *model) refreshTable() { + m.entries = m.sortedEntries() + setTableRows(&m.table, rowsFor(m.entries)) +} + +// sortedEntries returns m.entries in the order m.sortColumn/m.sortDesc +// select, or m.entries unchanged for sortByTime (the natural load order +// List/Search already returned, newest-first or FTS5-relevance - nothing +// to re-sort). A stable sort so ties (e.g. several entries with the same +// status) keep their relative load order rather than shuffling every +// re-render. +func (m *model) sortedEntries() []store.Summary { + if m.sortColumn == sortByTime { + return m.entries + } + sorted := append([]store.Summary(nil), m.entries...) + less := func(i, j int) bool { + a, b := sorted[i], sorted[j] + switch m.sortColumn { + case sortByStatus: + return a.StatusCode < b.StatusCode + case sortBySize: + return a.ReqSize+a.RespSize < b.ReqSize+b.RespSize + case sortByDuration: + return a.Duration < b.Duration + case sortByMethod: + return a.Method < b.Method + case sortByHost: + return a.Host < b.Host + case sortByPath: + return a.Path < b.Path + default: + return false + } + } + sort.SliceStable(sorted, func(i, j int) bool { + if m.sortDesc { + return less(j, i) + } + return less(i, j) + }) + return sorted +} + func rowsFor(entries []store.Summary) []table.Row { rows := make([]table.Row, len(entries)) for i, e := range entries { diff --git a/cmd/mitmux/pretty.go b/cmd/mitmux/pretty.go index f6da6ad..1350623 100644 --- a/cmd/mitmux/pretty.go +++ b/cmd/mitmux/pretty.go @@ -47,20 +47,30 @@ func prettyResponse(raw []byte) (formatted string, ok bool) { if err != nil || !json.Valid(body) { return "", false } - var pretty bytes.Buffer - if err := json.Indent(&pretty, body, "", " "); err != nil { - return "", false + + var headerBuf bytes.Buffer + headerBuf.WriteString(resp.Proto + " " + resp.Status + "\r\n") + writeHeadersSorted(&headerBuf, resp.Header, "Transfer-Encoding", "Content-Encoding", "Content-Length") + // Status and header values are server-controlled - sanitize before + // display, same as every other raw-text view. The colorized body + // below is handled separately and deliberately never passed through + // this: colorizeJSON's own re-marshaling of every string it prints + // already neutralizes control characters as a side effect of + // producing valid JSON, and sanitizeControl afterward would instead + // corrupt the ANSI color codes it adds (see colorizeJSON's doc + // comment). + headerText := sanitizeBlock(headerBuf.String()) + + bodyText, ok := colorizeJSON(body) + if !ok { + var plain bytes.Buffer + if err := json.Indent(&plain, body, "", " "); err != nil { + return "", false + } + bodyText = sanitizeBlock(plain.String()) } - var out bytes.Buffer - out.WriteString(resp.Proto + " " + resp.Status + "\r\n") - writeHeadersSorted(&out, resp.Header, "Transfer-Encoding", "Content-Encoding", "Content-Length") - out.WriteString("\r\n") - out.Write(pretty.Bytes()) - // Status and header values are server-controlled and not JSON-escaped - // like the body is - sanitize the whole rendering against control- - // character/ANSI injection before it's ever displayed (see sanitize.go). - return sanitizeBlock(out.String()), true + return headerText + "\r\n" + bodyText, true } func writeHeadersSorted(out *bytes.Buffer, h http.Header, omit ...string) { diff --git a/go.mod b/go.mod index 325a6f4..e13f6c4 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/muesli/termenv v0.16.0 github.com/pmezard/go-difflib v1.0.0 golang.org/x/net v0.58.0 modernc.org/sqlite v1.56.0 @@ -30,7 +31,6 @@ require ( github.com/mattn/go-runewidth v0.0.19 // indirect github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect github.com/muesli/cancelreader v0.2.2 // indirect - github.com/muesli/termenv v0.16.0 // indirect github.com/ncruces/go-strftime v1.0.0 // indirect github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect github.com/rivo/uniseg v0.4.7 // indirect -- cgit v1.2.3