diff options
| author | srdusr <[email protected]> | 2026-05-20 09:29:00 +0200 |
|---|---|---|
| committer | srdusr <[email protected]> | 2026-05-20 09:29:00 +0200 |
| commit | 51811b67018515366bd56f3c3b21aed11d906db2 (patch) | |
| tree | 86f0bb0a95be73e7e6ca45ce82c3989932d82844 /internal | |
| parent | 1ac926d9936947e7b7e1cadaf2fcdeeda52b8c83 (diff) | |
| download | mitmux-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.
Diffstat (limited to 'internal')
| -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 |
6 files changed, 339 insertions, 6 deletions
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() |