From 9febb1bba1d0db232730e001004e0624b6ccb32f Mon Sep 17 00:00:00 2001 From: Mathias Date: Thu, 11 Jun 2026 08:55:33 +0200 Subject: [PATCH] fix(claudewatcher): harden secret scrubber against \b evasion + add JWT A shell mangle that glued a key to a preceding word ('yes'+'sk-...') had no word boundary, so the leading \b in the openai-sk rule failed to match and a LiteLLM master key leaked past the scrubber into ingest (2026-06-11). - openai-sk: drop leading \b, match sk- shape anywhere ({32,} floor keeps short task-/disk- words clean). - add jwt rule for bare header.payload.sig tokens (no Bearer prefix). - regression tests: the exact yessk- evasion, standalone sk-, bare JWT, plus clean-content guards for task-/disk-. Co-Authored-By: Claude Opus 4.8 (1M context) --- ingestion/internal/claudewatcher/scrubber.go | 11 ++++++++++- ingestion/internal/claudewatcher/scrubber_test.go | 10 ++++++++++ 2 files changed, 20 insertions(+), 1 deletion(-) diff --git a/ingestion/internal/claudewatcher/scrubber.go b/ingestion/internal/claudewatcher/scrubber.go index c312633..575d408 100644 --- a/ingestion/internal/claudewatcher/scrubber.go +++ b/ingestion/internal/claudewatcher/scrubber.go @@ -38,11 +38,20 @@ var DefaultRules = []Rule{ // specific match name in logs. {Name: "authorization-header", RE: regexp.MustCompile(`(?i)Authorization\s*:\s*[A-Za-z]+\s+\S{8,}`)}, {Name: "bearer-token", RE: regexp.MustCompile(`(?i)Bearer\s+[A-Za-z0-9._\-]{16,}`)}, + // JWT (header.payload.sig), e.g. a Dex/OAuth token dumped to stdout + // without a "Bearer " prefix. Both header and payload base64url-encode + // 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/]+@`)}, {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`)}, - {Name: "openai-sk", RE: regexp.MustCompile(`\bsk-(?:proj-)?[A-Za-z0-9]{32,}\b`)}, + // No leading \b: a shell mangle can glue the key to a preceding word + // ("yes"+"sk-...") which has no word boundary, and that exact case + // leaked a LiteLLM master key past this rule (2026-06-11). Match the + // sk- shape wherever it appears; the {32,} length floor keeps short + // "task-"/"disk-" words from tripping it. + {Name: "openai-sk", RE: regexp.MustCompile(`sk-(?:proj-)?[A-Za-z0-9]{32,}`)}, {Name: "anthropic-sk", RE: regexp.MustCompile(`\bsk-ant-[A-Za-z0-9_\-]{32,}\b`)}, {Name: "aws-access-key", RE: regexp.MustCompile(`\bAKIA[0-9A-Z]{16}\b`)}, {Name: "homelab-env-token", RE: regexp.MustCompile(`(?i)(?:_TOKEN|_PASSWORD|_API_KEY|_SECRET)\s*[:=]\s*['"]?[A-Za-z0-9._/+\-]{12,}`)}, diff --git a/ingestion/internal/claudewatcher/scrubber_test.go b/ingestion/internal/claudewatcher/scrubber_test.go index 524114e..3a129f4 100644 --- a/ingestion/internal/claudewatcher/scrubber_test.go +++ b/ingestion/internal/claudewatcher/scrubber_test.go @@ -25,6 +25,13 @@ func TestScrub_PoisonedFixtures(t *testing.T) { {"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"}, } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { @@ -43,6 +50,9 @@ func TestScrub_CleanContentPassesThrough(t *testing.T) { "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)