srdusr
aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-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()