Files
hyperguild/ingestion/internal/claudewatcher/scrubber_test.go
mathiasandClaude Opus 5 3c70b6f367
CI / Lint / Test / Vet (push) Successful in 33s
CI / Mirror to GitHub (push) Successful in 5s
fix(claudewatcher): scrub credentials embedded in URL userinfo
The scrubber had a rule for `postgres://user:pass@` and none for the
general `scheme://user:secret@host` form — which is exactly what a git
credential store holds:

    https://<user>:<token>@git.d-ma.be

Verified against all 13 DefaultRules with an invented value: it matched
none of them. Not authorization-header, not bearer-token, and not
homelab-env-token, which needs a `_TOKEN=`-style prefix a bare URL does
not have. So such a turn was ingested rather than dropped, against the
stated intent in this file's own doc comment:

    Fail-closed by design: we'd rather lose signal than ingest
    credentials into a public-readable brain.

It failed closed for the shapes it named and open for this one.

How it surfaced: a test suite in mathias/infra executed a script that
configures git while inheriting the operator's real $HOME, read koala's
live ~/.git-credentials, and printed two live values. ~/.claude/projects
is this watcher's own input, so the scrubber was the last line of defence
for that mistake. A sweep of that tree found 12 occurrences of 2 distinct
credential-shaped URLs across 4 transcripts in 3 sessions — so the shape
had been passing for a while, not just that once. Both credentials are
being rotated (infra#466).

The generalisable point 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 or
permanent.

Ordering: the new rule sits after postgres-uri-with-password so a DSN
still reports the more specific name, the same convention as
authorization-header before bearer-token.

Both directions are tested, because a rule that dropped every URL with a
colon would discard most of the signal this watcher exists to keep. The
{8,} floor on the secret half keeps `https://host:443/path` and
`https://user:@host` out; the `[^:\s/@]+` user half stops a host:port
pair being read as userinfo. Must-not-scrub cases include a host with a
port, a password-less DSN, a colon in a query string, and the literal
printf format string `https://oauth2:%s@%s`.

Closes #142
Refs mathias/infra#466

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-30 16:08:17 +02:00

191 lines
8.1 KiB
Go

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_<base64url>), 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://<user>:<token>@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)
}