srdusr
aboutsummaryrefslogtreecommitdiffstats
path: root/cmd
diff options
context:
space:
mode:
Diffstat (limited to 'cmd')
-rw-r--r--cmd/mitmux/csv.go73
-rw-r--r--cmd/mitmux/csv_test.go84
-rw-r--r--cmd/mitmux/export.go100
-rw-r--r--cmd/mitmux/export_test.go70
-rw-r--r--cmd/mitmux/main.go56
5 files changed, 370 insertions, 13 deletions
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 != "" {