diff options
| -rw-r--r-- | PLAN.md | 30 | ||||
| -rw-r--r-- | README.md | 21 | ||||
| -rw-r--r-- | cmd/mitmux/har.go | 233 | ||||
| -rw-r--r-- | cmd/mitmux/har_test.go | 143 | ||||
| -rw-r--r-- | cmd/mitmux/main.go | 105 |
5 files changed, 522 insertions, 10 deletions
@@ -253,11 +253,31 @@ truncated, matching the Detail view's own labels) so the exported file carries the same trust information the UI already shows, not a blanker claim. -Still open from the expanded "worth considering" list: bulk export -(HAR, for interop with other tools), copy-as-curl, and scope/target -filtering (to keep noise - trackers, CDNs, unrelated third-party -hosts - out of history and search). All requested explicitly; none -started yet. +Shipped since: bulk export. `E` from the history list exports the +current view (respecting an active search filter - explicitly the +filtered set, not always everything, unlike `X` clear-all which is +deliberately the opposite) as a HAR 1.2 file, chosen specifically for +interop: DevTools, Burp, Postman, and others can all import it, which a +mitmux-specific format couldn't do. Building it means parsing each +entry's raw request/response bytes back into structured HAR fields +(method, url, headers, status, body) via the same net/http parsing the +capture path and prettyResponse already use - reused, not +reimplemented. A binary body is base64-encoded (HAR's "encoding" field) +rather than passed through as a JSON string, which would silently +corrupt it: encoding/json replaces invalid UTF-8 with U+FFFD by +default, exactly the failure mode that would quietly corrupt an +exported image or protobuf body with no error anywhere. An entry that +fails to fetch (daemon round trip) or parse (a deliberately malformed +Repeater request, say) is skipped rather than aborting the whole +export - the status line reports how many, so a partial export is +visible, not silent. + +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), copy-as-curl, and scope/target filtering (to keep +noise - trackers, CDNs, unrelated third-party hosts - out of history +and search). All requested explicitly; none started yet. Skipped deliberately (from the research, matches this tool's stated scope): active/passive vulnerability scanning, plugin marketplace, @@ -158,6 +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 | | `d` | Decoder | | `/` | search | | `m` | match-and-replace rules | @@ -191,6 +192,26 @@ standard, URL-safe, padded, and unpadded variants in turn rather than requiring you to know which one you're looking at. Single-transform 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. +- `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. + +There's no import yet (see `PLAN.md`). + ### Search syntax Plain text searches headers and bodies on both sides of the exchange. diff --git a/cmd/mitmux/har.go b/cmd/mitmux/har.go new file mode 100644 index 0000000..250ba5e --- /dev/null +++ b/cmd/mitmux/har.go @@ -0,0 +1,233 @@ +package main + +import ( + "bufio" + "bytes" + "encoding/base64" + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "unicode/utf8" + + "mitmux/internal/ipc" +) + +// HAR (HTTP Archive) 1.2 - a JSON format understood by Chrome/Firefox +// DevTools, Burp, Postman, Wireshark, and others, chosen specifically +// for interop: getting captured traffic INTO other tools, which a +// mitmux-specific format couldn't do. Only the fields those tools +// actually read are populated; optional per-spec fields with no +// meaningful mitmux equivalent (page timings, HAR "pages", cache +// tracking) are omitted rather than filled with placeholder zeros. +type harDoc struct { + Log harLog `json:"log"` +} + +type harLog struct { + Version string `json:"version"` + Creator harCreator `json:"creator"` + Entries []harEntry `json:"entries"` +} + +type harCreator struct { + Name string `json:"name"` + Version string `json:"version"` +} + +type harHeader struct { + Name string `json:"name"` + Value string `json:"value"` +} + +type harPostData struct { + MimeType string `json:"mimeType"` + Text string `json:"text"` + // Encoding is "base64" for a non-UTF-8 body - HAR's "text" field is + // a JSON string, which can't hold arbitrary bytes; encoding/json + // would otherwise silently replace invalid UTF-8 with U+FFFD, + // corrupting exactly the binary bodies (images, protobufs, ...) + // where byte-exactness matters most. + Encoding string `json:"encoding,omitempty"` +} + +type harRequest struct { + Method string `json:"method"` + URL string `json:"url"` + HTTPVersion string `json:"httpVersion"` + Headers []harHeader `json:"headers"` + QueryString []harHeader `json:"queryString"` + Cookies []harHeader `json:"cookies"` + HeadersSize int `json:"headersSize"` + BodySize int `json:"bodySize"` + PostData *harPostData `json:"postData,omitempty"` +} + +type harContent struct { + Size int `json:"size"` + MimeType string `json:"mimeType"` + Text string `json:"text,omitempty"` + Encoding string `json:"encoding,omitempty"` +} + +type harResponse struct { + Status int `json:"status"` + StatusText string `json:"statusText"` + HTTPVersion string `json:"httpVersion"` + Headers []harHeader `json:"headers"` + Cookies []harHeader `json:"cookies"` + Content harContent `json:"content"` + RedirectURL string `json:"redirectURL"` + HeadersSize int `json:"headersSize"` + BodySize int `json:"bodySize"` +} + +type harTimings struct { + Send float64 `json:"send"` + Wait float64 `json:"wait"` + Receive float64 `json:"receive"` +} + +type harEntry struct { + StartedDateTime string `json:"startedDateTime"` + Time float64 `json:"time"` + Request harRequest `json:"request"` + Response harResponse `json:"response"` + Cache struct{} `json:"cache"` + Timings harTimings `json:"timings"` +} + +func harHeadersFrom(h http.Header) []harHeader { + out := make([]harHeader, 0, len(h)) + for k, vv := range h { + for _, v := range vv { + out = append(out, harHeader{Name: k, Value: v}) + } + } + return out +} + +func harQueryFrom(rawQuery string) []harHeader { + values, err := url.ParseQuery(rawQuery) + if err != nil { + return nil + } + out := make([]harHeader, 0, len(values)) + for k, vv := range values { + for _, v := range vv { + out = append(out, harHeader{Name: k, Value: v}) + } + } + return out +} + +// harBodyText returns body as HAR text content, base64-encoded (with +// encoding="base64") if it isn't valid UTF-8 - see harPostData.Encoding. +func harBodyText(body []byte) (text, encoding string) { + if len(body) == 0 { + return "", "" + } + if utf8.Valid(body) { + return string(body), "" + } + return base64.StdEncoding.EncodeToString(body), "base64" +} + +// harEntryFromDetail parses d's raw request/response bytes (the same +// net/http parsing prettyResponse and the proxy's own capture path +// already use) into one HAR entry. scheme is needed separately because +// an HTTP/1.1 request line only carries the origin-form path, not a +// full URL. +func harEntryFromDetail(d *ipc.EntryDetail) (harEntry, error) { + req, err := http.ReadRequest(bufio.NewReader(bytes.NewReader(d.RequestRaw))) + if err != nil { + return harEntry{}, fmt.Errorf("parse request: %w", err) + } + reqBody, _ := io.ReadAll(req.Body) + req.Body.Close() + + entry := harEntry{ + StartedDateTime: d.StartedAt.Format("2006-01-02T15:04:05.000Z07:00"), + Time: float64(d.Duration.Milliseconds()), + Request: harRequest{ + Method: req.Method, + URL: fmt.Sprintf("%s://%s%s", d.Scheme, d.Host, req.URL.RequestURI()), + HTTPVersion: req.Proto, + Headers: harHeadersFrom(req.Header), + QueryString: harQueryFrom(req.URL.RawQuery), + Cookies: []harHeader{}, + HeadersSize: -1, + BodySize: len(reqBody), + }, + Timings: harTimings{Send: 0, Wait: float64(d.Duration.Milliseconds()), Receive: 0}, + } + if len(reqBody) > 0 { + text, encoding := harBodyText(reqBody) + entry.Request.PostData = &harPostData{ + MimeType: req.Header.Get("Content-Type"), + Text: text, + Encoding: encoding, + } + } + + if len(d.ResponseRaw) == 0 { + // No response (dial/network error, or nothing came back) - HAR + // requires a response object, so fill in the minimum honest + // placeholder rather than omitting it or fabricating a status. + entry.Response = harResponse{HTTPVersion: "", Headers: []harHeader{}, Cookies: []harHeader{}, HeadersSize: -1, BodySize: -1} + return entry, nil + } + + resp, err := http.ReadResponse(bufio.NewReader(bytes.NewReader(d.ResponseRaw)), req) + if err != nil { + return harEntry{}, fmt.Errorf("parse response: %w", err) + } + respBody, _ := io.ReadAll(resp.Body) + resp.Body.Close() + + text, encoding := harBodyText(respBody) + entry.Response = harResponse{ + Status: resp.StatusCode, + StatusText: http.StatusText(resp.StatusCode), + HTTPVersion: resp.Proto, + Headers: harHeadersFrom(resp.Header), + Cookies: []harHeader{}, + Content: harContent{ + Size: len(respBody), + MimeType: resp.Header.Get("Content-Type"), + Text: text, + Encoding: encoding, + }, + HeadersSize: -1, + BodySize: len(respBody), + } + return entry, nil +} + +// harDocFrom builds a full HAR document from details, skipping (not +// failing on) any entry that fails to parse - one malformed capture +// (a deliberately broken Repeater request, say) shouldn't block +// exporting everything else. Returns the count skipped alongside the +// document so the caller can report it. +func harDocFrom(details []*ipc.EntryDetail) (harDoc, int) { + doc := harDoc{Log: harLog{ + Version: "1.2", + Creator: harCreator{Name: "mitmux", Version: "1"}, + Entries: make([]harEntry, 0, len(details)), + }} + skipped := 0 + for _, d := range details { + e, err := harEntryFromDetail(d) + if err != nil { + skipped++ + continue + } + doc.Log.Entries = append(doc.Log.Entries, e) + } + return doc, skipped +} + +func harMarshal(doc harDoc) ([]byte, error) { + return json.MarshalIndent(doc, "", " ") +} diff --git a/cmd/mitmux/har_test.go b/cmd/mitmux/har_test.go new file mode 100644 index 0000000..58bfb99 --- /dev/null +++ b/cmd/mitmux/har_test.go @@ -0,0 +1,143 @@ +package main + +import ( + "encoding/base64" + "encoding/json" + "testing" + "time" + + "mitmux/internal/ipc" + "mitmux/internal/store" +) + +func TestHarEntryFromDetailBasic(t *testing.T) { + d := &ipc.EntryDetail{ + Summary: store.Summary{ + ID: 1, Method: "GET", Scheme: "https", Host: "example.com", Path: "/a", + StatusCode: 200, StartedAt: time.Date(2026, 1, 2, 3, 4, 5, 0, time.UTC), Duration: 250 * time.Millisecond, + }, + RequestRaw: []byte("GET /a?x=1 HTTP/1.1\r\nHost: example.com\r\nX-Foo: bar\r\n\r\n"), + ResponseRaw: []byte("HTTP/1.1 200 OK\r\nContent-Type: text/plain\r\nContent-Length: 5\r\n\r\nhello"), + } + e, err := harEntryFromDetail(d) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if e.Request.Method != "GET" { + t.Errorf("method = %q, want GET", e.Request.Method) + } + if e.Request.URL != "https://example.com/a?x=1" { + t.Errorf("url = %q, want https://example.com/a?x=1", e.Request.URL) + } + if len(e.Request.QueryString) != 1 || e.Request.QueryString[0].Name != "x" || e.Request.QueryString[0].Value != "1" { + t.Errorf("queryString = %+v, want [{x 1}]", e.Request.QueryString) + } + foundHeader := false + for _, h := range e.Request.Headers { + if h.Name == "X-Foo" && h.Value == "bar" { + foundHeader = true + } + } + if !foundHeader { + t.Errorf("expected X-Foo: bar header, got %+v", e.Request.Headers) + } + if e.Response.Status != 200 { + t.Errorf("status = %d, want 200", e.Response.Status) + } + if e.Response.Content.Text != "hello" { + t.Errorf("response text = %q, want hello", e.Response.Content.Text) + } + if e.Response.Content.Encoding != "" { + t.Errorf("expected no encoding for plain text body, got %q", e.Response.Content.Encoding) + } +} + +func TestHarEntryFromDetailBinaryBodyBase64(t *testing.T) { + binBody := []byte{0xff, 0xfe, 0x00, 0x01, 0x02} + raw := append([]byte("HTTP/1.1 200 OK\r\nContent-Type: application/octet-stream\r\nContent-Length: 5\r\n\r\n"), binBody...) + d := &ipc.EntryDetail{ + Summary: store.Summary{Method: "GET", Scheme: "http", Host: "x", Path: "/"}, + RequestRaw: []byte("GET / HTTP/1.1\r\nHost: x\r\n\r\n"), + ResponseRaw: raw, + } + e, err := harEntryFromDetail(d) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if e.Response.Content.Encoding != "base64" { + t.Fatalf("expected base64 encoding for binary body, got %q", e.Response.Content.Encoding) + } + decoded, err := base64.StdEncoding.DecodeString(e.Response.Content.Text) + if err != nil { + t.Fatalf("failed to decode base64 text: %v", err) + } + if string(decoded) != string(binBody) { + t.Errorf("decoded body = %v, want %v", decoded, binBody) + } +} + +func TestHarEntryFromDetailNoResponse(t *testing.T) { + d := &ipc.EntryDetail{ + Summary: store.Summary{Method: "GET", Scheme: "http", Host: "x", Path: "/", Error: "dial: connection refused"}, + RequestRaw: []byte("GET / HTTP/1.1\r\nHost: x\r\n\r\n"), + } + e, err := harEntryFromDetail(d) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if e.Response.Status != 0 { + t.Errorf("expected status 0 for no response, got %d", e.Response.Status) + } +} + +func TestHarEntryFromDetailMalformedRequest(t *testing.T) { + d := &ipc.EntryDetail{ + Summary: store.Summary{Method: "GET", Scheme: "http", Host: "x", Path: "/"}, + RequestRaw: []byte("not a valid http request at all"), + } + if _, err := harEntryFromDetail(d); err == nil { + t.Error("expected an error for a malformed request, got nil") + } +} + +func TestHarDocFromSkipsMalformedEntries(t *testing.T) { + good := &ipc.EntryDetail{ + Summary: store.Summary{Method: "GET", Scheme: "http", Host: "x", Path: "/"}, + RequestRaw: []byte("GET / HTTP/1.1\r\nHost: x\r\n\r\n"), + ResponseRaw: []byte("HTTP/1.1 200 OK\r\nContent-Length: 0\r\n\r\n"), + } + bad := &ipc.EntryDetail{ + Summary: store.Summary{Method: "GET", Scheme: "http", Host: "x", Path: "/"}, + RequestRaw: []byte("garbage"), + } + doc, skipped := harDocFrom([]*ipc.EntryDetail{good, bad}) + if skipped != 1 { + t.Errorf("skipped = %d, want 1", skipped) + } + if len(doc.Log.Entries) != 1 { + t.Errorf("entries = %d, want 1", len(doc.Log.Entries)) + } + if doc.Log.Version != "1.2" { + t.Errorf("version = %q, want 1.2", doc.Log.Version) + } +} + +func TestHarMarshalProducesValidJSON(t *testing.T) { + d := &ipc.EntryDetail{ + Summary: store.Summary{Method: "GET", Scheme: "http", Host: "x", Path: "/"}, + RequestRaw: []byte("GET / HTTP/1.1\r\nHost: x\r\n\r\n"), + ResponseRaw: []byte("HTTP/1.1 200 OK\r\nContent-Length: 0\r\n\r\n"), + } + doc, _ := harDocFrom([]*ipc.EntryDetail{d}) + data, err := harMarshal(doc) + if err != nil { + t.Fatalf("harMarshal: %v", err) + } + var out map[string]any + if err := json.Unmarshal(data, &out); err != nil { + t.Fatalf("output is not valid JSON: %v", err) + } + if _, ok := out["log"]; !ok { + t.Errorf("expected top-level \"log\" key, got %v", out) + } +} diff --git a/cmd/mitmux/main.go b/cmd/mitmux/main.go index d4811dc..467f0d5 100644 --- a/cmd/mitmux/main.go +++ b/cmd/mitmux/main.go @@ -213,10 +213,12 @@ type model struct { // exportEditing mirrors the grep-match/extract edit-buffer pattern: // a modal textinput overlay for the destination path, enter writes - // and confirms, esc cancels. Detail-view only (like pretty-print) - - // exporting needs the full EntryDetail with raw bytes, which is - // already loaded there. + // and confirms, esc cancels. Used from two places sharing the same + // buffer/state: Detail view (single entry, plain text - exportBulk + // false) and the history list ('E', the currently visible/filtered + // entries as one HAR file - exportBulk true). exportEditing bool + exportBulk bool exportInput textinput.Model statusMsg string @@ -393,6 +395,47 @@ func (m *model) clearHistory() tea.Cmd { } } +type harExportedMsg struct { + path string + count int + skipped int + err error +} + +// exportHAR fetches the full detail (raw bytes included - entries only +// carries Summary metadata) for every given entry and writes them all +// as one HAR file. Runs as a single tea.Cmd - the sequential Get calls +// happen in the goroutine bubbletea already runs commands in, so this +// doesn't block the UI even though it's a series of blocking round +// trips, one per entry. A failed fetch for one entry is skipped rather +// than aborting the whole export, same reasoning as harDocFrom skipping +// an entry that fails to parse: one bad entry shouldn't cost you every +// other one. +func (m *model) exportHAR(entries []store.Summary, path string) tea.Cmd { + client := m.client + return func() tea.Msg { + details := make([]*ipc.EntryDetail, 0, len(entries)) + fetchFailed := 0 + for _, e := range entries { + d, err := client.Get(e.ID) + if err != nil { + fetchFailed++ + continue + } + details = append(details, d) + } + doc, parseFailed := harDocFrom(details) + data, err := harMarshal(doc) + if err != nil { + return harExportedMsg{err: err} + } + if err := writeExportFile(path, string(data)); err != nil { + return harExportedMsg{err: err} + } + return harExportedMsg{path: path, count: len(doc.Log.Entries), skipped: fetchFailed + parseFailed} + } +} + func (m *model) loadList() tea.Msg { var entries []store.Summary var err error @@ -810,6 +853,17 @@ 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: + if msg.err != nil { + m.statusMsg = "export error: " + msg.err.Error() + return m, nil + } + m.statusMsg = fmt.Sprintf("exported %d entries to %s", msg.count, msg.path) + if msg.skipped > 0 { + m.statusMsg += fmt.Sprintf(" (%d skipped - failed to fetch or parse)", msg.skipped) + } + return m, nil + case detailLoadedMsg: if msg.err != nil { m.statusMsg = "get error: " + msg.err.Error() @@ -921,6 +975,29 @@ func (m *model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.statusMsg = "cancelled" return m, nil } + if m.exportEditing { + switch msg.String() { + case "enter": + path := m.exportInput.Value() + entries := m.entries + m.exportEditing = false + m.exportInput.Blur() + if len(entries) == 0 { + return m, nil + } + m.statusMsg = "exporting..." + return m, m.exportHAR(entries, path) + case "esc": + m.exportEditing = false + m.exportInput.Blur() + return m, nil + case "ctrl+c": + return m, tea.Quit + } + var cmd tea.Cmd + m.exportInput, cmd = m.exportInput.Update(msg) + return m, cmd + } if m.searching { switch msg.String() { case "enter": @@ -996,6 +1073,15 @@ func (m *model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.confirmPrompt = fmt.Sprintf("clear %s history entries (not just this view)? y/n", count) m.confirmYes = func() tea.Cmd { return m.clearHistory() } return m, nil + case "E": + if len(m.entries) == 0 { + return m, nil + } + m.exportEditing = true + m.exportBulk = true + m.exportInput.SetValue("mitmux-export.har") + m.exportInput.CursorEnd() + return m, m.exportInput.Focus() case "d": m.mode = viewDecoder m.statusMsg = "" @@ -1073,6 +1159,7 @@ func (m *model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { case "e": if m.detail != nil { m.exportEditing = true + m.exportBulk = false m.exportInput.SetValue(fmt.Sprintf("mitmux-entry-%d.txt", m.detail.ID)) m.exportInput.CursorEnd() return m, m.exportInput.Focus() @@ -1554,6 +1641,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", "d decoder (URL/Base64/Hex/HTML encode/decode)", "/ search: plain text, host:value, AND/OR/NOT,", " status:404 / status:4xx / status:>=400,", @@ -1637,6 +1725,13 @@ 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(m.exportInput.View()) + b.WriteString("\n") + b.WriteString(helpStyle.Render("enter write · esc cancel · ctrl+c quit")) + return b.String() + } if m.confirmPrompt != "" { b.WriteString(statusStyle.Render(m.confirmPrompt)) b.WriteString("\n") @@ -1644,9 +1739,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 · / search · m rules · ? help · q quit" + help := "enter view · r/i/c/d tools · f flag · x delete · X clear all · E export HAR · / search · m rules · ? help · q quit" if m.query != "" { - help = "enter view · r/i/c/d tools · f flag · x delete · X clear all · / search · esc clear filter · ? help · q quit" + help = "enter view · r/i/c/d tools · f flag · x delete · X clear all · E export HAR (this filter) · / search · esc clear filter · ? help · q quit" } b.WriteString(helpStyle.Render(help)) return b.String() |