srdusr
aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-rw-r--r--PLAN.md73
-rw-r--r--README.md27
-rw-r--r--cmd/mitmux/jsoncolor.go134
-rw-r--r--cmd/mitmux/jsoncolor_test.go155
-rw-r--r--cmd/mitmux/main.go201
-rw-r--r--cmd/mitmux/pretty.go34
-rw-r--r--go.mod2
7 files changed, 601 insertions, 25 deletions
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