diff options
| author | srdusr <[email protected]> | 2026-02-17 19:18:00 +0200 |
|---|---|---|
| committer | srdusr <[email protected]> | 2026-02-17 19:18:00 +0200 |
| commit | b8e5d5ea37cc64ffa05352c3fb3130ca59471935 (patch) | |
| tree | 18357a1a92214dec735b7422a66d9293fa64b565 | |
| parent | b33c1e5cc7086da46b36122aa5645ccee26be69f (diff) | |
| download | mitmux-b8e5d5ea37cc64ffa05352c3fb3130ca59471935.tar.gz mitmux-b8e5d5ea37cc64ffa05352c3fb3130ca59471935.zip | |
Flagged marker for history entries
Last of the "should build soon" items from the Burp/ZAP/Caido gap
research - Burp's row highlighting and Caido's Findings both serve
the same real workflow: mark something interesting mid-engagement,
revisit later. Scoped to a boolean flag (★) rather than full free-text
notes/comments, which would need their own text-input overlay for
comparatively modest extra value over a simple marker - tracked as a
real follow-up in PLAN.md, not dropped silently.
internal/store: history gains a flagged column (migrated in for
existing databases the same way source was) plus Store.SetFlagged and
Summary/Entry.Flagged. Search's structured-filter layer (added last
commit for status:/source:) gains flagged:true/false alongside them -
extractStructured already existed for exactly this kind of "pull it out
before it reaches FTS5" filter. internal/ipc gains a "set_flagged"
request. cmd/mitmux: 'f' toggles the flag on the selected history row
(applied optimistically to local state, persisted async - a drift
between local and server state on failure is an acceptable trade-off
for a marker this low-stakes), shown as a ★ column in the list and in
the detail view's title.
store_test.go covers the flagged: parsing (true/false spellings, and
a "looks like it but isn't" case - flagged:maybe - falling through as
literal search text, matching the existing pattern for status:).
Verified live: toggling 'f' shows the star immediately, flagged:true
correctly filtered to just that entry, and a direct SQLite check
confirmed the flag actually persisted to the database (flagged=1),
not just reflected in local UI state.
| -rw-r--r-- | PLAN.md | 28 | ||||
| -rw-r--r-- | cmd/mitmux/main.go | 52 | ||||
| -rw-r--r-- | internal/ipc/ipc.go | 24 | ||||
| -rw-r--r-- | internal/ipc/server.go | 9 | ||||
| -rw-r--r-- | internal/store/store.go | 54 | ||||
| -rw-r--r-- | internal/store/store_test.go | 27 |
6 files changed, 176 insertions, 18 deletions
@@ -74,3 +74,31 @@ hudsucker) - same problem, worth studying even though this build is Go. run in a loop with generated bytes - and results land in the same history table tagged source="intruder", same as Repeater's source="repeater", rather than a separate results store. + +## Post-build-order: Burp/ZAP/Caido parity pass + +Build order 1-7 is done. Researched what those three actually offer +(features and basic UI/UX) and triaged the gap into "should build soon" +/ "worth considering" / "skip" - see commit history for the full list; +tracking what's shipped vs. deferred here. + +Shipped: vi-modal editing for the raw request textareas (table and +viewport already had vi nav by default - this was specifically about +textarea/textinput, which don't); a persistent status bar and a '?' +keybinding reference; display-only response JSON pretty-printing; +structured search filters (status:, source:, flagged:) alongside the +existing FTS5 text search; a flagged marker (★) for "revisit this" - +deliberately simpler than full free-text notes/comments, which would +need their own text-input overlay for comparatively modest extra value +over a boolean; noted as a real follow-up, not dropped silently. + +Still open from "worth considering": a Comparer (diff) tool, a +standalone encoder/decoder utility, multiple concurrent Repeater tabs, +Intruder payload processing (encoding/case rules) and grep-match/ +grep-extract on results, CA install UX per OS, multiple proxy listeners +and upstream proxy chaining. None of these are started yet. + +Skipped deliberately (from the research, matches this tool's stated +scope): active/passive vulnerability scanning, plugin marketplace, +Collaborator/OAST, team collaboration, CI integration, client TLS +(mutual-TLS) certs, invisible/non-proxy-aware proxying. diff --git a/cmd/mitmux/main.go b/cmd/mitmux/main.go index aa659cd..1f0177c 100644 --- a/cmd/mitmux/main.go +++ b/cmd/mitmux/main.go @@ -171,10 +171,11 @@ type model struct { func newModel(client *ipc.Client, subCh <-chan store.Summary, socketPath string) *model { columns := []table.Column{ + {Title: "", Width: 1}, {Title: "ID", Width: 5}, {Title: "Method", Width: 7}, - {Title: "Host", Width: 28}, - {Title: "Path", Width: 32}, + {Title: "Host", Width: 27}, + {Title: "Path", Width: 31}, {Title: "Status", Width: 6}, {Title: "Size", Width: 10}, {Title: "Time", Width: 8}, @@ -194,7 +195,7 @@ func newModel(client *ipc.Client, subCh <-chan store.Summary, socketPath string) si := textinput.New() si.Prompt = "/" - si.Placeholder = "search - plain text, host:x, status:404 / status:4xx / status:>=400, source:repeater" + si.Placeholder = "search - plain text, host:x, status:404/4xx/>=400, source:repeater, flagged:true" rulesCols := []table.Column{ {Title: "On", Width: 3}, @@ -286,6 +287,20 @@ func (m *model) loadStatus() tea.Msg { return statusLoadedMsg{status: s, err: err} } +type flagSetMsg struct { + err error +} + +// setFlagged persists a flag toggle the caller has already applied +// optimistically to local state. On failure, just surfaces the error - +// the local/server state can drift until the next list reload, which +// is an acceptable trade-off for a marker this low-stakes. +func (m *model) setFlagged(id int64, flagged bool) tea.Cmd { + return func() tea.Msg { + return flagSetMsg{err: m.client.SetFlagged(id, flagged)} + } +} + func (m *model) loadList() tea.Msg { var entries []store.Summary var err error @@ -563,6 +578,12 @@ func (m *model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } return m, nil + case flagSetMsg: + if msg.err != nil { + m.statusMsg = "flag error: " + msg.err.Error() + } + return m, nil + case detailLoadedMsg: if msg.err != nil { m.statusMsg = "get error: " + msg.err.Error() @@ -681,6 +702,12 @@ func (m *model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.statusMsg = "" return m, m.loadDetail(m.entries[row].ID, "intruder") } + case "f": + if row := m.table.Cursor(); row >= 0 && row < len(m.entries) { + m.entries[row].Flagged = !m.entries[row].Flagged + m.table.SetRows(rowsFor(m.entries)) + return m, m.setFlagged(m.entries[row].ID, m.entries[row].Flagged) + } case "/": m.searching = true m.searchInput.SetValue(m.query) @@ -1022,8 +1049,10 @@ func (m *model) helpView() string { "enter view request/response detail", "r open in Repeater", "i open in Intruder", + "f toggle flag (★ mark this, revisit later)", "/ search: plain text, host:value, AND/OR/NOT,", - " status:404 / status:4xx / status:>=400, source:repeater", + " status:404 / status:4xx / status:>=400,", + " source:repeater, flagged:true", "esc clear active search filter", "m match-and-replace rules", "q quit", @@ -1086,9 +1115,9 @@ func (m *model) listView() string { b.WriteString(statusStyle.Render(m.statusMsg)) b.WriteString("\n") } - help := "↑/↓ navigate · enter view · r repeater · i intruder · / search · m rules · q quit" + help := "↑/↓ navigate · enter view · r repeater · i intruder · f flag · / search · m rules · q quit" if m.query != "" { - help = "↑/↓ navigate · enter view · r repeater · i intruder · / search · m rules · esc clear filter · q quit" + help = "↑/↓ navigate · enter view · r repeater · i intruder · f flag · / search · m rules · esc clear filter · q quit" } b.WriteString(helpStyle.Render(help)) return b.String() @@ -1100,7 +1129,11 @@ func (m *model) detailView() string { b.WriteString("loading...\n") return b.String() } - title := fmt.Sprintf(" #%d %s %s%s -> %d ", m.detail.ID, m.detail.Method, m.detail.Host, m.detail.Path, m.detail.StatusCode) + flagMark := "" + if m.detail.Flagged { + flagMark = "★ " + } + title := fmt.Sprintf(" %s#%d %s %s%s -> %d ", flagMark, m.detail.ID, m.detail.Method, m.detail.Host, m.detail.Path, m.detail.StatusCode) b.WriteString(titleStyle.Render(title)) b.WriteString("\n") @@ -1344,8 +1377,13 @@ func rowsFor(entries []store.Summary) []table.Row { if e.StatusCode == 0 { status = "ERR" } + flag := "" + if e.Flagged { + flag = "★" + } size := fmt.Sprintf("%s/%s", humanBytes(e.ReqSize), humanBytes(e.RespSize)) rows[i] = table.Row{ + flag, fmt.Sprintf("%d", e.ID), e.Method, e.Host, diff --git a/internal/ipc/ipc.go b/internal/ipc/ipc.go index d295238..7d81473 100644 --- a/internal/ipc/ipc.go +++ b/internal/ipc/ipc.go @@ -44,11 +44,14 @@ type Request struct { Rule *rules.Rule `json:"rule,omitempty"` RuleID int64 `json:"rule_id,omitempty"` RuleEnabled bool `json:"rule_enabled,omitempty"` + + // For "set_flagged": ID identifies the history entry. + Flagged bool `json:"flagged,omitempty"` } // Response is sent by the daemon to a client. type Response struct { - Type string `json:"type"` // "list", "get", "new", "repeat", "rules", "intrude_result", "intrude_done", "status", or "error" + Type string `json:"type"` // "list", "get", "new", "repeat", "rules", "intrude_result", "intrude_done", "status", "flagged", or "error" Entries []store.Summary `json:"entries,omitempty"` // for "list" Detail *EntryDetail `json:"detail,omitempty"` // for "get" and "repeat" New *store.Summary `json:"new,omitempty"` // for "new" (subscribe push) @@ -117,6 +120,25 @@ func (c *Client) Close() error { return c.conn.Close() } +// SetFlagged sets the flagged marker on a history entry - a simple +// "mark this, revisit later" bit, filterable via flagged:true/false in +// Search. +func (c *Client) SetFlagged(id int64, flagged bool) error { + c.mu.Lock() + defer c.mu.Unlock() + if err := c.enc.Encode(Request{Type: "set_flagged", ID: id, Flagged: flagged}); err != nil { + return err + } + var resp Response + if err := c.dec.Decode(&resp); err != nil { + return err + } + if resp.Type == "error" { + return errors.New(resp.Error) + } + return nil +} + // Status returns basic daemon info for a status bar. func (c *Client) Status() (*StatusMsg, error) { c.mu.Lock() diff --git a/internal/ipc/server.go b/internal/ipc/server.go index eea0ebb..28279a7 100644 --- a/internal/ipc/server.go +++ b/internal/ipc/server.go @@ -178,6 +178,13 @@ func (s *Server) handleConn(conn net.Conn) { } enc.Encode(Response{Type: "intrude_done"}) + case "set_flagged": + if err := s.db.SetFlagged(req.ID, req.Flagged); err != nil { + enc.Encode(Response{Type: "error", Error: err.Error()}) + continue + } + enc.Encode(Response{Type: "flagged"}) + case "rules_list": rs, err := s.db.ListRules() if err != nil { @@ -259,7 +266,7 @@ func detailFromEntry(e *store.Entry) *EntryDetail { ID: e.ID, StartedAt: e.StartedAt, Duration: e.Duration, Method: e.Method, Scheme: e.Scheme, Host: e.Host, Path: e.Path, StatusCode: e.StatusCode, ReqSize: len(e.RequestRaw), RespSize: len(e.ResponseRaw), - Error: e.Error, Source: e.Source, + Error: e.Error, Source: e.Source, Flagged: e.Flagged, }, RequestRaw: e.RequestRaw, ResponseRaw: e.ResponseRaw, RequestExact: e.RequestExact, ResponseExact: e.ResponseExact, diff --git a/internal/store/store.go b/internal/store/store.go index d04887b..4de4fa1 100644 --- a/internal/store/store.go +++ b/internal/store/store.go @@ -33,7 +33,8 @@ CREATE TABLE IF NOT EXISTS history ( request_exact INTEGER NOT NULL, response_exact INTEGER NOT NULL, error TEXT NOT NULL DEFAULT '', - source TEXT NOT NULL DEFAULT 'proxy' + source TEXT NOT NULL DEFAULT 'proxy', + flagged INTEGER NOT NULL DEFAULT 0 ); CREATE VIRTUAL TABLE IF NOT EXISTS history_fts USING fts5( @@ -87,6 +88,7 @@ func Open(path string) (*Store, error) { // Added after the initial schema; ignore the "duplicate column" error // on databases that already have it. db.Exec("ALTER TABLE history ADD COLUMN source TEXT NOT NULL DEFAULT 'proxy'") + db.Exec("ALTER TABLE history ADD COLUMN flagged INTEGER NOT NULL DEFAULT 0") // Backfill history_fts for rows inserted before it existed. A no-op // once caught up, since every Insert keeps both tables in sync. if _, err := db.Exec(` @@ -121,6 +123,7 @@ type Entry struct { ResponseExact bool Error string // network/transport error, if the request never got a response Source string // "proxy" or "repeater" + Flagged bool } // Summary is the lightweight metadata used for the history list view - @@ -138,6 +141,7 @@ type Summary struct { RespSize int Error string Source string + Flagged bool } // Insert stores e (and indexes it for search) and returns its assigned ID. @@ -207,7 +211,7 @@ func (s *Store) List(limit int, beforeID int64) ([]Summary, error) { } rows, err := s.db.Query( `SELECT id, started_at, duration_ms, method, scheme, host, path, - COALESCE(status_code, 0), length(request_raw), COALESCE(length(response_raw), 0), error, source + COALESCE(status_code, 0), length(request_raw), COALESCE(length(response_raw), 0), error, source, flagged FROM history WHERE id < ? ORDER BY id DESC LIMIT ?`, beforeID, limit, ) @@ -220,10 +224,12 @@ func (s *Store) List(limit int, beforeID int64) ([]Summary, error) { for rows.Next() { var sum Summary var startedAt, durationMs int64 + var flagged int if err := rows.Scan(&sum.ID, &startedAt, &durationMs, &sum.Method, &sum.Scheme, &sum.Host, &sum.Path, - &sum.StatusCode, &sum.ReqSize, &sum.RespSize, &sum.Error, &sum.Source); err != nil { + &sum.StatusCode, &sum.ReqSize, &sum.RespSize, &sum.Error, &sum.Source, &flagged); err != nil { return nil, fmt.Errorf("scan history row: %w", err) } + sum.Flagged = flagged != 0 sum.StartedAt = time.UnixMilli(startedAt) sum.Duration = time.Duration(durationMs) * time.Millisecond out = append(out, sum) @@ -267,6 +273,10 @@ func (s *Store) Search(query string, limit int, beforeID int64) ([]Summary, erro where = append(where, "h.source = ?") args = append(args, pred.source) } + if pred.flagged != nil { + where = append(where, "h.flagged = ?") + args = append(args, boolToInt(*pred.flagged)) + } var q string if remaining == "" { @@ -274,7 +284,7 @@ func (s *Store) Search(query string, limit int, beforeID int64) ([]Summary, erro // FTS5 join or ranking needed. q = `SELECT h.id, h.started_at, h.duration_ms, h.method, h.scheme, h.host, h.path, COALESCE(h.status_code, 0), length(h.request_raw), COALESCE(length(h.response_raw), 0), - h.error, h.source + h.error, h.source, h.flagged FROM history h WHERE ` + strings.Join(where, " AND ") + ` ORDER BY h.id DESC LIMIT ?` @@ -287,7 +297,7 @@ func (s *Store) Search(query string, limit int, beforeID int64) ([]Summary, erro args = append([]any{prepareFTSQuery(remaining)}, args...) q = `SELECT h.id, h.started_at, h.duration_ms, h.method, h.scheme, h.host, h.path, COALESCE(h.status_code, 0), length(h.request_raw), COALESCE(length(h.response_raw), 0), - h.error, h.source + h.error, h.source, h.flagged FROM history_fts JOIN history h ON h.id = history_fts.rowid WHERE ` + strings.Join(where, " AND ") + ` @@ -305,10 +315,12 @@ func (s *Store) Search(query string, limit int, beforeID int64) ([]Summary, erro for rows.Next() { var sum Summary var startedAt, durationMs int64 + var flagged int if err := rows.Scan(&sum.ID, &startedAt, &durationMs, &sum.Method, &sum.Scheme, &sum.Host, &sum.Path, - &sum.StatusCode, &sum.ReqSize, &sum.RespSize, &sum.Error, &sum.Source); err != nil { + &sum.StatusCode, &sum.ReqSize, &sum.RespSize, &sum.Error, &sum.Source, &flagged); err != nil { return nil, fmt.Errorf("scan search row: %w", err) } + sum.Flagged = flagged != 0 sum.StartedAt = time.UnixMilli(startedAt) sum.Duration = time.Duration(durationMs) * time.Millisecond out = append(out, sum) @@ -322,6 +334,7 @@ type structuredPredicate struct { statusSQL string statusArgs []any source string + flagged *bool } var ( @@ -358,6 +371,18 @@ func extractStructured(query string) (remaining string, pred structuredPredicate case strings.HasPrefix(lower, "source:"): pred.source = strings.ToLower(strings.TrimPrefix(f, "source:")) continue + case strings.HasPrefix(lower, "flagged:"): + val := strings.ToLower(strings.TrimPrefix(lower, "flagged:")) + switch val { + case "true", "1", "yes": + b := true + pred.flagged = &b + continue + case "false", "0", "no": + b := false + pred.flagged = &b + continue + } } kept = append(kept, f) } @@ -369,24 +394,35 @@ func (s *Store) Get(id int64) (*Entry, error) { row := s.db.QueryRow( `SELECT id, started_at, duration_ms, method, scheme, host, path, COALESCE(status_code, 0), request_raw, response_raw, - request_exact, response_exact, error, source + request_exact, response_exact, error, source, flagged FROM history WHERE id = ?`, id, ) var e Entry var startedAt, durationMs int64 - var reqExact, respExact int + var reqExact, respExact, flagged int if err := row.Scan(&e.ID, &startedAt, &durationMs, &e.Method, &e.Scheme, &e.Host, &e.Path, - &e.StatusCode, &e.RequestRaw, &e.ResponseRaw, &reqExact, &respExact, &e.Error, &e.Source); err != nil { + &e.StatusCode, &e.RequestRaw, &e.ResponseRaw, &reqExact, &respExact, &e.Error, &e.Source, &flagged); err != nil { return nil, fmt.Errorf("get history entry %d: %w", id, err) } e.StartedAt = time.UnixMilli(startedAt) e.Duration = time.Duration(durationMs) * time.Millisecond e.RequestExact = reqExact != 0 e.ResponseExact = respExact != 0 + e.Flagged = flagged != 0 return &e, nil } +// SetFlagged toggles the flagged marker on a history entry - a simple +// "mark this, revisit later" bit, filterable via flagged:true/false in +// Search. Doesn't touch anything else about the entry. +func (s *Store) SetFlagged(id int64, flagged bool) error { + if _, err := s.db.Exec(`UPDATE history SET flagged = ? WHERE id = ?`, boolToInt(flagged), id); err != nil { + return fmt.Errorf("set flagged on entry %d: %w", id, err) + } + return nil +} + // prepareFTSQuery makes free text safe to hand to FTS5's MATCH, whose // query grammar reserves a wide range of punctuation ('.', '-', '/', // '@', '(', ')', and more - confirmed empirically, not just from docs) diff --git a/internal/store/store_test.go b/internal/store/store_test.go index 7999ac3..7fa985a 100644 --- a/internal/store/store_test.go +++ b/internal/store/store_test.go @@ -3,6 +3,8 @@ package store import "testing" func TestExtractStructured(t *testing.T) { + boolPtr := func(b bool) *bool { return &b } + tests := []struct { name string query string @@ -10,6 +12,7 @@ func TestExtractStructured(t *testing.T) { statusSQL string statusArgs []any source string + flagged *bool }{ { name: "plain text only", @@ -17,6 +20,23 @@ func TestExtractStructured(t *testing.T) { remaining: "admin login", }, { + name: "flagged true", + query: "flagged:true", + remaining: "", + flagged: boolPtr(true), + }, + { + name: "flagged false", + query: "flagged:no", + remaining: "", + flagged: boolPtr(false), + }, + { + name: "not a real flagged token falls through as text", + query: "flagged:maybe", + remaining: "flagged:maybe", + }, + { name: "exact status", query: "status:404", remaining: "", @@ -90,6 +110,13 @@ func TestExtractStructured(t *testing.T) { if pred.source != tt.source { t.Errorf("source = %q, want %q", pred.source, tt.source) } + switch { + case pred.flagged == nil && tt.flagged == nil: + case pred.flagged == nil || tt.flagged == nil: + t.Errorf("flagged = %v, want %v", pred.flagged, tt.flagged) + case *pred.flagged != *tt.flagged: + t.Errorf("flagged = %v, want %v", *pred.flagged, *tt.flagged) + } }) } } |