diff options
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() |