diff options
Diffstat (limited to 'internal/scope')
| -rw-r--r-- | internal/scope/scope.go | 61 | ||||
| -rw-r--r-- | internal/scope/scope_test.go | 96 |
2 files changed, 157 insertions, 0 deletions
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") + } +} |