srdusr
aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorsrdusr <[email protected]>2026-02-17 19:18:00 +0200
committersrdusr <[email protected]>2026-02-17 19:18:00 +0200
commitb8e5d5ea37cc64ffa05352c3fb3130ca59471935 (patch)
tree18357a1a92214dec735b7422a66d9293fa64b565
parentb33c1e5cc7086da46b36122aa5645ccee26be69f (diff)
downloadmitmux-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.md28
-rw-r--r--cmd/mitmux/main.go52
-rw-r--r--internal/ipc/ipc.go24
-rw-r--r--internal/ipc/server.go9
-rw-r--r--internal/store/store.go54
-rw-r--r--internal/store/store_test.go27
6 files changed, 176 insertions, 18 deletions
diff --git a/PLAN.md b/PLAN.md
index 64d1a0d..ca379e9 100644
--- a/PLAN.md
+++ b/PLAN.md
@@ -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)
+ }
})
}
}