diff --git a/ingestion/internal/claudewatcher/scrubber.go b/ingestion/internal/claudewatcher/scrubber.go index 7344eff..557ba5f 100644 --- a/ingestion/internal/claudewatcher/scrubber.go +++ b/ingestion/internal/claudewatcher/scrubber.go @@ -43,6 +43,26 @@ var DefaultRules = []Rule{ // JSON, so both segments begin with "eyJ". {Name: "jwt", RE: regexp.MustCompile(`eyJ[A-Za-z0-9_\-]{8,}\.eyJ[A-Za-z0-9_\-]{8,}\.[A-Za-z0-9_\-]{8,}`)}, {Name: "postgres-uri-with-password", RE: regexp.MustCompile(`postgres(?:ql)?://[^:\s/]+:[^@\s/]+@`)}, + // Any credential embedded in a URL's userinfo, which is what a git credential + // store holds: `https://:@host`. The postgres rule above is the + // same shape narrowed to one scheme, and is kept ahead of this one only so a + // DSN reports the more specific name -- same ordering convention as + // authorization-header before bearer-token. + // + // Added after this shape passed ALL rules and was therefore ingested rather + // than dropped (hyperguild#142). It reached ~/.claude/projects -- this + // watcher's own input -- because 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 it. Two credentials were rotated + // (infra#466). The scrubber is the control that decides whether that class of + // mistake is survivable, and for this shape it was failing open. + // + // The {8,} floor on the secret half is what keeps `https://host:443/path` and + // `https://user:@host` out; the `[^:\s/@]+` user half stops a host:port pair + // being read as userinfo. Both directions are covered by + // TestScrub_URLUserinfoCredential, because a rule that dropped every URL with + // a colon in it would discard most of the signal this watcher exists to keep. + {Name: "url-userinfo-credential", RE: regexp.MustCompile(`[a-zA-Z][a-zA-Z0-9+.\-]*://[^:\s/@]+:[^@\s/]{8,}@`)}, {Name: "private-key", RE: regexp.MustCompile(`-----BEGIN[^-]*PRIVATE KEY-----`)}, {Name: "ssh-key", RE: regexp.MustCompile(`ssh-(?:rsa|ed25519|ecdsa)\s+[A-Za-z0-9+/=]{40,}`)}, {Name: "github-pat", RE: regexp.MustCompile(`\b(?:ghp|gho|ghu|ghr|gha)_[A-Za-z0-9]{30,}\b`)}, diff --git a/ingestion/internal/claudewatcher/scrubber_test.go b/ingestion/internal/claudewatcher/scrubber_test.go index 15b7e89..11bc2b4 100644 --- a/ingestion/internal/claudewatcher/scrubber_test.go +++ b/ingestion/internal/claudewatcher/scrubber_test.go @@ -46,6 +46,64 @@ func TestScrub_PoisonedFixtures(t *testing.T) { } } +// 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{ "",