diff options
| author | srdusr <[email protected]> | 2026-02-16 11:09:00 +0200 |
|---|---|---|
| committer | srdusr <[email protected]> | 2026-02-16 11:09:00 +0200 |
| commit | b33c1e5cc7086da46b36122aa5645ccee26be69f (patch) | |
| tree | 1a22f5e649c60231d6ea21ae3345349f8ba8dfac /internal/store | |
| parent | 157dd0a91badb7eabb3e670b9656f8471dfc9eb6 (diff) | |
| download | mitmux-b33c1e5cc7086da46b36122aa5645ccee26be69f.tar.gz mitmux-b33c1e5cc7086da46b36122aa5645ccee26be69f.zip | |
Structured search filters: status:, source:
Closes another top item from the Burp/ZAP/Caido gap research: status-
code and MIME/type filtering alongside free text is used constantly in
practice (Caido's HTTPQL, Burp's proxy history filter). Scoped to
status and source for now - method: already works today via FTS5's own
method column (a plain text match on "POST" is effectively exact for a
short alphanumeric token), so it didn't need special handling.
status_code isn't a text column FTS5 can index, and doesn't benefit
from full-text matching anyway (it's a numeric comparison, not a word
search), so extractStructured pulls status:/source: tokens out of the
query before it reaches FTS5 and turns them into real parameterized SQL
predicates against history's typed columns: status:404 (exact),
status:>=400 / status:!=200 (comparison operators), status:4xx (also
2xx/3xx/5xx - the shorthand people actually reach for: "show me the
errors"), source:repeater/intruder/proxy. Whatever text remains after
extraction still goes through the existing FTS5 path, so "admin
status:200" correctly ANDs a real full-text match with a real status
predicate in one query. When nothing remains (pure "status:4xx"),
Search skips the FTS5 join entirely and queries history directly.
store_test.go covers the parsing (exact/operator/range/source,
combined with free text, and two "looks like it but isn't" cases -
status:banana and the malformed 4-digit status:4004 - to confirm they
fall through as literal search text instead of being misparsed).
Verified live against real varied traffic (status 200/404/500 requests
plus a POST with an "admin" body) - status:4xx matched only the 404;
status:>=400 matched both 404 and 500; "admin status:200" correctly
matched only the POST and excluded the other unrelated 200; source:proxy
matched everything captured so far. All against the actual SQL execution
path, not just the pure parsing function.
Diffstat (limited to 'internal/store')
| -rw-r--r-- | internal/store/store.go | 114 | ||||
| -rw-r--r-- | internal/store/store_test.go | 95 |
2 files changed, 194 insertions, 15 deletions
diff --git a/internal/store/store.go b/internal/store/store.go index eb017db..d04887b 100644 --- a/internal/store/store.go +++ b/internal/store/store.go @@ -8,6 +8,8 @@ package store import ( "database/sql" "fmt" + "regexp" + "strconv" "strings" "time" @@ -238,6 +240,14 @@ func (s *Store) List(limit int, beforeID int64) ([]Summary, error) { // plain terms need help: FTS5's query grammar treats characters like // '.', '-', '/', '@' as syntax, so an unquoted domain name is a parse // error, not a search term, unless quoted first. +// +// Two more filters are recognized ahead of the FTS5 layer, since neither +// fits it - status_code isn't a text column FTS5 can index, and doesn't +// benefit from full-text matching (numeric comparison, not word search), +// and source is a plain low-cardinality column better matched exactly: +// status:404, status:>=400, status:!=200, status:4xx (also 2xx/3xx/5xx), +// and source:proxy / source:repeater / source:intruder. These combine +// with AND against any remaining free-text/FTS5 portion of the query. func (s *Store) Search(query string, limit int, beforeID int64) ([]Summary, error) { if limit <= 0 || limit > 1000 { limit = 200 @@ -245,21 +255,47 @@ func (s *Store) Search(query string, limit int, beforeID int64) ([]Summary, erro if beforeID <= 0 { beforeID = 1<<63 - 1 } - query = prepareFTSQuery(query) - // history_fts must appear unaliased for MATCH/bm25 to resolve against - // it - this SQLite build doesn't support querying FTS5 through a - // table alias (confirmed directly against the sqlite3 CLI: aliasing - // it raises "no such column"). - rows, err := s.db.Query( - `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 - FROM history_fts - JOIN history h ON h.id = history_fts.rowid - WHERE history_fts MATCH ? AND h.id < ? - ORDER BY bm25(history_fts), h.id DESC LIMIT ?`, - query, beforeID, limit, - ) + + remaining, pred := extractStructured(query) + where := []string{"h.id < ?"} + args := []any{beforeID} + if pred.statusSQL != "" { + where = append(where, pred.statusSQL) + args = append(args, pred.statusArgs...) + } + if pred.source != "" { + where = append(where, "h.source = ?") + args = append(args, pred.source) + } + + var q string + if remaining == "" { + // No free-text component left - query history directly, no + // 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 + FROM history h + WHERE ` + strings.Join(where, " AND ") + ` + ORDER BY h.id DESC LIMIT ?` + } else { + // history_fts must appear unaliased for MATCH/bm25 to resolve + // against it - this SQLite build doesn't support querying FTS5 + // through a table alias (confirmed directly against the sqlite3 + // CLI: aliasing it raises "no such column"). + where = append([]string{"history_fts MATCH ?"}, where...) + 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 + FROM history_fts + JOIN history h ON h.id = history_fts.rowid + WHERE ` + strings.Join(where, " AND ") + ` + ORDER BY bm25(history_fts), h.id DESC LIMIT ?` + } + args = append(args, limit) + + rows, err := s.db.Query(q, args...) if err != nil { return nil, fmt.Errorf("search history: %w", err) } @@ -280,6 +316,54 @@ func (s *Store) Search(query string, limit int, beforeID int64) ([]Summary, erro return out, rows.Err() } +// structuredPredicate holds filters extracted from a search query that +// get applied as real SQL predicates instead of going through FTS5. +type structuredPredicate struct { + statusSQL string + statusArgs []any + source string +} + +var ( + statusExactRe = regexp.MustCompile(`^(>=|<=|!=|>|<|=)?(\d{3})$`) + statusRangeRe = regexp.MustCompile(`^([2-5])xx$`) +) + +// extractStructured pulls status:/source: tokens out of query, returning +// what's left (for the FTS5 layer, if anything) and the predicates found. +func extractStructured(query string) (remaining string, pred structuredPredicate) { + fields := strings.Fields(query) + kept := fields[:0:0] + for _, f := range fields { + lower := strings.ToLower(f) + switch { + case strings.HasPrefix(lower, "status:"): + val := strings.ToLower(strings.TrimPrefix(f, "status:")) + if m := statusRangeRe.FindStringSubmatch(val); m != nil { + lo, _ := strconv.Atoi(m[1] + "00") + pred.statusSQL = "status_code >= ? AND status_code < ?" + pred.statusArgs = []any{lo, lo + 100} + continue + } + if m := statusExactRe.FindStringSubmatch(val); m != nil { + op := m[1] + if op == "" { + op = "=" + } + n, _ := strconv.Atoi(m[2]) + pred.statusSQL = "status_code " + op + " ?" + pred.statusArgs = []any{n} + continue + } + case strings.HasPrefix(lower, "source:"): + pred.source = strings.ToLower(strings.TrimPrefix(f, "source:")) + continue + } + kept = append(kept, f) + } + return strings.Join(kept, " "), pred +} + // Get returns the full entry (including raw bytes) for id. func (s *Store) Get(id int64) (*Entry, error) { row := s.db.QueryRow( diff --git a/internal/store/store_test.go b/internal/store/store_test.go new file mode 100644 index 0000000..7999ac3 --- /dev/null +++ b/internal/store/store_test.go @@ -0,0 +1,95 @@ +package store + +import "testing" + +func TestExtractStructured(t *testing.T) { + tests := []struct { + name string + query string + remaining string + statusSQL string + statusArgs []any + source string + }{ + { + name: "plain text only", + query: "admin login", + remaining: "admin login", + }, + { + name: "exact status", + query: "status:404", + remaining: "", + statusSQL: "status_code = ?", + statusArgs: []any{404}, + }, + { + name: "status with operator", + query: "status:>=400", + remaining: "", + statusSQL: "status_code >= ?", + statusArgs: []any{400}, + }, + { + name: "status not-equal", + query: "status:!=200", + remaining: "", + statusSQL: "status_code != ?", + statusArgs: []any{200}, + }, + { + name: "status range shorthand", + query: "status:4xx", + remaining: "", + statusSQL: "status_code >= ? AND status_code < ?", + statusArgs: []any{400, 500}, + }, + { + name: "source filter", + query: "source:repeater", + remaining: "", + source: "repeater", + }, + { + name: "combined with free text", + query: "admin status:>=400 source:proxy", + remaining: "admin", + statusSQL: "status_code >= ?", + statusArgs: []any{400}, + source: "proxy", + }, + { + name: "not a real status token falls through as text", + query: "status:banana", + remaining: "status:banana", + }, + { + name: "malformed 4-digit status falls through as text", + query: "status:4004", + remaining: "status:4004", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + remaining, pred := extractStructured(tt.query) + if remaining != tt.remaining { + t.Errorf("remaining = %q, want %q", remaining, tt.remaining) + } + if pred.statusSQL != tt.statusSQL { + t.Errorf("statusSQL = %q, want %q", pred.statusSQL, tt.statusSQL) + } + if len(pred.statusArgs) != len(tt.statusArgs) { + t.Fatalf("statusArgs = %v, want %v", pred.statusArgs, tt.statusArgs) + } + for i := range pred.statusArgs { + if pred.statusArgs[i] != tt.statusArgs[i] { + t.Errorf("statusArgs[%d] = %v, want %v", i, pred.statusArgs[i], tt.statusArgs[i]) + } + } + if pred.source != tt.source { + t.Errorf("source = %q, want %q", pred.source, tt.source) + } + }) + } +} |