a73x

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))
	}
}