diff options
| -rw-r--r-- | PLAN.md | 39 | ||||
| -rw-r--r-- | README.md | 23 | ||||
| -rw-r--r-- | cmd/mitmux/main.go | 223 | ||||
| -rw-r--r-- | internal/ipc/ipc.go | 73 | ||||
| -rw-r--r-- | internal/ipc/server.go | 43 | ||||
| -rw-r--r-- | internal/proxy/proxy.go | 13 | ||||
| -rw-r--r-- | internal/scope/scope.go | 61 | ||||
| -rw-r--r-- | internal/scope/scope_test.go | 96 | ||||
| -rw-r--r-- | internal/store/store.go | 59 |
9 files changed, 619 insertions, 11 deletions
@@ -272,12 +272,45 @@ Repeater request, say) is skipped rather than aborting the whole export - the status line reports how many, so a partial export is visible, not silent. +Shipped since: target scope. `s` from the history list opens scope +management - add/toggle/delete rules matching a host by substring +(case-insensitive, so "example.com" matches "www.example.com" and +"api.example.com" too, covering "this domain and its subdomains" +without a separate wildcard syntax) or by regex, mirroring the same +Match-text-or-regex toggle match-and-replace rules already use for one +consistent mental model. No rules configured (or none enabled) means +everything is recorded - today's behavior before scope existed at all, +unchanged, so a fresh install or a user who never opens the scope view +keeps recording everything rather than silently nothing. + +Scope only filters what gets recorded, not what gets proxied: an +out-of-scope request still reaches its destination and its response +still reaches the client completely normally (see `internal/proxy`'s +`forward()` - the response is already written to the client by the +time the scope check runs; skipping the record step only skips +storage). This was a deliberate choice over blocking out-of-scope +traffic outright, which would be a materially different, much riskier +feature - an access-control mechanism, not a noise filter, and a wrong +scope pattern could silently break the very traffic the user is trying +to test. Repeater and Intruder deliberately bypass the scope check +entirely (recordRaw, a separate code path from the passive-capture +record()): a user explicitly resending or fuzzing a specific request +wants to see the result regardless of scope, which exists to cut +passive-capture noise (CDNs, analytics, trackers, unrelated third-party +hosts), not to second-guess a deliberate action. Verified live: added a +substring scope rule for one host, confirmed a request to a +non-matching host still proxied successfully (200 response reached the +client) but was never recorded, confirmed the matching host's requests +were recorded, confirmed a Repeater resend of the excluded host WAS +recorded despite being out of scope, confirmed toggling the rule off +resumed recording everything, and confirmed both the substring and +regex pattern forms save and match correctly. + Still open from the expanded "worth considering" list: import (no path back in yet - HAR export was prioritized as the more common daily need, getting captured evidence OUT for a report or another tool, over -bringing traffic IN), copy-as-curl, and scope/target filtering (to keep -noise - trackers, CDNs, unrelated third-party hosts - out of history -and search). All requested explicitly; none started yet. +bringing traffic IN) and copy-as-curl. Both requested explicitly; not +started yet. Skipped deliberately (from the research, matches this tool's stated scope): active/passive vulnerability scanning, plugin marketplace, @@ -162,6 +162,7 @@ below is enough to get going. | `d` | Decoder | | `/` | search | | `m` | match-and-replace rules | +| `s` | target scope (what gets recorded) | | `q` | quit | ### Detail view @@ -307,6 +308,27 @@ text, not per-value substitution, so a rule can add or remove a header entirely, not just rewrite an existing one. Currently headers only - see `PLAN.md` for why body rules are a separate, harder problem. +### Scope + +Press `s` from the history view to manage what gets **recorded** to +history - not what gets proxied. Out-of-scope traffic still reaches its +destination and the response still reaches the client completely +normally; it's just not stored, so unrelated CDN/analytics/tracker +noise doesn't pollute history and search on a real engagement. No rules +(or none enabled) means everything is recorded, same as before scope +existed. + +`a` adds a rule (a pattern, and `tab` to toggle regex matching - same +Match-text-or-regex model as match-and-replace rules), `space` toggles +one on/off, `d` deletes the selected one. A non-regex pattern matches +by case-insensitive substring against the host - `example.com` matches +`example.com`, `www.example.com`, and `api.example.com` alike, covering +"this domain and its subdomains" without a separate wildcard syntax. + +Repeater and Intruder always record regardless of scope - a request you +deliberately resend or fuzz is something you clearly want to see the +result of, not noise scope exists to cut. + ## Architecture `mitmuxd` owns the proxy listener and the SQLite database; `mitmux` is @@ -336,6 +358,7 @@ cmd/mitmux/ TUI entrypoint internal/ca/ root CA + per-host leaf certificate generation internal/proxy/ proxy engine: HTTP/CONNECT handling, capture, Repeater, Intruder internal/rules/ match-and-replace engine +internal/scope/ target scope (what gets recorded) internal/store/ SQLite storage, FTS5 search internal/ipc/ daemon <-> client protocol (JSON over a Unix socket) ``` diff --git a/cmd/mitmux/main.go b/cmd/mitmux/main.go index 467f0d5..21209bf 100644 --- a/cmd/mitmux/main.go +++ b/cmd/mitmux/main.go @@ -21,6 +21,7 @@ import ( "mitmux/internal/ca" "mitmux/internal/ipc" "mitmux/internal/rules" + "mitmux/internal/scope" "mitmux/internal/store" ) @@ -76,6 +77,7 @@ const ( viewIntruder viewCompare viewDecoder + viewScope viewHelp ) @@ -160,6 +162,16 @@ type model struct { ruleRegex bool ruleField ruleField + // Target scope: which captured traffic gets recorded to history. + // Simpler than match-and-replace rules (one pattern + a regex + // toggle, no name/scope/replace fields), so add-only - no + // edit-in-place, delete and re-add covers changing a pattern. + scopeTable table.Model + scopeRows []scope.Rule + scopeForm bool + scopePattern textinput.Model + scopeIsRegex bool + intruderScheme string intruderHost string intruderTemplate viTextarea @@ -269,6 +281,17 @@ func newModel(client *ipc.Client, subCh <-chan store.Summary, socketPath string) replaceIn := textinput.New() replaceIn.Placeholder = "replacement" + scopeCols := []table.Column{ + {Title: "On", Width: 3}, + {Title: "Pattern", Width: 40}, + {Title: "Regex", Width: 5}, + } + scopeTbl := table.New(table.WithColumns(scopeCols), table.WithFocused(true)) + scopeTbl.SetStyles(st) + + scopePatternIn := textinput.New() + scopePatternIn.Placeholder = "host substring, or a regex - e.g. example.com" + itmpl := newViTextarea() itmpl.ta.Placeholder = "raw request bytes - wrap positions to fuzz in § markers, e.g. /users/§123§" itmpl.ta.ShowLineNumbers = false @@ -311,6 +334,8 @@ func newModel(client *ipc.Client, subCh <-chan store.Summary, socketPath string) repeaterIndex: -1, searchInput: si, rulesTable: rt, + scopeTable: scopeTbl, + scopePattern: scopePatternIn, ruleName: nameIn, ruleMatch: matchIn, ruleReplace: replaceIn, @@ -744,6 +769,64 @@ func (m *model) focusRuleField() { } } +type scopeRulesLoadedMsg struct { + rules []scope.Rule + err error +} + +type scopeRuleWriteDoneMsg struct { + action string // "added", "deleted", "toggled" - for the status line + err error +} + +func (m *model) loadScopeRules() tea.Msg { + rs, err := m.client.ListScopeRules() + return scopeRulesLoadedMsg{rules: rs, err: err} +} + +func (m *model) addScopeRule(r scope.Rule) tea.Cmd { + return func() tea.Msg { + _, err := m.client.AddScopeRule(r) + return scopeRuleWriteDoneMsg{action: "added", err: err} + } +} + +func (m *model) deleteSelectedScopeRule() tea.Cmd { + row := m.scopeTable.Cursor() + if row < 0 || row >= len(m.scopeRows) { + return nil + } + id := m.scopeRows[row].ID + return func() tea.Msg { + err := m.client.DeleteScopeRule(id) + return scopeRuleWriteDoneMsg{action: "deleted", err: err} + } +} + +func (m *model) toggleSelectedScopeRule() tea.Cmd { + row := m.scopeTable.Cursor() + if row < 0 || row >= len(m.scopeRows) { + return nil + } + r := m.scopeRows[row] + return func() tea.Msg { + err := m.client.SetScopeRuleEnabled(r.ID, !r.Enabled) + return scopeRuleWriteDoneMsg{action: "toggled", err: err} + } +} + +// enterScopeForm opens the (add-only) scope rule form. +func (m *model) enterScopeForm() { + m.scopeForm = true + m.scopePattern.SetValue("") + m.scopeIsRegex = false + m.scopePattern.Focus() +} + +func (m *model) scopeRuleFromForm() scope.Rule { + return scope.Rule{Enabled: true, Pattern: m.scopePattern.Value(), IsRegex: m.scopeIsRegex} +} + func (m *model) Init() tea.Cmd { return tea.Batch(m.loadList, m.waitForEntry, m.loadStatus) } @@ -779,6 +862,10 @@ func (m *model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.ruleMatch.Width = formWidth m.ruleReplace.Width = formWidth + m.scopeTable.SetWidth(msg.Width) + m.scopeTable.SetHeight(h - 5) + m.scopePattern.Width = formWidth + // h-9 rather than h-8: one extra line reserved for the payload // rules / grep-match-extract status line in intruderView. itmplHeight := (h - 9) / 3 @@ -937,6 +1024,24 @@ func (m *model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.statusMsg = "rule " + msg.action return m, m.loadRules + case scopeRulesLoadedMsg: + if msg.err != nil { + m.statusMsg = "scope error: " + msg.err.Error() + return m, nil + } + m.scopeRows = msg.rules + setTableRows(&m.scopeTable, scopeRowsFor(m.scopeRows)) + return m, nil + + case scopeRuleWriteDoneMsg: + if msg.err != nil { + m.statusMsg = "scope " + msg.action + " error: " + msg.err.Error() + return m, nil + } + m.scopeForm = false + m.statusMsg = "scope rule " + msg.action + return m, m.loadScopeRules + case intrudeStartedMsg: if msg.err != nil { m.intruderRunning = false @@ -1097,6 +1202,10 @@ func (m *model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.mode = viewRules m.statusMsg = "" return m, m.loadRules + case "s": + m.mode = viewScope + m.statusMsg = "" + return m, m.loadScopeRules case "esc": if m.query != "" { m.query = "" @@ -1344,6 +1453,49 @@ func (m *model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.rulesTable, cmd = m.rulesTable.Update(msg) return m, cmd + case viewScope: + if m.scopeForm { + switch msg.String() { + case "esc": + m.scopeForm = false + return m, nil + case "ctrl+c": + return m, tea.Quit + case "ctrl+s": + return m, m.addScopeRule(m.scopeRuleFromForm()) + case "tab": + m.scopeIsRegex = !m.scopeIsRegex + return m, nil + } + var cmd tea.Cmd + m.scopePattern, cmd = m.scopePattern.Update(msg) + return m, cmd + } + + switch msg.String() { + case "q", "esc": + m.mode = viewList + return m, nil + case "ctrl+c": + return m, tea.Quit + case "?": + m.prevMode = viewScope + m.mode = viewHelp + return m, nil + case "a": + m.enterScopeForm() + return m, nil + case "d": + m.statusMsg = "" + return m, m.deleteSelectedScopeRule() + case " ": + m.statusMsg = "" + return m, m.toggleSelectedScopeRule() + } + var cmd tea.Cmd + m.scopeTable, cmd = m.scopeTable.Update(msg) + return m, cmd + case viewIntruder: // Editing a grep pattern is a modal overlay on top of the // normal template/payloads/results panes, same pattern as @@ -1584,6 +1736,12 @@ func (m *model) View() string { } else { body = m.rulesView() } + case viewScope: + if m.scopeForm { + body = m.scopeFormView() + } else { + body = m.scopeView() + } case viewIntruder: body = m.intruderView() case viewCompare: @@ -1610,6 +1768,7 @@ func (m *model) statusBar() string { view := map[viewMode]string{ viewList: "history", viewDetail: "detail", viewRepeater: "repeater", viewRules: "rules", viewIntruder: "intruder", viewCompare: "comparer", viewDecoder: "decoder", + viewScope: "scope", }[m.mode] return statusBarStyle.Render(fmt.Sprintf(" mitmux · proxy %s%s · %s ", proxy, count, view)) } @@ -1648,6 +1807,7 @@ func (m *model) helpView() string { " source:repeater, flagged:true", "esc clear active search filter", "m match-and-replace rules", + "s target scope (what gets recorded)", "q quit", ) section("Detail view", @@ -1691,6 +1851,15 @@ func (m *model) helpView() string { "tab/shift+tab move between form fields ctrl+s save form", "◀▶ change scope/regex toggle esc cancel/back", ) + section("Scope", + "What gets recorded to history - out-of-scope traffic still", + "proxies normally, it's just not stored. No rules means", + "everything is recorded (today's default, unchanged).", + "a add rule (pattern + regex toggle)", + "d delete selected space toggle enabled", + "tab toggle regex (in the add form)", + "ctrl+s save form esc cancel/back", + ) b.WriteString(helpStyle.Render("press any key to go back")) return b.String() @@ -1739,9 +1908,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 · ? help · q quit" + 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" 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 · ? help · q quit" + 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" } b.WriteString(helpStyle.Render(help)) return b.String() @@ -1909,6 +2078,40 @@ func (m *model) ruleFormView() string { return b.String() } +func (m *model) scopeView() string { + var b strings.Builder + title := fmt.Sprintf(" scope (%d) - no rules means everything is recorded ", len(m.scopeRows)) + b.WriteString(titleStyle.Render(title)) + b.WriteString("\n") + b.WriteString(m.scopeTable.View()) + b.WriteString("\n") + if m.statusMsg != "" { + b.WriteString(statusStyle.Render(sanitizeLine(m.statusMsg))) + b.WriteString("\n") + } + b.WriteString(helpStyle.Render("a add · d delete · space toggle · esc back · q quit")) + return b.String() +} + +func (m *model) scopeFormView() string { + var b strings.Builder + b.WriteString(titleStyle.Render(" add scope rule ")) + b.WriteString("\n\n") + b.WriteString(tabActive.Render("Pattern (substring match, or a regex against the host)") + "\n") + b.WriteString(m.scopePattern.View() + "\n\n") + regexText := "Regex: off (tab to toggle)" + if m.scopeIsRegex { + regexText = "Regex: on (tab to toggle)" + } + b.WriteString(helpStyle.Render(regexText) + "\n\n") + if m.statusMsg != "" { + b.WriteString(statusStyle.Render(sanitizeLine(m.statusMsg))) + b.WriteString("\n") + } + b.WriteString(helpStyle.Render("tab toggle regex · ctrl+s save · esc cancel · ctrl+c quit")) + return b.String() +} + func rulesRowsFor(rs []rules.Rule) []table.Row { rows := make([]table.Row, len(rs)) for i, r := range rs { @@ -1925,6 +2128,22 @@ func rulesRowsFor(rs []rules.Rule) []table.Row { return rows } +func scopeRowsFor(rs []scope.Rule) []table.Row { + rows := make([]table.Row, len(rs)) + for i, r := range rs { + on := " " + if r.Enabled { + on = "✓" + } + regex := "" + if r.IsRegex { + regex = "yes" + } + rows[i] = table.Row{on, r.Pattern, regex} + } + return rows +} + func (m *model) intruderView() string { var b strings.Builder title := fmt.Sprintf(" intruder - %s://%s ", sanitizeLine(m.intruderScheme), sanitizeLine(m.intruderHost)) diff --git a/internal/ipc/ipc.go b/internal/ipc/ipc.go index c2be080..2abce89 100644 --- a/internal/ipc/ipc.go +++ b/internal/ipc/ipc.go @@ -13,6 +13,7 @@ import ( "time" "mitmux/internal/rules" + "mitmux/internal/scope" "mitmux/internal/store" ) @@ -55,6 +56,14 @@ type Request struct { RuleID int64 `json:"rule_id,omitempty"` RuleEnabled bool `json:"rule_enabled,omitempty"` + // For "scope_add": the new rule (always an add - scope rules are + // simple enough that edit-in-place isn't worth a separate update + // path; delete and re-add covers it). For "scope_delete"/ + // "scope_toggle": ScopeRuleID (and RuleEnabled for toggle) identify + // the target. + ScopeRule *scope.Rule `json:"scope_rule,omitempty"` + ScopeRuleID int64 `json:"scope_rule_id,omitempty"` + // For "set_flagged" and "delete_entry": ID identifies the history // entry. "clear_history" needs no fields at all. Flagged bool `json:"flagged,omitempty"` @@ -62,12 +71,13 @@ type Request struct { // 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", "flagged", "deleted", "cleared", 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) - Rules []rules.Rule `json:"rules,omitempty"` // for "rules" - Status *StatusMsg `json:"status,omitempty"` // for "status" + Type string `json:"type"` // "list", "get", "new", "repeat", "rules", "scope_rules", "intrude_result", "intrude_done", "status", "flagged", "deleted", "cleared", 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) + Rules []rules.Rule `json:"rules,omitempty"` // for "rules" + ScopeRules []scope.Rule `json:"scope_rules,omitempty"` // for "scope_rules" + Status *StatusMsg `json:"status,omitempty"` // for "status" // For "intrude_result": one completed attack request. IntrudeResult *IntrudeResultMsg `json:"intrude_result,omitempty"` @@ -326,6 +336,57 @@ func (c *Client) rulesRoundTrip(req Request) ([]rules.Rule, error) { return resp.Rules, nil } +// ListScopeRules returns every scope rule, enabled or not. +func (c *Client) ListScopeRules() ([]scope.Rule, error) { + c.mu.Lock() + defer c.mu.Unlock() + return c.scopeRoundTrip(Request{Type: "scope_list"}) +} + +// AddScopeRule adds r and returns its assigned ID. +func (c *Client) AddScopeRule(r scope.Rule) (int64, error) { + c.mu.Lock() + defer c.mu.Unlock() + saved, err := c.scopeRoundTrip(Request{Type: "scope_add", ScopeRule: &r}) + if err != nil { + return 0, err + } + if len(saved) == 0 { + return 0, errors.New("scope_add: daemon returned no rule") + } + return saved[0].ID, nil +} + +// DeleteScopeRule removes a scope rule. +func (c *Client) DeleteScopeRule(id int64) error { + c.mu.Lock() + defer c.mu.Unlock() + _, err := c.scopeRoundTrip(Request{Type: "scope_delete", ScopeRuleID: id}) + return err +} + +// SetScopeRuleEnabled toggles a scope rule without touching its pattern. +func (c *Client) SetScopeRuleEnabled(id int64, enabled bool) error { + c.mu.Lock() + defer c.mu.Unlock() + _, err := c.scopeRoundTrip(Request{Type: "scope_toggle", ScopeRuleID: id, RuleEnabled: enabled}) + return err +} + +func (c *Client) scopeRoundTrip(req Request) ([]scope.Rule, error) { + if err := c.enc.Encode(req); err != nil { + return nil, err + } + var resp Response + if err := c.dec.Decode(&resp); err != nil { + return nil, err + } + if resp.Type == "error" { + return nil, errors.New(resp.Error) + } + return resp.ScopeRules, nil +} + // Subscribe opens a dedicated connection that streams newly captured // history entries as they happen. The returned channel is closed when // the connection ends; call the returned close func to stop early. diff --git a/internal/ipc/server.go b/internal/ipc/server.go index 7f77ceb..303fdef 100644 --- a/internal/ipc/server.go +++ b/internal/ipc/server.go @@ -10,6 +10,7 @@ import ( "sync" "mitmux/internal/rules" + "mitmux/internal/scope" "mitmux/internal/store" ) @@ -281,6 +282,48 @@ func (s *Server) handleConn(conn net.Conn) { } enc.Encode(Response{Type: "rules"}) + case "scope_list": + rs, err := s.db.ListScopeRules() + if err != nil { + enc.Encode(Response{Type: "error", Error: err.Error()}) + continue + } + enc.Encode(Response{Type: "scope_rules", ScopeRules: rs}) + + case "scope_add": + if req.ScopeRule == nil { + enc.Encode(Response{Type: "error", Error: "scope_add: missing rule"}) + continue + } + r := *req.ScopeRule + if r.IsRegex { + if _, err := regexp.Compile(r.Pattern); err != nil { + enc.Encode(Response{Type: "error", Error: "invalid regex: " + err.Error()}) + continue + } + } + id, err := s.db.AddScopeRule(r) + if err != nil { + enc.Encode(Response{Type: "error", Error: err.Error()}) + continue + } + r.ID = id + enc.Encode(Response{Type: "scope_rules", ScopeRules: []scope.Rule{r}}) + + case "scope_delete": + if err := s.db.DeleteScopeRule(req.ScopeRuleID); err != nil { + enc.Encode(Response{Type: "error", Error: err.Error()}) + continue + } + enc.Encode(Response{Type: "scope_rules"}) + + case "scope_toggle": + if err := s.db.SetScopeRuleEnabled(req.ScopeRuleID, req.RuleEnabled); err != nil { + enc.Encode(Response{Type: "error", Error: err.Error()}) + continue + } + enc.Encode(Response{Type: "scope_rules"}) + case "subscribe": sub := s.hub.subscribe() defer s.hub.unsubscribe(sub) diff --git a/internal/proxy/proxy.go b/internal/proxy/proxy.go index 216e947..2e80ed2 100644 --- a/internal/proxy/proxy.go +++ b/internal/proxy/proxy.go @@ -38,6 +38,7 @@ import ( "mitmux/internal/ca" "mitmux/internal/rules" + "mitmux/internal/scope" "mitmux/internal/store" ) @@ -509,6 +510,18 @@ func (s *Server) record(started time.Time, duration time.Duration, scheme, host if s.store == nil { return } + // Scope only filters what gets recorded here - the request has + // already been forwarded and its response already written to the + // client by the time record() runs (see forward()), so an + // out-of-scope host still proxies completely normally, it just + // doesn't clutter history. Repeat/Intrude (recordRaw, a different + // function) deliberately don't go through this check: a user + // explicitly resending or fuzzing a specific request wants to see + // the result regardless of scope, which exists to cut passive- + // capture noise, not to second-guess a deliberate action. + if scopeRules, err := s.store.ListScopeRules(); err == nil && !scope.InScope(scopeRules, host) { + return + } e := &store.Entry{ StartedAt: started, diff --git a/internal/scope/scope.go b/internal/scope/scope.go new file mode 100644 index 0000000..d8d2e80 --- /dev/null +++ b/internal/scope/scope.go @@ -0,0 +1,61 @@ +// Package scope filters which captured traffic gets recorded to +// history - a target scope, in Burp's sense: out-of-scope requests +// still proxy through completely normally (nothing is blocked), they +// just aren't stored, so unrelated CDN/analytics/tracker noise doesn't +// pollute history and search on a real engagement. Deliberately not an +// access-control mechanism; that would be a materially different, +// riskier feature (breaking a workflow by silently blocking traffic is +// a much worse failure mode than a noisier history). +package scope + +import ( + "regexp" + "strings" +) + +// Rule is one scope entry. A non-regex Pattern matches by substring +// containment against the host (case-insensitive) - "example.com" +// matches "example.com", "www.example.com", and "api.example.com" +// alike, covering the common "this domain and its subdomains" case +// without inventing a separate wildcard syntax. IsRegex switches to a +// full regex match against the host, mirroring the same toggle +// match-and-replace rules already use, for the same reason: one +// consistent mental model across both rule types in this tool. +type Rule struct { + ID int64 + Enabled bool + Pattern string + IsRegex bool +} + +// InScope reports whether host should be recorded, given rules. +// An empty rule set (or one with nothing enabled) means "no scope +// configured" - everything is in scope, matching this tool's behavior +// before scope existed at all, so a fresh install or a user who never +// opens the scope view keeps recording everything, not silently +// nothing. Once at least one rule is enabled, only a host matching one +// of them is in scope. +func InScope(rules []Rule, host string) bool { + anyEnabled := false + for _, r := range rules { + if !r.Enabled { + continue + } + anyEnabled = true + if ruleMatches(r, host) { + return true + } + } + return !anyEnabled +} + +func ruleMatches(r Rule, host string) bool { + if r.IsRegex { + re, err := regexp.Compile(r.Pattern) + if err != nil { + return false + } + return re.MatchString(host) + } + return strings.Contains(strings.ToLower(host), strings.ToLower(r.Pattern)) +} diff --git a/internal/scope/scope_test.go b/internal/scope/scope_test.go new file mode 100644 index 0000000..fee5d44 --- /dev/null +++ b/internal/scope/scope_test.go @@ -0,0 +1,96 @@ +package scope + +import "testing" + +func TestInScopeEmptyRulesMeansEverything(t *testing.T) { + if !InScope(nil, "example.com") { + t.Error("empty rule set should mean everything is in scope") + } + if !InScope([]Rule{}, "anything.at.all") { + t.Error("empty rule set should mean everything is in scope") + } +} + +func TestInScopeAllDisabledMeansEverything(t *testing.T) { + rules := []Rule{{Enabled: false, Pattern: "example.com"}} + if !InScope(rules, "unrelated.org") { + t.Error("no enabled rules should mean everything is in scope") + } +} + +func TestInScopeSubstringMatch(t *testing.T) { + rules := []Rule{{Enabled: true, Pattern: "example.com"}} + tests := []struct { + host string + want bool + }{ + {"example.com", true}, + {"www.example.com", true}, + {"api.example.com", true}, + {"example.com.evil.org", true}, // substring containment, deliberately simple + {"other.org", false}, + } + for _, tt := range tests { + if got := InScope(rules, tt.host); got != tt.want { + t.Errorf("InScope(%q) = %v, want %v", tt.host, got, tt.want) + } + } +} + +func TestInScopeCaseInsensitive(t *testing.T) { + rules := []Rule{{Enabled: true, Pattern: "Example.COM"}} + if !InScope(rules, "www.EXAMPLE.com") { + t.Error("substring match should be case-insensitive") + } +} + +func TestInScopeRegex(t *testing.T) { + rules := []Rule{{Enabled: true, Pattern: `(^|\.)example\.com$`, IsRegex: true}} + tests := []struct { + host string + want bool + }{ + {"example.com", true}, + {"api.example.com", true}, + {"notexample.com", false}, + {"example.com.evil.org", false}, + } + for _, tt := range tests { + if got := InScope(rules, tt.host); got != tt.want { + t.Errorf("InScope(%q) = %v, want %v", tt.host, got, tt.want) + } + } +} + +func TestInScopeInvalidRegexNeverMatches(t *testing.T) { + rules := []Rule{{Enabled: true, Pattern: "(unclosed", IsRegex: true}} + if InScope(rules, "example.com") { + t.Error("an invalid regex rule should never match, not panic or false-positive") + } +} + +func TestInScopeMultipleRulesAnyMatch(t *testing.T) { + rules := []Rule{ + {Enabled: true, Pattern: "example.com"}, + {Enabled: true, Pattern: "other.org"}, + } + if !InScope(rules, "other.org") { + t.Error("should match the second rule") + } + if InScope(rules, "unrelated.net") { + t.Error("should not match either rule") + } +} + +func TestInScopeDisabledRuleIgnored(t *testing.T) { + rules := []Rule{ + {Enabled: false, Pattern: "example.com"}, + {Enabled: true, Pattern: "other.org"}, + } + if InScope(rules, "example.com") { + t.Error("a disabled rule should not match") + } + if !InScope(rules, "other.org") { + t.Error("the enabled rule should still match") + } +} diff --git a/internal/store/store.go b/internal/store/store.go index c0cdea5..befd5a6 100644 --- a/internal/store/store.go +++ b/internal/store/store.go @@ -16,6 +16,7 @@ import ( _ "modernc.org/sqlite" "mitmux/internal/rules" + "mitmux/internal/scope" ) const schema = ` @@ -55,6 +56,13 @@ CREATE TABLE IF NOT EXISTS rules ( is_regex INTEGER NOT NULL DEFAULT 0, position INTEGER NOT NULL DEFAULT 0 ); + +CREATE TABLE IF NOT EXISTS scope_rules ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + enabled INTEGER NOT NULL DEFAULT 1, + pattern TEXT NOT NULL, + is_regex INTEGER NOT NULL DEFAULT 0 +); ` // Store is a handle to the history database. Safe for concurrent use. @@ -560,6 +568,57 @@ func (s *Store) SetRuleEnabled(id int64, enabled bool) error { return nil } +// ListScopeRules returns every scope rule, including disabled ones (the +// scope management view needs to show and let you re-enable those too). +func (s *Store) ListScopeRules() ([]scope.Rule, error) { + rows, err := s.db.Query(`SELECT id, enabled, pattern, is_regex FROM scope_rules ORDER BY id`) + if err != nil { + return nil, fmt.Errorf("list scope rules: %w", err) + } + defer rows.Close() + + var out []scope.Rule + for rows.Next() { + var r scope.Rule + var enabled, isRegex int + if err := rows.Scan(&r.ID, &enabled, &r.Pattern, &isRegex); err != nil { + return nil, fmt.Errorf("scan scope rule row: %w", err) + } + r.Enabled = enabled != 0 + r.IsRegex = isRegex != 0 + out = append(out, r) + } + return out, rows.Err() +} + +// AddScopeRule stores r and returns its assigned ID. +func (s *Store) AddScopeRule(r scope.Rule) (int64, error) { + res, err := s.db.Exec( + `INSERT INTO scope_rules (enabled, pattern, is_regex) VALUES (?, ?, ?)`, + boolToInt(r.Enabled), r.Pattern, boolToInt(r.IsRegex), + ) + if err != nil { + return 0, fmt.Errorf("add scope rule: %w", err) + } + return res.LastInsertId() +} + +// SetScopeRuleEnabled toggles a scope rule without touching its pattern. +func (s *Store) SetScopeRuleEnabled(id int64, enabled bool) error { + if _, err := s.db.Exec(`UPDATE scope_rules SET enabled = ? WHERE id = ?`, boolToInt(enabled), id); err != nil { + return fmt.Errorf("set scope rule %d enabled: %w", id, err) + } + return nil +} + +// DeleteScopeRule removes a scope rule. +func (s *Store) DeleteScopeRule(id int64) error { + if _, err := s.db.Exec(`DELETE FROM scope_rules WHERE id = ?`, id); err != nil { + return fmt.Errorf("delete scope rule %d: %w", id, err) + } + return nil +} + // DeleteEntry removes a single history entry and its search index row. func (s *Store) DeleteEntry(id int64) error { tx, err := s.db.Begin() |