package claudewatcher import ( "testing" "github.com/stretchr/testify/assert" ) func TestScrub_PoisonedFixtures(t *testing.T) { // One representative bad-string per rule. If a rule fires for the // wrong content shape later, this table localises the regression. cases := []struct { name string content string want string }{ {"bearer-token", "curl -H 'Authorization: Bearer abcdef1234567890ghijklmnop'", "authorization-header"}, {"bearer-no-header", "header = Bearer eyJhbGciOiJIUzI1NiJ9.payload.sig", "bearer-token"}, {"postgres-uri", "DATABASE_URL=postgres://user:s3cret@10.0.1.20:5432/brain", "postgres-uri-with-password"}, {"private-key", "-----BEGIN OPENSSH PRIVATE KEY-----\nb3BlbnNzaC1rZXktdjEAAAAA", "private-key"}, {"ssh-public", "deploy: ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIK1234567890abcdefghij user@host", "ssh-key"}, {"github-pat-classic", "GH_TOKEN=ghp_aBcD1234EfGh5678IjKl9012MnOp3456QrSt", "github-pat"}, {"openai-key", "OPENAI_API_KEY=sk-proj-AAAABBBBCCCCDDDDEEEEFFFFGGGGHHHHIIII", "openai-sk"}, {"anthropic-key", "ANTHROPIC_API_KEY=sk-ant-api03-aaaaBBBBccccDDDDeeeeFFFFggggHHHHiiiiJJJJkkkk", "anthropic-sk"}, {"aws-access-key", "AWS_ACCESS_KEY_ID=AKIAIOSFODNN7EXAMPLE", "aws-access-key"}, {"homelab-env", "POSTGRES_PASSWORD=hunter2supersecretvalue", "homelab-env-token"}, {"sops-marker", "value: ENC[AES256_GCM,data:abc123def456,iv:zzz]", "sops-encrypted-marker"}, // Regression: a shell mangle glued the key to a preceding word // ("yes"+"sk-..."), defeating the leading \b in the sk- rule and // leaking a LiteLLM master key past the scrubber (2026-06-11). {"sk-glued-to-word", "master key resolved: yessk-7181ca984603239d8c4819361bf33b94b9c3c07018791868", "openai-sk"}, {"sk-standalone-hex", "sk-7181ca984603239d8c4819361bf33b94b9c3c07018791868", "openai-sk"}, // Bare JWT not preceded by "Bearer" (e.g. a Dex token dumped to stdout). {"jwt-bare", "token=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIn0.dQw4w9WgXcQabcdef", "jwt"}, // 1Password service-account token (ops_), env-assigned and bare. // Both hit the dedicated op-service-account rule (ordered before the // generic homelab-env-token). Guards ~/.zshrc reads etc. (2026-06-14). {"op-sa-env", "export OP_SERVICE_ACCOUNT_TOKEN=ops_eyJzaWduSW5BZGRyZXNzIjoibXkuMXBhc3N3b3JkLmNvbSJ9", "op-service-account"}, {"op-sa-bare", "ops_eyJzaWduSW5BZGRyZXNzIjoibXkuMXBhc3N3b3JkLmNvbSIsInVzZXJBdXRoIjp7fX0aGVsbG8", "op-service-account"}, } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { got := Scrub(tc.content) assert.Equal(t, tc.want, got) }) } } // A credential embedded in a URL's userinfo is the shape a git credential store // holds -- `https://:@host` -- and it walked past every rule until // hyperguild#142. There was a rule for `postgres://user:pass@` and nothing for the // general form, so `~/.git-credentials` content was ingested rather than dropped. // // Found the hard way: a test suite in mathias/infra executed a script that // configures git while inheriting the operator's real $HOME, read koala's live // credential store, and printed two live credentials into a session transcript. // ~/.claude/projects is this watcher's own input, so the scrubber was the last // line of defence for that mistake and it failed open. Two credentials were // rotated (infra#466). // // The lesson is not "do not read the credential file" -- that mistake will recur. // It is that this shape must fail closed, because the scrubber is what decides // whether such a mistake is survivable. func TestScrub_URLUserinfoCredential(t *testing.T) { // Every secret below is INVENTED. None is or was a real credential. mustScrub := []struct { name string content string }{ {"git-credential-store-user", "https://mathias:0123456789abcdef0123456789abcdef01234567@git.d-ma.be"}, {"git-credential-store-oauth2", "https://oauth2:0123456789abcdef0123456789abcdef01234567@git.d-ma.be"}, {"in-context-payload-line", `payload line: https://mathias:0123456789abcdef0123456789abcdef01234567@git.d-ma.be`}, {"clone-url-in-argv", "git clone https://oauth2:abcdefghijklmnopqrstuvwx@git.d-ma.be/mathias/infra.git"}, {"http-not-https", "http://admin:supersecretvalue123@10.0.1.20/api"}, {"git-plus-ssh-scheme", "git+ssh://deploy:abcdefghijklmnop@example.com/repo"}, {"redis-scheme", "redis://default:abcdefghijklmnopqrst@10.0.1.20:6379/0"}, } for _, tc := range mustScrub { t.Run(tc.name, func(t *testing.T) { assert.NotEmpty(t, Scrub(tc.content), "a credential in URL userinfo must be dropped, not ingested") }) } // The other direction matters as much: a rule that scrubs every URL with a // colon in it would drop most of the signal this watcher exists to keep. mustNotScrub := []struct { name string content string }{ {"host-with-port", "cloning https://git.d-ma.be:443/mathias/infra.git"}, {"postgres-no-password", "postgres://localhost:5432/brain"}, {"url-with-colon-in-query", "GET https://example.com/path?filter=a:b&x=1"}, {"plain-url", "see https://git.d-ma.be/mathias/infra/issues/466"}, {"userinfo-no-secret", "https://user:@example.com"}, {"format-string-placeholder", `printf 'https://oauth2:%s@%s\n' "$TOKEN" "$HOST"`}, {"scheme-relative-time", "duration https://example.com took 00:03:22"}, } for _, tc := range mustNotScrub { t.Run(tc.name, func(t *testing.T) { assert.Empty(t, Scrub(tc.content), "this is not a credential and must pass through") }) } } func TestScrub_CleanContentPassesThrough(t *testing.T) { cases := []string{ "", "plain text with no credentials", "a 40 char hex string aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa is fine in isolation", "`Bearer` token mentioned in docs without an actual value", "file at ~/.ssh/id_ed25519", "the function Authorization() takes no args", "comment: see API key in 1Password", // loosened sk- rule must not trip on short "task-"/"disk-" words "run task-build then task-test in the pipeline", "mounted /dev/disk-by-id/wwn-0x5000", } for _, c := range cases { assert.Empty(t, Scrub(c), "expected clean for %q", c) } } func TestScrub_FirstMatchWins(t *testing.T) { // Content matching multiple rules: report the first rule order in // DefaultRules. Stability matters for log triage. content := "Authorization: Bearer ghp_aBcD1234EfGh5678IjKl9012MnOp3456QrSt" assert.Equal(t, "authorization-header", Scrub(content)) } func TestRegisterRule_ClientNameGuard(t *testing.T) { t.Cleanup(ResetExtraRules) require := func(err error) { if err != nil { t.Fatalf("unexpected err: %v", err) } } require(RegisterRule("client-name", `(?i)\b(SEB|Mastercard)\b`)) // Hits — case variations + word-boundary respect. for _, hit := range []string{ "mentioned SEB in this commit", "the Mastercard project deadline", "working on mastercard scope", "SEB internal review", } { assert.Equal(t, "client-name", Scrub(hit), "should match %q", hit) } // Misses — substring within a longer word should NOT match // thanks to \b. "Sebastian" contains "seb" but \b prevents hit. for _, miss := range []string{ "Sebastian wrote the docs", "unrelated text", "researcher", "https://example.com/search?seb=1", // 'seb' bounded by ?=, still matches \b } { got := Scrub(miss) if miss == "https://example.com/search?seb=1" { // `seb=` has word-boundary at '='; this DOES match \bseb\b. // Accept either outcome; document the tradeoff. assert.Contains(t, []string{"", "client-name"}, got) continue } assert.Empty(t, got, "should NOT match %q", miss) } } func TestRegisterRule_CredentialsTakePrecedence(t *testing.T) { t.Cleanup(ResetExtraRules) require := func(err error) { if err != nil { t.Fatalf("unexpected err: %v", err) } } require(RegisterRule("client-name", `\b(SEB)\b`)) // Content matches both a credential rule AND a client rule — // credential rule wins by ordering, so log triage points at the // strictly more dangerous leak. content := "SEB project uses OPENAI_API_KEY=sk-proj-AAAABBBBCCCCDDDDEEEEFFFFGGGGHHHHIIII" assert.Equal(t, "openai-sk", Scrub(content)) } func TestRegisterRule_RejectsInvalidPattern(t *testing.T) { t.Cleanup(ResetExtraRules) err := RegisterRule("bad", "[unclosed") assert.Error(t, err) }