diff options
| -rw-r--r-- | PLAN.md | 35 | ||||
| -rw-r--r-- | README.md | 45 | ||||
| -rw-r--r-- | cmd/mitmux/csv.go | 73 | ||||
| -rw-r--r-- | cmd/mitmux/csv_test.go | 84 | ||||
| -rw-r--r-- | cmd/mitmux/export.go | 100 | ||||
| -rw-r--r-- | cmd/mitmux/export_test.go | 70 | ||||
| -rw-r--r-- | cmd/mitmux/main.go | 56 |
7 files changed, 432 insertions, 31 deletions
@@ -306,11 +306,38 @@ recorded despite being out of scope, confirmed toggling the rule off resumed recording everything, and confirmed both the substring and regex pattern forms save and match correctly. +Shipped since: CSV export and copy-as-curl, both extending the existing +export system by dispatching on the file extension the user types +rather than adding a separate format-selection control - ".har" (the +existing default) or ".csv" for bulk export from the history list, +".txt" (the existing default) or ".sh"/".curl" for single-entry export +from Detail view. CSV is deliberately a lighter, faster path than HAR: +a summary table (id/method/host/path/status/sizes/timing/flag/source) +built straight from the already-loaded Summary rows, no per-entry fetch +from the daemon needed, matching Burp's own "export as CSV" being a +listing rather than a full-fidelity capture - HAR already covers that. +Copy-as-curl parses the raw request (same net/http parsing used +everywhere else in this codebase) and re-serializes it as a runnable +curl command line rather than another copy of the raw bytes, which the +plain-text format already gives you; verified by actually executing a +generated command against the real target and confirming the response +matched the original. + +CSV export required one more thing HAR didn't: guarding against CSV/ +formula injection. Method, host, path, and error all ultimately trace +back to a request line or Host header - content this tool exists +specifically to inspect from potentially hostile traffic - and a field +starting with =, +, -, @, tab, or CR is a formula to Excel/LibreOffice/ +Sheets when the exported file is later opened. csvSafe prefixes any +such field with a single quote, the standard mitigation (OWASP's own +guidance), so every affected spreadsheet application treats it as +literal text instead. This is the same class of bug as the terminal- +injection fix from the robustness audit, just for a different output +format - captured content controlling the tool that later processes it, +rather than the terminal that renders it. + Still open from the expanded "worth considering" list: import (no path -back in yet - HAR export was prioritized as the more common daily need, -getting captured evidence OUT for a report or another tool, over -bringing traffic IN) and copy-as-curl. Both requested explicitly; not -started yet. +back in yet). Requested explicitly; not started yet. Skipped deliberately (from the research, matches this tool's stated scope): active/passive vulnerability scanning, plugin marketplace, @@ -158,7 +158,7 @@ below is enough to get going. | `c` | mark for comparison - press `c` on another entry to diff | | `x` | delete the selected entry (asks `y`/`n` to confirm) | | `X` | clear ALL history, not just the current search filter (asks `y`/`n` to confirm) | -| `E` | export the current view (respects an active search filter) as a HAR file | +| `E` | export the current view (respects an active search filter) - `.har` or `.csv` | | `d` | Decoder | | `/` | search | | `m` | match-and-replace rules | @@ -196,20 +196,37 @@ only - not chained/pipelined the way Burp's Decoder supports. ### Export Two independent export paths, both a modal path-prompt (`enter` writes -and confirms, `esc` cancels): - -- `e` from Detail view exports the single selected entry - request and - response raw bytes, plain text, exactly what Detail view already - shows. Each side is annotated when it isn't wire-exact (truncated or - reconstructed), matching Detail view's own labels. +and confirms, `esc` cancels). Format is picked by the extension you +type, the same convention any "save as" dialog uses - no separate +format-selection control: + +- `e` from Detail view exports the single selected entry. + - `.txt` (default) - request and response raw bytes, plain text, + exactly what Detail view already shows. Each side is annotated when + it isn't wire-exact (truncated or reconstructed), matching Detail + view's own labels. + - `.sh` / `.curl` - the request as a runnable `curl` command line + (Burp/DevTools' own "copy as curl"), for handing to someone else or + re-running standalone without mitmux. Every value is shell-quoted + (a captured or edited request can contain arbitrary bytes). - `E` from the history list exports the current view - the visible, - filtered set if a search is active, everything otherwise - as one - [HAR](https://en.wikipedia.org/wiki/HAR_(file_format)) file, for - importing into Chrome/Firefox DevTools, Burp, Postman, or anything - else that reads HAR 1.2. A binary body (an image, say) is base64- - encoded in the HAR rather than corrupted as text. An entry that fails - to fetch or parse is skipped rather than aborting the whole export; - the status line reports how many, if any. + filtered set if a search is active, everything otherwise. + - `.har` (default) - one + [HAR](https://en.wikipedia.org/wiki/HAR_(file_format)) 1.2 file, for + importing into Chrome/Firefox DevTools, Burp, Postman, or anything + else that reads HAR. A binary body (an image, say) is base64- + encoded rather than corrupted as text. An entry that fails to fetch + or parse is skipped rather than aborting the whole export; the + status line reports how many, if any. + - `.csv` - a summary table (id, method, host, path, status, sizes, + timing, flag, source) for a report or spreadsheet - lighter and + faster than HAR since it needs no per-entry fetch from the daemon. + Any field that could be interpreted as a spreadsheet formula (starts + with `=`, `+`, `-`, `@`, tab, or CR - method/host/path/error all + ultimately trace back to a request line or Host header, exactly the + kind of content this tool exists to inspect from hostile traffic) + is neutralized with a leading quote before writing, the standard + CSV-injection mitigation. There's no import yet (see `PLAN.md`). diff --git a/cmd/mitmux/csv.go b/cmd/mitmux/csv.go new file mode 100644 index 0000000..f64a071 --- /dev/null +++ b/cmd/mitmux/csv.go @@ -0,0 +1,73 @@ +package main + +import ( + "encoding/csv" + "strconv" + "strings" + "time" + + "mitmux/internal/store" +) + +// csvFromSummaries renders entries as CSV - a summary table (ID, +// method, host, path, status, sizes, timing, flag, source), not full +// request/response bytes. This is deliberately the lighter, faster +// bulk-export path: unlike HAR, it needs no per-entry fetch from the +// daemon (Summary already has everything a spreadsheet/report table +// needs), matching Burp's own "export as CSV" being a summary listing +// rather than a full-fidelity capture format - that's what HAR is for. +func csvFromSummaries(entries []store.Summary) (string, error) { + var b strings.Builder + w := csv.NewWriter(&b) + + header := []string{"id", "method", "scheme", "host", "path", "status", "request_bytes", "response_bytes", "duration_ms", "flagged", "source", "started_at", "error"} + if err := w.Write(header); err != nil { + return "", err + } + for _, e := range entries { + row := []string{ + strconv.FormatInt(e.ID, 10), + csvSafe(e.Method), + csvSafe(e.Scheme), + csvSafe(e.Host), + csvSafe(e.Path), + strconv.Itoa(e.StatusCode), + strconv.Itoa(e.ReqSize), + strconv.Itoa(e.RespSize), + strconv.FormatInt(e.Duration.Milliseconds(), 10), + strconv.FormatBool(e.Flagged), + e.Source, + e.StartedAt.Format(time.RFC3339), + csvSafe(e.Error), + } + if err := w.Write(row); err != nil { + return "", err + } + } + w.Flush() + if err := w.Error(); err != nil { + return "", err + } + return b.String(), nil +} + +// csvSafe guards against CSV/formula injection: Method, Host, Path, and +// Error all ultimately trace back to a request line or Host header - +// content this tool exists specifically to inspect from potentially +// hostile traffic. A value starting with =, +, -, @, tab, or CR is a +// formula to Excel/LibreOffice/Sheets when the exported file is opened, +// which (depending on the app and its settings) can call out to other +// cells, external data, or worse. Prefixing such a value with a single +// quote makes every affected spreadsheet application treat it as +// literal text instead - the standard mitigation (OWASP's own CSV +// injection guidance), not something specific to this tool. +func csvSafe(s string) string { + if s == "" { + return s + } + switch s[0] { + case '=', '+', '-', '@', '\t', '\r': + return "'" + s + } + return s +} diff --git a/cmd/mitmux/csv_test.go b/cmd/mitmux/csv_test.go new file mode 100644 index 0000000..b2f6074 --- /dev/null +++ b/cmd/mitmux/csv_test.go @@ -0,0 +1,84 @@ +package main + +import ( + "encoding/csv" + "strings" + "testing" + "time" + + "mitmux/internal/store" +) + +func TestCsvFromSummariesBasic(t *testing.T) { + entries := []store.Summary{ + {ID: 1, Method: "GET", Scheme: "https", Host: "example.com", Path: "/a", StatusCode: 200, ReqSize: 100, RespSize: 200, Duration: 150 * time.Millisecond, Source: "proxy"}, + } + out, err := csvFromSummaries(entries) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + r := csv.NewReader(strings.NewReader(out)) + rows, err := r.ReadAll() + if err != nil { + t.Fatalf("output is not valid CSV: %v", err) + } + if len(rows) != 2 { // header + 1 row + t.Fatalf("expected 2 rows (header+1), got %d: %v", len(rows), rows) + } + if rows[1][0] != "1" || rows[1][3] != "example.com" || rows[1][5] != "200" { + t.Errorf("unexpected row content: %v", rows[1]) + } +} + +func TestCsvFromSummariesEmpty(t *testing.T) { + out, err := csvFromSummaries(nil) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + r := csv.NewReader(strings.NewReader(out)) + rows, err := r.ReadAll() + if err != nil { + t.Fatalf("output is not valid CSV: %v", err) + } + if len(rows) != 1 { // header only + t.Fatalf("expected header-only output, got %d rows", len(rows)) + } +} + +func TestCsvSafeNeutralizesFormulaPrefixes(t *testing.T) { + tests := []struct { + in string + want string + }{ + {"=cmd|'/c calc'!A1", "'=cmd|'/c calc'!A1"}, + {"+1+1", "'+1+1"}, + {"-1", "'-1"}, + {"@SUM(A1)", "'@SUM(A1)"}, + {"example.com", "example.com"}, + {"", ""}, + } + for _, tt := range tests { + if got := csvSafe(tt.in); got != tt.want { + t.Errorf("csvSafe(%q) = %q, want %q", tt.in, got, tt.want) + } + } +} + +func TestCsvFromSummariesNeutralizesFormulaInjection(t *testing.T) { + entries := []store.Summary{ + {ID: 1, Method: "GET", Scheme: "http", Host: "=cmd|'/c calc'!A1", Path: "/", StatusCode: 200}, + } + out, err := csvFromSummaries(entries) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + r := csv.NewReader(strings.NewReader(out)) + rows, err := r.ReadAll() + if err != nil { + t.Fatalf("output is not valid CSV: %v", err) + } + host := rows[1][3] + if !strings.HasPrefix(host, "'") { + t.Errorf("expected malicious host to be neutralized with a leading quote, got %q", host) + } +} diff --git a/cmd/mitmux/export.go b/cmd/mitmux/export.go index 5d11ca8..8b79226 100644 --- a/cmd/mitmux/export.go +++ b/cmd/mitmux/export.go @@ -1,8 +1,14 @@ package main import ( + "bufio" + "bytes" "fmt" + "io" + "net/http" "os" + "path/filepath" + "sort" "strings" "time" @@ -38,6 +44,100 @@ func exportEntryText(d *ipc.EntryDetail) string { return b.String() } +// singleEntryFormat picks the single-entry export format from path's +// extension, the same "save as" convention bulkExportFormat uses for +// the history list - no separate format-selection UI, just write the +// extension you want. +type singleEntryFormat int + +const ( + formatPlainText singleEntryFormat = iota + formatCurl +) + +func singleEntryFormatFor(path string) singleEntryFormat { + switch strings.ToLower(filepath.Ext(path)) { + case ".sh", ".curl": + return formatCurl + default: + return formatPlainText + } +} + +// bulkExportFormat picks the history-list export format from path's +// extension - same convention as singleEntryFormat, a separate type +// because the two operate on entirely different data (Summary rows vs. +// one EntryDetail) and don't share format options. +type bulkExportFormat int + +const ( + formatHAR bulkExportFormat = iota + formatCSV +) + +func bulkExportFormatFor(path string) bulkExportFormat { + switch strings.ToLower(filepath.Ext(path)) { + case ".csv": + return formatCSV + default: + return formatHAR + } +} + +// curlCommand renders one entry's request as a curl command line - +// Burp/browser DevTools' own "copy as curl", so a request captured or +// edited in mitmux can be handed to someone else, or re-run standalone, +// without needing mitmux itself. Parses the raw request the same way +// prettyResponse and the HAR conversion do (net/http, not reimplemented) +// and re-serializes it as curl flags rather than emitting the raw bytes +// directly - the point of this format specifically is a runnable shell +// command, not another copy of the same raw text plain-text export +// already gives you. +func curlCommand(d *ipc.EntryDetail) (string, error) { + req, err := http.ReadRequest(bufio.NewReader(bytes.NewReader(d.RequestRaw))) + if err != nil { + return "", fmt.Errorf("parse request: %w", err) + } + body, _ := io.ReadAll(req.Body) + req.Body.Close() + + var b strings.Builder + fmt.Fprintf(&b, "curl -X %s", shellQuote(req.Method)) + + names := make([]string, 0, len(req.Header)) + for k := range req.Header { + if strings.EqualFold(k, "Host") || strings.EqualFold(k, "Content-Length") { + continue // -H Host is redundant with the URL; curl sets Content-Length itself from --data-raw + } + names = append(names, k) + } + sort.Strings(names) + for _, k := range names { + for _, v := range req.Header[k] { + fmt.Fprintf(&b, " \\\n -H %s", shellQuote(k+": "+v)) + } + } + if len(body) > 0 { + fmt.Fprintf(&b, " \\\n --data-raw %s", shellQuote(string(body))) + } + fmt.Fprintf(&b, " \\\n %s\n", shellQuote(fmt.Sprintf("%s://%s%s", d.Scheme, d.Host, req.URL.RequestURI()))) + return b.String(), nil +} + +// shellQuote wraps s in single quotes for safe use as one POSIX shell +// argument, escaping any embedded single quote by closing the quoted +// string, inserting an escaped literal quote, then reopening it. Every +// value here +// (header values, body, URL) can be arbitrary attacker- or user- +// supplied bytes - a captured/edited request is exactly the kind of +// content that might contain shell metacharacters, so building the curl +// command with naive string concatenation would risk producing a +// command that does something other than what it appears to when +// pasted into a shell. +func shellQuote(s string) string { + return "'" + strings.ReplaceAll(s, "'", `'\''`) + "'" +} + func exportSuffix(exact, truncated bool) string { switch { case exact: diff --git a/cmd/mitmux/export_test.go b/cmd/mitmux/export_test.go index 03c38e0..84c93bb 100644 --- a/cmd/mitmux/export_test.go +++ b/cmd/mitmux/export_test.go @@ -77,3 +77,73 @@ func TestWriteExportFileEmptyPath(t *testing.T) { t.Error("expected an error for an empty path, got nil") } } + +func TestSingleEntryFormatFor(t *testing.T) { + tests := []struct { + path string + want singleEntryFormat + }{ + {"entry.txt", formatPlainText}, + {"entry.sh", formatCurl}, + {"entry.curl", formatCurl}, + {"entry.CURL", formatCurl}, + {"entry", formatPlainText}, + {"", formatPlainText}, + } + for _, tt := range tests { + if got := singleEntryFormatFor(tt.path); got != tt.want { + t.Errorf("singleEntryFormatFor(%q) = %v, want %v", tt.path, got, tt.want) + } + } +} + +func TestCurlCommand(t *testing.T) { + d := &ipc.EntryDetail{ + Summary: store.Summary{Method: "POST", Scheme: "https", Host: "example.com"}, + RequestRaw: []byte("POST /a?x=1 HTTP/1.1\r\nHost: example.com\r\nContent-Type: application/json\r\nContent-Length: 13\r\n\r\n{\"key\":\"val\"}"), + } + got, err := curlCommand(d) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + for _, want := range []string{ + "curl -X 'POST'", + "-H 'Content-Type: application/json'", + "--data-raw '{\"key\":\"val\"}'", + "'https://example.com/a?x=1'", + } { + if !strings.Contains(got, want) { + t.Errorf("curlCommand output missing %q, got:\n%s", want, got) + } + } + if strings.Contains(got, "-H 'Host:") { + t.Errorf("curlCommand should not include a redundant Host header, got:\n%s", got) + } + if strings.Contains(got, "Content-Length") { + t.Errorf("curlCommand should not include Content-Length (curl sets it itself), got:\n%s", got) + } +} + +func TestCurlCommandMalformedRequest(t *testing.T) { + d := &ipc.EntryDetail{RequestRaw: []byte("not a request")} + if _, err := curlCommand(d); err == nil { + t.Error("expected an error for a malformed request, got nil") + } +} + +func TestShellQuoteEscapesEmbeddedQuotes(t *testing.T) { + tests := []struct { + in string + want string + }{ + {"simple", "'simple'"}, + {"it's", `'it'\''s'`}, + {"", "''"}, + {"a; rm -rf /", "'a; rm -rf /'"}, + } + for _, tt := range tests { + if got := shellQuote(tt.in); got != tt.want { + t.Errorf("shellQuote(%q) = %q, want %q", tt.in, got, tt.want) + } + } +} diff --git a/cmd/mitmux/main.go b/cmd/mitmux/main.go index 21209bf..35cadaa 100644 --- a/cmd/mitmux/main.go +++ b/cmd/mitmux/main.go @@ -420,7 +420,7 @@ func (m *model) clearHistory() tea.Cmd { } } -type harExportedMsg struct { +type bulkExportedMsg struct { path string count int skipped int @@ -452,12 +452,30 @@ func (m *model) exportHAR(entries []store.Summary, path string) tea.Cmd { doc, parseFailed := harDocFrom(details) data, err := harMarshal(doc) if err != nil { - return harExportedMsg{err: err} + return bulkExportedMsg{err: err} } if err := writeExportFile(path, string(data)); err != nil { - return harExportedMsg{err: err} + return bulkExportedMsg{err: err} } - return harExportedMsg{path: path, count: len(doc.Log.Entries), skipped: fetchFailed + parseFailed} + return bulkExportedMsg{path: path, count: len(doc.Log.Entries), skipped: fetchFailed + parseFailed} + } +} + +// exportCSV writes entries as a CSV summary table. Unlike exportHAR, +// this needs no per-entry fetch - Summary already carries everything a +// CSV row needs - so it's synchronous work wrapped in a tea.Cmd only +// for consistency with every other write-a-file action, not because it +// actually blocks on anything slow. +func (m *model) exportCSV(entries []store.Summary, path string) tea.Cmd { + return func() tea.Msg { + data, err := csvFromSummaries(entries) + if err != nil { + return bulkExportedMsg{err: err} + } + if err := writeExportFile(path, data); err != nil { + return bulkExportedMsg{err: err} + } + return bulkExportedMsg{path: path, count: len(entries)} } } @@ -940,7 +958,7 @@ func (m *model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.statusMsg = "history cleared" return m, tea.Batch(m.loadList, m.loadStatus) - case harExportedMsg: + case bulkExportedMsg: if msg.err != nil { m.statusMsg = "export error: " + msg.err.Error() return m, nil @@ -1091,6 +1109,9 @@ func (m *model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m, nil } m.statusMsg = "exporting..." + if bulkExportFormatFor(path) == formatCSV { + return m, m.exportCSV(entries, path) + } return m, m.exportHAR(entries, path) case "esc": m.exportEditing = false @@ -1228,7 +1249,16 @@ func (m *model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { if m.detail == nil { return m, nil } - if err := writeExportFile(path, exportEntryText(m.detail)); err != nil { + content := exportEntryText(m.detail) + if singleEntryFormatFor(path) == formatCurl { + c, err := curlCommand(m.detail) + if err != nil { + m.statusMsg = "export error: " + err.Error() + return m, nil + } + content = c + } + if err := writeExportFile(path, content); err != nil { m.statusMsg = "export error: " + err.Error() } else { m.statusMsg = "exported to " + path @@ -1800,7 +1830,7 @@ func (m *model) helpView() string { "c mark for comparison, then press c on another entry to diff", "x delete the selected entry (asks to confirm)", "X clear ALL history, not just the current filter (asks to confirm)", - "E export the current view (respects an active search filter) as a HAR file", + "E export the current view (respects an active filter) - .har or .csv", "d decoder (URL/Base64/Hex/HTML encode/decode)", "/ search: plain text, host:value, AND/OR/NOT,", " status:404 / status:4xx / status:>=400,", @@ -1816,7 +1846,7 @@ func (m *model) helpView() string { "p toggle pretty-printed JSON (response only, display-only)", "c mark/compare (same as history list)", "r / i open in Repeater / Intruder", - "e export this entry to a file (request + response, plain text)", + "e export this entry - .txt (raw request+response) or .sh/.curl (curl command)", "esc / q back to history", ) section("Comparer", @@ -1895,10 +1925,10 @@ func (m *model) listView() string { b.WriteString(m.table.View()) b.WriteString("\n") if m.exportEditing { - b.WriteString("export HAR (current view) to: ") + b.WriteString("export current view to: ") b.WriteString(m.exportInput.View()) b.WriteString("\n") - b.WriteString(helpStyle.Render("enter write · esc cancel · ctrl+c quit")) + b.WriteString(helpStyle.Render("enter write · esc cancel · ctrl+c quit - .har (full, default) or .csv (summary table)")) return b.String() } if m.confirmPrompt != "" { @@ -1908,9 +1938,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 HAR · / search · m rules · s scope · ? help · q quit" + help := "enter view · r/i/c/d tools · f flag · x delete · X clear all · E export (HAR/CSV) · / search · m rules · s scope · ? help · q quit" if m.query != "" { - help = "enter view · r/i/c/d tools · f flag · x delete · X clear all · E export HAR (this filter) · / search · esc clear filter · s scope · ? help · q quit" + help = "enter view · r/i/c/d tools · f flag · x delete · X clear all · E export (this filter) · / search · esc clear filter · s scope · ? help · q quit" } b.WriteString(helpStyle.Render(help)) return b.String() @@ -1949,7 +1979,7 @@ func (m *model) detailView() string { b.WriteString("export to: ") b.WriteString(m.exportInput.View()) b.WriteString("\n") - b.WriteString(helpStyle.Render("enter write · esc cancel · ctrl+c quit")) + b.WriteString(helpStyle.Render("enter write · esc cancel · ctrl+c quit - .txt (raw, default) or .sh/.curl (curl command)")) return b.String() } if m.statusMsg != "" { |