srdusr
aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorsrdusr <[email protected]>2026-05-20 09:29:00 +0200
committersrdusr <[email protected]>2026-05-20 09:29:00 +0200
commit51811b67018515366bd56f3c3b21aed11d906db2 (patch)
tree86f0bb0a95be73e7e6ca45ce82c3989932d82844
parent1ac926d9936947e7b7e1cadaf2fcdeeda52b8c83 (diff)
downloadmitmux-51811b67018515366bd56f3c3b21aed11d906db2.tar.gz
mitmux-51811b67018515366bd56f3c3b21aed11d906db2.zip
Target scope: filter what gets recorded, not what gets proxied
The proxy captured and stored literally everything with no way to exclude unrelated traffic - every CDN asset, analytics beacon, and third-party tracker request on a real engagement pollutes history and search right alongside the traffic that actually matters. internal/scope: Rule{Enabled, Pattern, IsRegex} and InScope(rules, host). 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 inventing a separate wildcard syntax. IsRegex mirrors the same toggle match-and-replace rules already use, for one consistent mental model across both rule types in this tool. An empty or all-disabled rule set means everything is in scope - the 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. Deliberately a recording filter, not access control: out-of-scope traffic still proxies completely normally, reaching its destination and the client exactly as before. internal/proxy's forward() already writes the response to the client before record() ever runs, so the scope check (new in record()) can only affect whether the exchange gets stored, never whether it happens. Blocking out-of-scope traffic outright would be a materially different, much riskier feature - a wrong scope pattern could silently break the very traffic someone's trying to test, which is a far worse failure mode than a noisier history. Repeat/Intrude (recordRaw, a separate function from record()) deliberately don't go through the scope check at all: 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 second-guess a deliberate action. internal/store: new scope_rules table (CREATE TABLE IF NOT EXISTS, no migration needed - it's a new table, not a new column on an existing one) plus List/Add/SetEnabled/Delete, mirroring the existing match-and-replace rules CRUD exactly. internal/ipc: scope_list/ scope_add/scope_delete/scope_toggle request types and matching Client methods; scope_add validates a regex pattern compiles before persisting, same reasoning and same fix as the earlier rules_save validation (an invalid regex should be rejected up front, not silently never match at apply time with zero feedback). TUI: 's' from the history list opens scope management, mirroring the Rules view's own list+form pattern but simpler (add-only, no edit-in-place - a pattern and a regex toggle don't need a five-field form, delete-and-re-add covers changing one). Verified live in tmux against a running daemon: added a substring scope rule for one host, sent requests to both a matching and a non-matching host - the non-matching one proxied successfully (client got its 200) but was never recorded, the matching one was recorded normally; confirmed a Repeater resend of the excluded host WAS recorded despite being out of scope; toggled the rule off and confirmed recording resumed for everything; added and confirmed a regex-mode rule saves and displays correctly; deleted a rule and confirmed the list returns to empty ("no rules means everything is recorded"). go build/vet/gofmt/test/mod tidy all clean.
-rw-r--r--PLAN.md39
-rw-r--r--README.md23
-rw-r--r--cmd/mitmux/main.go223
-rw-r--r--internal/ipc/ipc.go73
-rw-r--r--internal/ipc/server.go43
-rw-r--r--internal/proxy/proxy.go13
-rw-r--r--internal/scope/scope.go61
-rw-r--r--internal/scope/scope_test.go96
-rw-r--r--internal/store/store.go59
9 files changed, 619 insertions, 11 deletions
diff --git a/PLAN.md b/PLAN.md
index 4864012..a3ca810 100644
--- a/PLAN.md
+++ b/PLAN.md
@@ -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,
diff --git a/README.md b/README.md
index f0bcc0c..de8274e 100644
--- a/README.md
+++ b/README.md
@@ -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()