diff options
Diffstat (limited to 'cmd')
| -rw-r--r-- | cmd/mitmux/jsoncolor.go | 134 | ||||
| -rw-r--r-- | cmd/mitmux/jsoncolor_test.go | 155 | ||||
| -rw-r--r-- | cmd/mitmux/main.go | 201 | ||||
| -rw-r--r-- | cmd/mitmux/pretty.go | 34 |
4 files changed, 504 insertions, 20 deletions
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) { |