srdusr
aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorsrdusr <[email protected]>2026-02-16 11:09:00 +0200
committersrdusr <[email protected]>2026-02-16 11:09:00 +0200
commitb33c1e5cc7086da46b36122aa5645ccee26be69f (patch)
tree1a22f5e649c60231d6ea21ae3345349f8ba8dfac
parent157dd0a91badb7eabb3e670b9656f8471dfc9eb6 (diff)
downloadmitmux-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.
-rw-r--r--cmd/mitmux/main.go5
-rw-r--r--internal/store/store.go114
-rw-r--r--internal/store/store_test.go95
3 files changed, 197 insertions, 17 deletions
diff --git a/cmd/mitmux/main.go b/cmd/mitmux/main.go
index 360b68d..aa659cd 100644
--- a/cmd/mitmux/main.go
+++ b/cmd/mitmux/main.go
@@ -194,7 +194,7 @@ func newModel(client *ipc.Client, subCh <-chan store.Summary, socketPath string)
si := textinput.New()
si.Prompt = "/"
- si.Placeholder = "search - plain text, or host:example.com / AND / OR / NOT"
+ si.Placeholder = "search - plain text, host:x, status:404 / status:4xx / status:>=400, source:repeater"
rulesCols := []table.Column{
{Title: "On", Width: 3},
@@ -1022,7 +1022,8 @@ func (m *model) helpView() string {
"enter view request/response detail",
"r open in Repeater",
"i open in Intruder",
- "/ search (plain text, host:value, AND/OR/NOT)",
+ "/ search: plain text, host:value, AND/OR/NOT,",
+ " status:404 / status:4xx / status:>=400, source:repeater",
"esc clear active search filter",
"m match-and-replace rules",
"q quit",
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)
+ }
+ })
+ }
+}