a6b480a3
feat: add per-host rule exemptions to scanner
a73x 2026-03-31 06:00
Adds exempt_hosts field to scanner rules so specific hosts can bypass specific rules. Defaults bearer-token to exempt api.anthropic.com. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
diff --git a/proxy/proxy.go b/proxy/proxy.go index 1a7c1ce..0b6ad7c 100644 --- a/proxy/proxy.go +++ b/proxy/proxy.go @@ -214,7 +214,7 @@ func (p *Proxy) handleConnectMITM(w http.ResponseWriter, r *http.Request) { return } findings := p.scanRequest(req) findings := p.scanRequest(req, host) if len(findings) > 0 { rules := make([]string, 0, len(findings)) for _, f := range findings { @@ -294,7 +294,7 @@ func (p *Proxy) getOrCreateLeaf(host string) (*tls.Certificate, error) { return &leaf, nil } func (p *Proxy) scanRequest(r *http.Request) []scanner.Finding { func (p *Proxy) scanRequest(r *http.Request, host string) []scanner.Finding { if p.scanner == nil { return nil } @@ -319,11 +319,12 @@ func (p *Proxy) scanRequest(r *http.Request) []scanner.Finding { r.Body = io.NopCloser(bytes.NewReader(body)) } return p.scanner.Scan(buf.Bytes()) return p.scanner.Scan(buf.Bytes(), host) } func (p *Proxy) handleHTTP(w http.ResponseWriter, r *http.Request) { findings := p.scanRequest(r) host := extractHost(r.Host) findings := p.scanRequest(r, host) if len(findings) > 0 { rules := make([]string, 0, len(findings)) for _, f := range findings { diff --git a/scanner/scanner.go b/scanner/scanner.go index 54670aa..2f0c642 100644 --- a/scanner/scanner.go +++ b/scanner/scanner.go @@ -15,8 +15,9 @@ type Finding struct { } type rule struct { name string pattern *regexp.Regexp name string pattern *regexp.Regexp exemptHosts []string } // Scanner holds compiled rules for scanning request bodies. @@ -25,8 +26,9 @@ type Scanner struct { } type yamlRule struct { Name string `yaml:"name"` Pattern string `yaml:"pattern"` Name string `yaml:"name"` Pattern string `yaml:"pattern"` ExemptHosts []string `yaml:"exempt_hosts"` } type yamlConfig struct { @@ -52,7 +54,7 @@ func New(path string) (*Scanner, error) { if err != nil { return nil, fmt.Errorf("compiling pattern for rule %q: %w", yr.Name, err) } rules = append(rules, rule{name: yr.Name, pattern: re}) rules = append(rules, rule{name: yr.Name, pattern: re, exemptHosts: yr.ExemptHosts}) } return &Scanner{rules: rules}, nil @@ -65,9 +67,13 @@ func (s *Scanner) RuleCount() int { // Scan checks body against all rules and returns any findings. // Match snippets are truncated to 40 characters. func (s *Scanner) Scan(body []byte) []Finding { // Rules with exempt_hosts are skipped when host matches. func (s *Scanner) Scan(body []byte, host string) []Finding { var findings []Finding for _, r := range s.rules { if r.isExempt(host) { continue } match := r.pattern.Find(body) if match == nil { continue @@ -81,6 +87,15 @@ func (s *Scanner) Scan(body []byte) []Finding { return findings } func (r *rule) isExempt(host string) bool { for _, h := range r.exemptHosts { if h == host { return true } } return false } const defaultRulesYAML = `rules: - name: ssh-private-key pattern: "-----BEGIN (OPENSSH|RSA|DSA|EC|ED25519) PRIVATE KEY-----" @@ -90,6 +105,8 @@ const defaultRulesYAML = `rules: pattern: "Authorization:\\s*Basic\\s+" - name: bearer-token pattern: "Authorization:\\s*Bearer\\s+" exempt_hosts: - api.anthropic.com - name: aws-access-key pattern: "AKIA[0-9A-Z]{16}" - name: github-token diff --git a/scanner/scanner_test.go b/scanner/scanner_test.go index 6f824bf..f5e9a3b 100644 --- a/scanner/scanner_test.go +++ b/scanner/scanner_test.go @@ -48,7 +48,7 @@ func writeRules(t *testing.T, content string) string { func TestScan_DetectsSSHPrivateKey(t *testing.T) { path := writeRules(t, "rules:\n - name: ssh-private-key\n pattern: \"-----BEGIN (OPENSSH|RSA|DSA|EC|ED25519) PRIVATE KEY-----\"\n") s, _ := scanner.New(path) findings := s.Scan([]byte("some data\n-----BEGIN RSA PRIVATE KEY-----\nMIIE...")) findings := s.Scan([]byte("some data\n-----BEGIN RSA PRIVATE KEY-----\nMIIE..."), "") if len(findings) != 1 { t.Fatalf("expected 1 finding, got %d", len(findings)) } @@ -60,7 +60,7 @@ func TestScan_DetectsSSHPrivateKey(t *testing.T) { func TestScan_DetectsAWSKey(t *testing.T) { path := writeRules(t, "rules:\n - name: aws-access-key\n pattern: \"AKIA[0-9A-Z]{16}\"\n") s, _ := scanner.New(path) findings := s.Scan([]byte("{\"key\": \"AKIAIOSFODNN7EXAMPLE\"}")) findings := s.Scan([]byte("{\"key\": \"AKIAIOSFODNN7EXAMPLE\"}"), "") if len(findings) != 1 { t.Fatalf("expected 1 finding, got %d", len(findings)) } @@ -73,7 +73,7 @@ func TestScan_ReturnsMultipleFindings(t *testing.T) { path := writeRules(t, "rules:\n - name: ssh-private-key\n pattern: \"-----BEGIN RSA PRIVATE KEY-----\"\n - name: aws-access-key\n pattern: \"AKIA[0-9A-Z]{16}\"\n") s, _ := scanner.New(path) body := []byte("-----BEGIN RSA PRIVATE KEY-----\nkey\nAKIAIOSFODNN7EXAMPLE") findings := s.Scan(body) findings := s.Scan(body, "") if len(findings) != 2 { t.Fatalf("expected 2 findings, got %d", len(findings)) } @@ -82,7 +82,7 @@ func TestScan_ReturnsMultipleFindings(t *testing.T) { func TestScan_ReturnsEmptyForCleanBody(t *testing.T) { path := writeRules(t, "rules:\n - name: ssh-private-key\n pattern: \"-----BEGIN RSA PRIVATE KEY-----\"\n") s, _ := scanner.New(path) findings := s.Scan([]byte("just some normal POST data")) findings := s.Scan([]byte("just some normal POST data"), "") if len(findings) != 0 { t.Errorf("expected 0 findings, got %d", len(findings)) } @@ -91,7 +91,7 @@ func TestScan_ReturnsEmptyForCleanBody(t *testing.T) { func TestScan_TruncatesMatchSnippet(t *testing.T) { path := writeRules(t, "rules:\n - name: ssh-private-key\n pattern: \"-----BEGIN RSA PRIVATE KEY-----\"\n") s, _ := scanner.New(path) findings := s.Scan([]byte("-----BEGIN RSA PRIVATE KEY-----")) findings := s.Scan([]byte("-----BEGIN RSA PRIVATE KEY-----"), "") if len(findings) != 1 { t.Fatalf("expected 1 finding, got %d", len(findings)) } @@ -131,3 +131,25 @@ func TestWriteDefaultRules_DoesNotOverwrite(t *testing.T) { t.Errorf("expected 1 rule (not overwritten), got %d", s.RuleCount()) } } func TestScan_SkipsExemptHost(t *testing.T) { path := writeRules(t, `rules: - name: bearer-token pattern: "Authorization:\\s*Bearer\\s+" exempt_hosts: - api.anthropic.com `) s, _ := scanner.New(path) body := []byte("Authorization: Bearer sk-ant-123") findings := s.Scan(body, "api.anthropic.com") if len(findings) != 0 { t.Errorf("expected 0 findings for exempt host, got %d", len(findings)) } findings = s.Scan(body, "evil.com") if len(findings) != 1 { t.Errorf("expected 1 finding for non-exempt host, got %d", len(findings)) } }