From c2ed5ea021cd52a6fe4d542f68c52b5dafb74dab Mon Sep 17 00:00:00 2001 From: luckyPipewrench Date: Sun, 8 Nov 2026 20:25:47 -0500 Subject: [PATCH 1/6] fix(scanner): bind sign-in bearer allowance to the core JWT pattern --- internal/config/canonical_golden_test.go | 6 +- internal/config/canonical_test.go | 2 +- internal/config/credential_audience_test.go | 5 +- internal/config/dlp_patterns.go | 8 +- internal/config/schema.go | 2 +- internal/proxy/credential_signin_test.go | 112 ++++++++++++++++++ internal/scanner/core.go | 2 +- internal/scanner/credential_audience.go | 44 +++++-- ...redential_audience_actions_results_test.go | 2 +- internal/scanner/credential_signin.go | 10 +- internal/scanner/credential_signin_test.go | 87 +++++++++++++- internal/scanner/registry_audience_test.go | 12 +- internal/scanner/scanner.go | 2 +- internal/scanner/text_dlp.go | 2 +- 14 files changed, 257 insertions(+), 39 deletions(-) diff --git a/internal/config/canonical_golden_test.go b/internal/config/canonical_golden_test.go index 59f25e0b5d..c125e360e4 100644 --- a/internal/config/canonical_golden_test.go +++ b/internal/config/canonical_golden_test.go @@ -463,7 +463,8 @@ const ( // audience added platform.claude.com and mcp-proxy.anthropic.com // (Claude Code first-party credential endpoints). The default // pattern corpus is part of the canonical policy view. - goldenHashDefaults = "75f25791a3c011d532dccb4b143b3bc35083b757e9d62796a50955ce321d25ed" + // Re-bumped for the compiled sign-in bearer carrier on the JWT pattern. + goldenHashDefaults = "aaa69c20b466da8e99b1af8c1ee67aa10167abe9248941de90df64e23ebc42b4" // goldenHashRichConfig pins the hash for goldenRichYAML loaded via // config.Load, post-ApplyDefaults + Validate. Covers a broad, @@ -709,7 +710,8 @@ const ( // audience added platform.claude.com and mcp-proxy.anthropic.com // (Claude Code first-party credential endpoints). The default // pattern corpus is part of the canonical policy view. - goldenHashRichConfig = "9b9b5fe7d208752f94b36ad9a5ba8c409086052fc020eb0d7b8ccdfdbaad8ec8" + // Re-bumped with goldenHashDefaults for the sign-in bearer carrier. + goldenHashRichConfig = "941a0679fef814e2030e8736d32983fbd6ec5674d3ebd6021cf1bfa17de6a595" ) // goldenRichYAML is the canonical fixture for goldenHashRichConfig. It diff --git a/internal/config/canonical_test.go b/internal/config/canonical_test.go index 3f295c538c..8c313031d3 100644 --- a/internal/config/canonical_test.go +++ b/internal/config/canonical_test.go @@ -52,7 +52,7 @@ func TestCanonicalPolicyHash_Deterministic(t *testing.T) { func TestCanonicalPolicyHash_CredentialAudienceCarriers(t *testing.T) { t.Parallel() baseline := canonicalHashOf(t, nil) - for _, mask := range []uint16{0, CredentialAudienceCarrierAuthorizationBearer} { + for _, mask := range []uint32{0, CredentialAudienceCarrierAuthorizationBearer} { changed := canonicalHashOf(t, func(cfg *Config) { found := false for i := range cfg.DLP.Patterns { diff --git a/internal/config/credential_audience_test.go b/internal/config/credential_audience_test.go index b81a0336bf..00375b2cbe 100644 --- a/internal/config/credential_audience_test.go +++ b/internal/config/credential_audience_test.go @@ -363,7 +363,7 @@ func TestJWTCredentialAudience_CompiledQueryOnlyMetadata(t *testing.T) { } wantHosts := []string{"release-assets.githubusercontent.com"} if strings.Join(builtIn.CredentialAudienceHosts, ",") != strings.Join(wantHosts, ",") || - builtIn.CredentialAudienceCarrierMask != CredentialAudienceCarrierURLQuery|CredentialAudienceCarrierRegistryBearer|CredentialAudienceCarrierRegistryBasic || + builtIn.CredentialAudienceCarrierMask != CredentialAudienceCarrierURLQuery|CredentialAudienceCarrierRegistryBearer|CredentialAudienceCarrierRegistryBasic|CredentialAudienceCarrierSignInBearer || builtIn.CredentialAudienceAuthorizationOnly || len(builtIn.CredentialAudienceGitHosts) != 0 { t.Fatalf("JWT compiled audience = %#v", builtIn) } @@ -380,7 +380,6 @@ func TestJWTCredentialAudience_CompiledQueryOnlyMetadata(t *testing.T) { } { t.Run(name, func(t *testing.T) { p := builtIn - p.CredentialAudienceHosts, p.CredentialAudienceCarrierMask = nil, 0 mutate(&p) patterns := []DLPPattern{p} markBuiltInCredentialAudienceHosts(patterns) @@ -404,7 +403,7 @@ func TestJWTCredentialAudience_CompiledQueryOnlyMetadata(t *testing.T) { exact.CredentialAudienceHosts, exact.CredentialAudienceCarrierMask = nil, 0 patterns := []DLPPattern{exact} markBuiltInCredentialAudienceHosts(patterns) - if len(patterns[0].CredentialAudienceHosts) != 1 || patterns[0].CredentialAudienceCarrierMask != CredentialAudienceCarrierURLQuery|CredentialAudienceCarrierRegistryBearer|CredentialAudienceCarrierRegistryBasic { + if len(patterns[0].CredentialAudienceHosts) != 1 || patterns[0].CredentialAudienceCarrierMask != CredentialAudienceCarrierURLQuery|CredentialAudienceCarrierRegistryBearer|CredentialAudienceCarrierRegistryBasic|CredentialAudienceCarrierSignInBearer { t.Fatalf("exact built-in lost its audience: %#v", patterns[0]) } } diff --git a/internal/config/dlp_patterns.go b/internal/config/dlp_patterns.go index 199ec2a50f..35d8efcdd3 100644 --- a/internal/config/dlp_patterns.go +++ b/internal/config/dlp_patterns.go @@ -32,7 +32,7 @@ const ( // provider accepts only the schemes its clients document. Google's Bearer-only // rule predates the mask and stays on CredentialAudienceAuthorizationOnly. const ( - CredentialAudienceCarrierAuthorizationBearer uint16 = 1 << iota + CredentialAudienceCarrierAuthorizationBearer uint32 = 1 << iota CredentialAudienceCarrierAuthorizationToken CredentialAudienceCarrierAuthorizationBasic CredentialAudienceCarrierPrivateToken @@ -81,6 +81,10 @@ const ( // validated client Identify (op 2) or Resume (op 6) JSON frame over WSS. // https://docs.discord.com/developers/events/gateway-events CredentialAudienceCarrierDiscordGateway + // CredentialAudienceCarrierSignInBearer accepts a provider sign-in JWT + // only as the whole HTTPS Authorization bearer at its compiled API route; + // see scanner.signInBearerRoutes for the provider contracts. + CredentialAudienceCarrierSignInBearer ) // githubTokenAudienceMask is the GitHub carriers: Authorization with the @@ -408,7 +412,7 @@ var defaultDLPPatternSet = []DLPPattern{ // "ey..."-ish fragments tripped it. Keep only narrow, case-sensitive // JSON-object prefixes so the precision fix does not drop compact // JWTs serialized with whitespace. - {Name: "JWT Token", Regex: `(?:(?-i:ey[JA])[a-zA-Z0-9_\-=]{7,}|(?-i:ew[ok0])[a-zA-Z0-9_\-=]{7,})\.(?:(?-i:ey[JA])[a-zA-Z0-9_\-=]{7,}|(?-i:ew[ok0])[a-zA-Z0-9_\-=]{7,}|(?-i:e30=?))\.[a-zA-Z0-9_\-=]{10,}`, Severity: SeverityHigh, CredentialAudienceHosts: githubDownloadGrantAudienceHosts, CredentialAudienceRegistryHosts: githubRegistryHosts, CredentialAudienceCarrierMask: CredentialAudienceCarrierURLQuery | CredentialAudienceCarrierRegistryBearer | CredentialAudienceCarrierRegistryBasic}, + {Name: "JWT Token", Regex: `(?:(?-i:ey[JA])[a-zA-Z0-9_\-=]{7,}|(?-i:ew[ok0])[a-zA-Z0-9_\-=]{7,})\.(?:(?-i:ey[JA])[a-zA-Z0-9_\-=]{7,}|(?-i:ew[ok0])[a-zA-Z0-9_\-=]{7,}|(?-i:e30=?))\.[a-zA-Z0-9_\-=]{10,}`, Severity: SeverityHigh, CredentialAudienceHosts: githubDownloadGrantAudienceHosts, CredentialAudienceRegistryHosts: githubRegistryHosts, CredentialAudienceCarrierMask: CredentialAudienceCarrierURLQuery | CredentialAudienceCarrierRegistryBearer | CredentialAudienceCarrierRegistryBasic | CredentialAudienceCarrierSignInBearer}, // Cryptocurrency private keys // Bitcoin WIF: base58check. Uncompressed (5 + 50 base58 = 51 chars) or diff --git a/internal/config/schema.go b/internal/config/schema.go index bd447bf5f0..3aa8b46ade 100644 --- a/internal/config/schema.go +++ b/internal/config/schema.go @@ -1353,7 +1353,7 @@ type DLPPattern struct { Compiled bool `yaml:"-"` // true for patterns created in Defaults() CredentialAudienceHosts []string `yaml:"-"` // compiled built-ins only; strict YAML rejects attempts to configure it CredentialAudienceAuthorizationOnly bool `yaml:"-"` // compiled built-ins only; restricts audience allowance to Authorization headers - CredentialAudienceCarrierMask uint16 `yaml:"-"` // compiled built-ins only; which surfaces may carry the credential + CredentialAudienceCarrierMask uint32 `yaml:"-"` // compiled built-ins only; which surfaces may carry the credential CredentialAudienceGitHosts []string `yaml:"-"` // compiled built-ins only; hosts of the git-over-HTTPS Basic rule CredentialAudienceRegistryHosts []string `yaml:"-"` // compiled built-ins only; hosts of the package-registry Basic or bearer rule // CredentialURLWhitespaceGrammar is set only by the built-in default diff --git a/internal/proxy/credential_signin_test.go b/internal/proxy/credential_signin_test.go index 60c4f13583..921ed1091e 100644 --- a/internal/proxy/credential_signin_test.go +++ b/internal/proxy/credential_signin_test.go @@ -11,7 +11,9 @@ import ( "strings" "testing" + "github.com/luckyPipewrench/pipelock/internal/audit" "github.com/luckyPipewrench/pipelock/internal/config" + "github.com/luckyPipewrench/pipelock/internal/metrics" "github.com/luckyPipewrench/pipelock/internal/scanner" ) @@ -57,6 +59,9 @@ func TestSignInBearer_RequestHeaderScan(t *testing.T) { "Authorization": {"Bearer " + codex}, "User-Agent": {"codex_cli_rs/0.167.0"}, "Chatgpt-Account-Id": {"acct-1"}, }, target, true, false, ""}, {"authorization only", http.Header{"Authorization": {"Bearer " + codex}}, target, true, false, ""}, + {"trailing garbage", http.Header{"Authorization": {"Bearer " + codex + "!"}}, target, false, false, "JWT Token"}, + {"token elsewhere", http.Header{"Authorization": {"Bearer !" + codex}}, target, false, false, "JWT Token"}, + {"second token", http.Header{"Authorization": {"Bearer " + codex + "!" + other}}, target, false, false, "JWT Token"}, {"same token copied to another header", http.Header{ "Authorization": {"Bearer " + codex}, "X-Copy": {codex}, "User-Agent": {"codex_cli_rs/0.167.0"}, }, target, false, true, ""}, @@ -116,6 +121,72 @@ func TestSignInBearer_RequestHeaderScan(t *testing.T) { } }) } + t.Run(mode+"/reload", func(t *testing.T) { + testSignInBearerHeaderReload(t, cfg, codex, target) + }) + } +} + +func testSignInBearerHeaderReload(t *testing.T, cfg *config.Config, token, target string) { + t.Helper() + p, err := New(cfg, audit.NewNop(), scanner.MustNew(cfg), metrics.New()) + if err != nil { + t.Fatal(err) + } + t.Cleanup(p.Close) + check := func(t *testing.T, allow bool) { + t.Helper() + var allows []scanner.CredentialAudienceAllow + result := scanRequestHeadersForTargetWithAudience(t.Context(), http.Header{ + "Authorization": {"Bearer " + token}, "User-Agent": {"client"}, + }, p.cfgPtr.Load(), p.scannerPtr.Load(), target, nil, + func(a scanner.CredentialAudienceAllow) error { allows = append(allows, a); return nil }) + clean := result == nil || result.Clean + recorded := false + for _, a := range allows { + recorded = recorded || a.PatternName == "JWT Token" + } + if clean != allow || recorded != allow { + t.Fatalf("clean=%t recorded=%t want %t", clean, recorded, allow) + } + if !allow { + found := false + for _, m := range result.DLPMatches { + found = found || m.PatternName == "JWT Token" + } + if !found { + t.Fatal("custom JWT finding missing") + } + } + } + check(t, true) + for _, step := range []struct { + name string + custom bool + }{ + {"unrelated reload", false}, + {"replace built-in", true}, + {"retain replacement", true}, + {"restore built-in", false}, + } { + t.Run(step.name, func(t *testing.T) { + next := cfg.Clone() + if step.custom { + for i := range next.DLP.Patterns { + if next.DLP.Patterns[i].Name == "JWT Token" { + next.DLP.Patterns[i].Regex += `|custom-[a-z]+` + } + } + } + next.ApplyDefaults() + if err := next.Validate(); err != nil { + t.Fatal(err) + } + if !p.Reload(next, scanner.MustNew(next)) { + t.Fatal("reload refused") + } + check(t, !step.custom) + }) } } @@ -127,3 +198,44 @@ func percentEncodeAll(v string) string { } return b.String() } + +func TestRegistryBearer_RequestHeaderScan(t *testing.T) { + host := config.RegistryBearerHosts()[0] + token := signInTestJWT(t, map[string]any{"aud": host, "access": []any{map[string]any{"type": "repository"}}}) + for _, mode := range []string{config.HeaderModeSensitive, config.HeaderModeAll} { + cfg := config.Defaults() + cfg.Internal = nil + cfg.RequestBodyScanning.ScanHeaders = true + cfg.RequestBodyScanning.HeaderMode = mode + sc := scanner.MustNew(cfg) + t.Cleanup(sc.Close) + for _, tc := range []struct { + name, value string + allow bool + }{ + {"whole token", "Bearer " + token, true}, + {"trailing garbage", "Bearer " + token + "!", false}, + {"token elsewhere", "Bearer !" + token, false}, + {"other secret", "Bearer " + token + " AKIA" + strings.Repeat("A", 16), false}, + } { + t.Run(mode+"/"+tc.name, func(t *testing.T) { + var allows []scanner.CredentialAudienceAllow + result := scanRequestHeadersForTargetWithAudience(t.Context(), http.Header{ + "Authorization": {tc.value}, "User-Agent": {"client"}, + }, cfg, sc, "https://"+host+"/v2/", nil, + func(a scanner.CredentialAudienceAllow) error { allows = append(allows, a); return nil }) + clean := result == nil || result.Clean + if clean != tc.allow { + t.Fatalf("clean=%t want %t", clean, tc.allow) + } + recorded := false + for _, a := range allows { + recorded = recorded || a.PatternName == "JWT Token" + } + if recorded != tc.allow { + t.Fatalf("JWT allowance=%t want %t", recorded, tc.allow) + } + }) + } + } +} diff --git a/internal/scanner/core.go b/internal/scanner/core.go index a5b5e39598..0a4205b954 100644 --- a/internal/scanner/core.go +++ b/internal/scanner/core.go @@ -48,7 +48,7 @@ type coreDLPPattern struct { severity string credentialAudienceHosts []string credentialAudienceAuthorizationOnly bool - credentialAudienceCarrierMask uint16 + credentialAudienceCarrierMask uint32 credentialAudienceGitHosts []string credentialAudienceRegistryHosts []string } diff --git a/internal/scanner/credential_audience.go b/internal/scanner/credential_audience.go index 5354847a2d..8dd8d1be8a 100644 --- a/internal/scanner/credential_audience.go +++ b/internal/scanner/credential_audience.go @@ -49,10 +49,12 @@ type credentialAudienceCandidate struct { patternName string hosts []string authorizationOnly bool - carrierMask uint16 + carrierMask uint32 gitHosts []string registryHosts []string headerValue string + valueIdentity [32]byte + hasValueIdentity bool // encoded is the decoding view the match came from; empty for the raw // value. A carrier that reads claims from the header value can only bind // them to a match found in that raw value. @@ -123,7 +125,7 @@ func CredentialAudienceHeaderSurface(headerName, value string) string { } } -func audienceSurfacePermitted(surface string, authorizationOnly bool, mask uint16) bool { +func audienceSurfacePermitted(surface string, authorizationOnly bool, mask uint32) bool { if !authorizationOnly && mask == 0 { return true } @@ -368,11 +370,20 @@ func registryBearerAllowed(candidate credentialAudienceCandidate, host, target, if err != nil || !strings.EqualFold(parsed.Scheme, "https") { return false } + token, ok := matchedBearerToken(candidate) + return ok && registryJWTAudienceMatches(token, host) +} + +// matchedBearerToken binds claims to the bytes that produced the DLP finding. +// Findings retain a SHA-256 value identity rather than credential text. Missing +// identity, a substring, or a different token must never borrow this bearer. +func matchedBearerToken(candidate credentialAudienceCandidate) (string, bool) { fields := strings.Fields(candidate.headerValue) - if len(fields) != 2 || !strings.EqualFold(fields[0], "Bearer") { - return false + if len(fields) != 2 || !strings.EqualFold(fields[0], "Bearer") || + !candidate.hasValueIdentity || sha256.Sum256([]byte(fields[1])) != candidate.valueIdentity { + return "", false } - return registryJWTAudienceMatches(fields[1], host) + return fields[1], true } // registryJWTAudienceMatches reports whether token is a registry JWT whose @@ -679,14 +690,14 @@ func (s *Scanner) credentialAudienceAllows(pattern *compiledPattern, target, sur } func (s *Scanner) credentialAudienceAllowsAt(pattern *compiledPattern, target, surface string, now time.Time) (CredentialAudienceAllow, bool) { - return s.credentialAudienceAllowsWithHeaderAt(pattern, target, surface, "", now) + return s.credentialAudienceAllowsWithHeaderAt(pattern, target, surface, "", "", now) } -func (s *Scanner) credentialAudienceAllowsWithHeader(pattern *compiledPattern, target, surface, headerValue string) (CredentialAudienceAllow, bool) { - return s.credentialAudienceAllowsWithHeaderAt(pattern, target, surface, headerValue, s.currentTime()) +func (s *Scanner) credentialAudienceAllowsWithHeader(pattern *compiledPattern, target, surface, headerValue, matchedValue string) (CredentialAudienceAllow, bool) { + return s.credentialAudienceAllowsWithHeaderAt(pattern, target, surface, headerValue, matchedValue, s.currentTime()) } -func (s *Scanner) credentialAudienceAllowsWithHeaderAt(pattern *compiledPattern, target, surface, headerValue string, now time.Time) (CredentialAudienceAllow, bool) { +func (s *Scanner) credentialAudienceAllowsWithHeaderAt(pattern *compiledPattern, target, surface, headerValue, matchedValue string, now time.Time) (CredentialAudienceAllow, bool) { if pattern == nil { return CredentialAudienceAllow{}, false } @@ -698,6 +709,8 @@ func (s *Scanner) credentialAudienceAllowsWithHeaderAt(pattern *compiledPattern, gitHosts: pattern.credentialAudienceGitHosts, registryHosts: pattern.credentialAudienceRegistryHosts, headerValue: headerValue, + valueIdentity: sha256.Sum256([]byte(matchedValue)), + hasValueIdentity: matchedValue != "", }}, target, surface, now) if len(keep) != 1 || keep[0] || len(allows) != 1 { return CredentialAudienceAllow{}, false @@ -811,6 +824,8 @@ func (s *Scanner) filterTextDLPMatchesForDestination(matches []TextDLPMatch, tar candidates[i].gitHosts = match.credentialAudienceGitHosts candidates[i].registryHosts = match.credentialAudienceRegistryHosts candidates[i].headerValue = headerValue + candidates[i].valueIdentity = match.valueIdentity + candidates[i].hasValueIdentity = match.hasValueIdentity candidates[i].encoded = match.Encoded } keep, allows := filterCredentialAudienceAt(candidates, target, surface, s.currentTime()) @@ -842,10 +857,13 @@ func (s *Scanner) ScrubAuthorizedCredentialFromJoinedHeaders(headerName, value, if !pattern.credentialAudienceCarrierRestricted() { continue } - if _, ok := s.credentialAudienceAllowsWithHeader(pattern, target, surface, value); !ok { - continue - } - value = pattern.re.ReplaceAllString(value, authorizedCredentialPlaceholder) + headerValue := value + value = pattern.re.ReplaceAllStringFunc(value, func(matchedValue string) string { + if _, ok := s.credentialAudienceAllowsWithHeader(pattern, target, surface, headerValue, matchedValue); ok { + return authorizedCredentialPlaceholder + } + return matchedValue + }) } return s.scrubAuthorizedEncodedFields(value, target, surface) } diff --git a/internal/scanner/credential_audience_actions_results_test.go b/internal/scanner/credential_audience_actions_results_test.go index 0a731c9d5d..88c1fe07cd 100644 --- a/internal/scanner/credential_audience_actions_results_test.go +++ b/internal/scanner/credential_audience_actions_results_test.go @@ -399,7 +399,7 @@ func TestActionsResultsSASCarrier(t *testing.T) { } }) } - for _, mask := range []uint16{0, config.CredentialAudienceCarrierURLQuery, config.CredentialAudienceCarrierReleaseGrantSAS} { + for _, mask := range []uint32{0, config.CredentialAudienceCarrierURLQuery, config.CredentialAudienceCarrierReleaseGrantSAS} { candidate := credentialAudienceCandidate{carrierMask: mask} got := githubBlobSASCandidateAllowed(candidate, actionsResultsHost, target, credentialAudienceURLQuerySurface, now) if got != (mask == config.CredentialAudienceCarrierReleaseGrantSAS) { diff --git a/internal/scanner/credential_signin.go b/internal/scanner/credential_signin.go index 49c6acd800..96c5b3bbbd 100755 --- a/internal/scanner/credential_signin.go +++ b/internal/scanner/credential_signin.go @@ -7,6 +7,8 @@ import ( "encoding/json" "net/url" "strings" + + "github.com/luckyPipewrench/pipelock/internal/config" ) // signInBearerRoute is one provider API that accepts a sign-in access token, @@ -50,7 +52,7 @@ func signInBearerAllowed(candidate credentialAudienceCandidate, host, target, su // joins fields, and the single-token shape below leaves nothing to join. A // token visible only after decoding (percent, base64, hex) could sit beside // a genuine sign-in token and borrow its claims. - if candidate.patternName != "JWT Token" || surface != CredentialAudienceAuthorizationHeaderSurface || + if candidate.carrierMask&config.CredentialAudienceCarrierSignInBearer == 0 || surface != CredentialAudienceAuthorizationHeaderSurface || (candidate.encoded != "" && candidate.encoded != "whitespace") { return false } @@ -67,11 +69,11 @@ func signInBearerAllowed(candidate credentialAudienceCandidate, host, target, su return false } } - fields := strings.Fields(candidate.headerValue) - if len(fields) != 2 || !strings.EqualFold(fields[0], "Bearer") { + token, ok := matchedBearerToken(candidate) + if !ok { return false } - parts := strings.Split(fields[1], ".") + parts := strings.Split(token, ".") if len(parts) != 3 { return false } diff --git a/internal/scanner/credential_signin_test.go b/internal/scanner/credential_signin_test.go index 06a69cc839..2175c1e2dc 100755 --- a/internal/scanner/credential_signin_test.go +++ b/internal/scanner/credential_signin_test.go @@ -5,9 +5,12 @@ package scanner import ( "context" + "crypto/sha256" "encoding/base64" "strings" "testing" + + "github.com/luckyPipewrench/pipelock/internal/config" ) func TestSignInBearerAudience(t *testing.T) { @@ -86,16 +89,16 @@ func TestSignInBearerAudience(t *testing.T) { } } -// The route is earned only by the JWT pattern; another pattern's match in the -// same Authorization value gets nothing from it. +// The route requires the compiled sign-in carrier; a pattern name alone +// cannot earn it. func TestSignInBearerAllowedOnlyForJWTPattern(t *testing.T) { t.Parallel() codex := registryJWT(t, map[string]any{"iss": "https://auth.openai.com", "client_id": "app_EMoamEEZ73f0CkXaXp7hrann"}) - c := credentialAudienceCandidate{patternName: "OpenAI API Key", headerValue: "Bearer " + codex} + c := credentialAudienceCandidate{patternName: "JWT Token", headerValue: "Bearer " + codex, valueIdentity: sha256.Sum256([]byte(codex)), hasValueIdentity: true} if signInBearerAllowed(c, "chatgpt.com", "https://chatgpt.com/backend-api/codex", CredentialAudienceAuthorizationHeaderSurface) { - t.Fatal("non-JWT pattern earned the sign-in route") + t.Fatal("pattern without the compiled carrier earned the sign-in route") } - c.patternName = "JWT Token" + c.carrierMask = config.CredentialAudienceCarrierSignInBearer if signInBearerAllowed(c, "chatgpt.com", "https://chatgpt.com/backend-api/codex", credentialAudienceURLQuerySurface) { t.Fatal("URL query surface earned the sign-in route") } @@ -106,10 +109,12 @@ func TestSignInBearerAllowedOnlyForJWTPattern(t *testing.T) { t.Fatal("unparseable target allowed") } c.headerValue = "Bearer not.a-jwt" + c.valueIdentity = sha256.Sum256([]byte("not.a-jwt")) if signInBearerAllowed(c, "chatgpt.com", "https://chatgpt.com/backend-api/codex", CredentialAudienceAuthorizationHeaderSurface) { t.Fatal("malformed token allowed") } c.headerValue = "Bearer a.%%%.b" + c.valueIdentity = sha256.Sum256([]byte("a.%%%.b")) if signInBearerAllowed(c, "chatgpt.com", "https://chatgpt.com/backend-api/codex", CredentialAudienceAuthorizationHeaderSurface) { t.Fatal("undecodable payload allowed") } @@ -134,9 +139,79 @@ func TestSignInBearerAllowedSchemeAndHeaderShape(t *testing.T) { {"bare token", "https://chatgpt.com/backend-api/codex", codex, false}, {"empty value", "https://chatgpt.com/backend-api/codex", "", false}, } { - c := credentialAudienceCandidate{patternName: "JWT Token", headerValue: tc.value} + c := credentialAudienceCandidate{patternName: "JWT Token", carrierMask: config.CredentialAudienceCarrierSignInBearer, headerValue: tc.value, valueIdentity: sha256.Sum256([]byte(codex)), hasValueIdentity: true} if got := signInBearerAllowed(c, "chatgpt.com", tc.target, surface); got != tc.allow { t.Errorf("%s: allowed=%t want %t", tc.name, got, tc.allow) } } } + +func TestSignInBearerMatchedValue(t *testing.T) { + t.Parallel() + route := signInBearerRoutes[0] + token := registryJWT(t, map[string]any{"iss": route.issuer, "client_id": route.clientID}) + other := registryJWT(t, map[string]any{"iss": "https://auth.vendor.example", "client_id": "app_other"}) + target := "https://" + route.host + route.prefix + "/responses" + s := MustNew(credentialAudienceTestConfig()) + t.Cleanup(s.Close) + for _, tc := range []struct { + name, matched, value string + allow bool + }{ + {"whole token", token, "Bearer " + token, true}, + {"whitespace", token, " bEaReR\t" + token + " ", true}, + {"different token", other, "Bearer " + token, false}, + {"trailing garbage", token, "Bearer " + token + "!", false}, + {"prefix garbage", token, "Bearer !" + token, false}, + {"second token", other, "Bearer " + token + "!" + other, false}, + {"token elsewhere", token, "Bearer placeholder " + token, false}, + {"case differs", token[:len(token)-16] + strings.Repeat("A", 16), "Bearer " + token, false}, + } { + t.Run(tc.name, func(t *testing.T) { + matches := s.ScanTextForDLP(t.Context(), tc.matched).Matches + if !matchRetained(matches, "JWT Token") { + t.Fatal("fixture must match JWT pattern") + } + retained, allows := s.FilterHeaderDLPMatches(matches, target, "Authorization", tc.value) + if audienceAllowFor(allows, "JWT Token") != tc.allow || matchRetained(retained, "JWT Token") == tc.allow { + t.Fatalf("allow=%t retained=%v records=%v", tc.allow, retained, allows) + } + if tc.matched == token { + scrubbed := s.ScrubAuthorizedCredentialFromJoinedHeaders("Authorization", tc.value, target) + if (scrubbed != tc.value) != tc.allow { + t.Fatalf("scrubbed=%t want %t", scrubbed != tc.value, tc.allow) + } + } + }) + } + candidate := credentialAudienceCandidate{carrierMask: config.CredentialAudienceCarrierSignInBearer, headerValue: "Bearer " + token} + if signInBearerAllowed(candidate, route.host, target, CredentialAudienceAuthorizationHeaderSurface) { + t.Fatal("missing match identity earned allowance") + } +} + +func TestSignInBearerCustomPattern(t *testing.T) { + t.Parallel() + route := signInBearerRoutes[0] + token := registryJWT(t, map[string]any{"iss": route.issuer, "client_id": route.clientID}) + cfg := credentialAudienceTestConfig() + for i := range cfg.DLP.Patterns { + if cfg.DLP.Patterns[i].Name == "JWT Token" { + cfg.DLP.Patterns[i].Regex += `|custom-[a-z]+` + } + } + cfg.ApplyDefaults() + if err := cfg.Validate(); err != nil { + t.Fatal(err) + } + s := MustNew(cfg) + t.Cleanup(s.Close) + matches := s.ScanTextForDLP(t.Context(), token).Matches + if !matchRetained(matches, "JWT Token") { + t.Fatal("custom JWT pattern must still detect token") + } + retained, allows := s.FilterHeaderDLPMatches(matches, "https://"+route.host+route.prefix+"/responses", "Authorization", "Bearer "+token) + if !matchRetained(retained, "JWT Token") || audienceAllowFor(allows, "JWT Token") { + t.Fatalf("custom JWT pattern earned allowance: retained=%v records=%v", retained, allows) + } +} diff --git a/internal/scanner/registry_audience_test.go b/internal/scanner/registry_audience_test.go index 273554af75..924e5ef83a 100644 --- a/internal/scanner/registry_audience_test.go +++ b/internal/scanner/registry_audience_test.go @@ -5,6 +5,7 @@ package scanner import ( "context" + "crypto/sha256" "encoding/base64" "encoding/json" "strings" @@ -183,6 +184,9 @@ func TestRegistryBearerRejectsMalformedRequests(t *testing.T) { {"basic scheme", "https://ghcr.io/v2/", "Basic " + jwt}, {"extra field", "https://ghcr.io/v2/", "Bearer " + jwt + " x"}, {"missing value", "https://ghcr.io/v2/", ""}, + {"trailing garbage", "https://ghcr.io/v2/", "Bearer " + jwt + "!"}, + {"prefix garbage", "https://ghcr.io/v2/", "Bearer !" + jwt}, + {"different token", "https://ghcr.io/v2/", "Bearer " + registryJWT(t, map[string]any{"aud": "ghcr.io", "access": []any{"other"}})}, } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { @@ -210,9 +214,11 @@ func TestRegistryAllowGuardsDirect(t *testing.T) { headerValue: registryBasic("octocat", gh), } bearer := credentialAudienceCandidate{ - carrierMask: config.CredentialAudienceCarrierRegistryBearer, - registryHosts: []string{"ghcr.io"}, - headerValue: "Bearer " + jwt, + carrierMask: config.CredentialAudienceCarrierRegistryBearer, + registryHosts: []string{"ghcr.io"}, + headerValue: "Bearer " + jwt, + valueIdentity: sha256.Sum256([]byte(jwt)), + hasValueIdentity: true, } if !registryBasicAllowed(basic, "ghcr.io", "https://ghcr.io/token", credentialAudienceAuthorizationBasicSurface) { t.Fatal("positive basic control refused") diff --git a/internal/scanner/scanner.go b/internal/scanner/scanner.go index 898bdecd23..b1a3be00e3 100644 --- a/internal/scanner/scanner.go +++ b/internal/scanner/scanner.go @@ -483,7 +483,7 @@ type compiledPattern struct { core bool // name belongs to the immutable floor: exemptDomains is never honored credentialAudienceHosts []string // compiled built-ins only; empty means no audience exception credentialAudienceAuthorizationOnly bool // compiled built-ins only; limits the allow to Authorization headers - credentialAudienceCarrierMask uint16 // compiled built-ins only; which headers may carry the credential + credentialAudienceCarrierMask uint32 // compiled built-ins only; which headers may carry the credential credentialAudienceGitHosts []string // compiled built-ins only; hosts of the git-over-HTTPS Basic rule credentialAudienceRegistryHosts []string // compiled built-ins only; hosts of the package-registry rule bundle string // empty for built-in/config patterns diff --git a/internal/scanner/text_dlp.go b/internal/scanner/text_dlp.go index 4fbe566547..15bb2b5983 100644 --- a/internal/scanner/text_dlp.go +++ b/internal/scanner/text_dlp.go @@ -340,7 +340,7 @@ type TextDLPMatch struct { span MatchSpan credentialAudienceHosts []string credentialAudienceAuthorizationOnly bool - credentialAudienceCarrierMask uint16 + credentialAudienceCarrierMask uint32 credentialAudienceGitHosts []string credentialAudienceRegistryHosts []string } From 223e27a69f2c29f9716c21e395780f68898a4814 Mon Sep 17 00:00:00 2001 From: luckyPipewrench Date: Sun, 8 Nov 2026 21:00:19 -0500 Subject: [PATCH 2/6] test(scanner): expect the sign-in carrier on the JWT audience --- internal/scanner/credential_audience_url_query_test.go | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/internal/scanner/credential_audience_url_query_test.go b/internal/scanner/credential_audience_url_query_test.go index fe70520e43..92108b058b 100644 --- a/internal/scanner/credential_audience_url_query_test.go +++ b/internal/scanner/credential_audience_url_query_test.go @@ -356,7 +356,8 @@ func TestFilterTextDLPMatchesForDestination_GitHubReleaseGrantJWTOnlyInQuery(t * } // No other built-in audience gained query carriage. The JWT built-in is the -// only pattern on CredentialAudienceCarrierURLQuery; the Azure SAS Token +// only pattern on CredentialAudienceCarrierURLQuery (it also carries the +// registry and sign-in bearer bits, none of which grants query carriage); the Azure SAS Token // built-in is the only pattern on the separate ReleaseGrantSAS carrier, and // shares the JWT's exact host list because it is granted only alongside that // same JWT grant, never on its own. @@ -367,7 +368,7 @@ func TestBuiltInAudiencesQueryCarriageIsJWTOnly(t *testing.T) { hasReleaseSAS := p.CredentialAudienceCarrierMask&config.CredentialAudienceCarrierReleaseGrantSAS != 0 switch p.Name { case jwtPatternName: - if !hasQuery || hasReleaseSAS || p.CredentialAudienceCarrierMask != config.CredentialAudienceCarrierURLQuery|config.CredentialAudienceCarrierRegistryBearer|config.CredentialAudienceCarrierRegistryBasic || + if !hasQuery || hasReleaseSAS || p.CredentialAudienceCarrierMask != config.CredentialAudienceCarrierURLQuery|config.CredentialAudienceCarrierRegistryBearer|config.CredentialAudienceCarrierRegistryBasic|config.CredentialAudienceCarrierSignInBearer || len(p.CredentialAudienceHosts) != 1 || p.CredentialAudienceHosts[0] != githubReleaseAssetsHost { t.Errorf("JWT audience = hosts %v mask %d", p.CredentialAudienceHosts, p.CredentialAudienceCarrierMask) } From f0c10863b8bbb9d7e215863bfef0030d2bf6a9eb Mon Sep 17 00:00:00 2001 From: luckyPipewrench Date: Sun, 8 Nov 2026 21:54:35 -0500 Subject: [PATCH 3/6] test(config): rebaseline default-corpus policy hashes for the sign-in carrier --- internal/config/canonical_golden_test.go | 8 ++++---- internal/config/dlp_keywords_test.go | 5 +++-- 2 files changed, 7 insertions(+), 6 deletions(-) diff --git a/internal/config/canonical_golden_test.go b/internal/config/canonical_golden_test.go index c125e360e4..a859da132f 100644 --- a/internal/config/canonical_golden_test.go +++ b/internal/config/canonical_golden_test.go @@ -1282,11 +1282,11 @@ func TestCanonicalPolicyHash_NewToolAdmissionVocabularyGolden(t *testing.T) { }{ // These fixtures inherit the default credential audiences and sensitive // headers, including Api-Key. The hashes include the structured Discord - // Gateway carrier and the publisher SDK Together host, matching the - // default and rich-config goldens above. + // Gateway carrier, the publisher SDK Together host and the JWT sign-in + // bearer carrier, matching the default and rich-config goldens above. // They inherit the RFC 9110 default URI limit of 8000 octets. - {name: "admit remains warn", admission: NewToolAdmit, wantHash: "cbea0950f1b877eba93dd0baa67394ae8d47ea14d1ac7e9dc309604f22a065a1"}, - {name: "withhold remains block", admission: NewToolWithhold, wantHash: "64f268562607b1b5467558847bdbb41346c668e5c5ffc0e40559358e64bf8260"}, + {name: "admit remains warn", admission: NewToolAdmit, wantHash: "ba2cd65c3708db0faa29ce29a9c82015e0b6a7cb499849d26b6f2b24f1394f67"}, + {name: "withhold remains block", admission: NewToolWithhold, wantHash: "4679f249c6c0852349f58617b5773094ca364da0a67a3637499f0c4f17d94f0d"}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { diff --git a/internal/config/dlp_keywords_test.go b/internal/config/dlp_keywords_test.go index eaab60a09d..ec99109149 100644 --- a/internal/config/dlp_keywords_test.go +++ b/internal/config/dlp_keywords_test.go @@ -225,12 +225,13 @@ func TestDLPKeywordsDocsAndPresets(t *testing.T) { func TestDLPKeywordsLegacyPolicyHashGolden(t *testing.T) { // Legacy values were captured from the pre-keyword policy at // 06907c6982b07f25905d4d40782cb9d76badd6cb. The balanced default-corpus - // case was rebaselined for the Anthropic credential audience change. + // case was rebaselined for the Anthropic credential audience change and + // the JWT sign-in bearer carrier. // Balanced mode without default patterns retains its pre-keyword hash // independently of changes to the built-in corpus. for _, tc := range []struct{ body, want string }{ {keywordConfigYAML(""), "85c76016e5a0725a682561e23f18dd8ebade2ada53e72c7324cbb511f7749ed6"}, - {"mode: balanced\n", "5d8f1ffcec94a274d83727cea8daab74f84fd7ecbd39e78ad09a681ea4eebc29"}, + {"mode: balanced\n", "f1dc5ee228617541bb01320f32d9be052e4e6ee2d102eb69d22bca769fd7e90f"}, {"mode: balanced\ndlp:\n include_defaults: false\n", "66df31a95a2ce552d4d50fc55356f0dd204d8bf3999a3bfa90bf9c3332710d86"}, } { cfg, err := LoadBytes([]byte(tc.body)) From bb05899dfc7ff25390737bee322470ed324ef550 Mon Sep 17 00:00:00 2001 From: luckyPipewrench Date: Sun, 8 Nov 2026 23:09:58 -0500 Subject: [PATCH 4/6] test(proxy): regenerate decision inventory policy hashes for the sign-in carrier --- .../decision-inventory-policy.golden.jsonl | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/internal/proxy/testdata/decision-inventory-policy.golden.jsonl b/internal/proxy/testdata/decision-inventory-policy.golden.jsonl index e22353c6eb..152eb6fce2 100644 --- a/internal/proxy/testdata/decision-inventory-policy.golden.jsonl +++ b/internal/proxy/testdata/decision-inventory-policy.golden.jsonl @@ -1,8 +1,8 @@ -{"preset":"defaults","runtime_hashes":{"forward":"3f64d286c29151a2a2424712fa6d2881b4e293b2ce68a05f642fe4def3e26423","forward-mcp-listener":"6ac58780e915b4323bda7fbc88fcec1b0c63b52290187a86ecc728b0266f32e7","mcp-proxy":"6ac58780e915b4323bda7fbc88fcec1b0c63b52290187a86ecc728b0266f32e7","mcp-scan":"fd5870e832d546336d04c3953940bc6533a0a213575d49cfe751c8effb6171c7"}} -{"preset":"strict","runtime_hashes":{"forward":"12a7d7eed8fbe470abf07d86599de62967c5d1ddc5d238ad91f31a275d76732f","forward-mcp-listener":"12a7d7eed8fbe470abf07d86599de62967c5d1ddc5d238ad91f31a275d76732f","mcp-proxy":"12a7d7eed8fbe470abf07d86599de62967c5d1ddc5d238ad91f31a275d76732f","mcp-scan":"12a7d7eed8fbe470abf07d86599de62967c5d1ddc5d238ad91f31a275d76732f"}} -{"preset":"balanced","runtime_hashes":{"forward":"5494adbb954e8799f9af7160087d3ef82b47ff018544fbf6686596a2b9710e48","forward-mcp-listener":"5494adbb954e8799f9af7160087d3ef82b47ff018544fbf6686596a2b9710e48","mcp-proxy":"5494adbb954e8799f9af7160087d3ef82b47ff018544fbf6686596a2b9710e48","mcp-scan":"5494adbb954e8799f9af7160087d3ef82b47ff018544fbf6686596a2b9710e48"}} -{"preset":"audit","runtime_hashes":{"forward":"83728725f252c10a34646d6cfd78b2ab11f2a6f6e1120e86fbad5630bdd2f976","forward-mcp-listener":"83728725f252c10a34646d6cfd78b2ab11f2a6f6e1120e86fbad5630bdd2f976","mcp-proxy":"83728725f252c10a34646d6cfd78b2ab11f2a6f6e1120e86fbad5630bdd2f976","mcp-scan":"83728725f252c10a34646d6cfd78b2ab11f2a6f6e1120e86fbad5630bdd2f976"}} -{"preset":"claude-code","runtime_hashes":{"forward":"46af5c3b3b9c79b152231de1f57b5c2234465176d3f63fe1b3b2856ac663a321","forward-mcp-listener":"46af5c3b3b9c79b152231de1f57b5c2234465176d3f63fe1b3b2856ac663a321","mcp-proxy":"46af5c3b3b9c79b152231de1f57b5c2234465176d3f63fe1b3b2856ac663a321","mcp-scan":"46af5c3b3b9c79b152231de1f57b5c2234465176d3f63fe1b3b2856ac663a321"}} -{"preset":"cursor","runtime_hashes":{"forward":"dcc6dcd297f21043abde232aa3be413c5bc469398274595a305b646110274a23","forward-mcp-listener":"dcc6dcd297f21043abde232aa3be413c5bc469398274595a305b646110274a23","mcp-proxy":"dcc6dcd297f21043abde232aa3be413c5bc469398274595a305b646110274a23","mcp-scan":"dcc6dcd297f21043abde232aa3be413c5bc469398274595a305b646110274a23"}} -{"preset":"generic-agent","runtime_hashes":{"forward":"02098eae1d3bc31891981c6db388ebdb95c43623af2074fec869d72aa16fa042","forward-mcp-listener":"02098eae1d3bc31891981c6db388ebdb95c43623af2074fec869d72aa16fa042","mcp-proxy":"02098eae1d3bc31891981c6db388ebdb95c43623af2074fec869d72aa16fa042","mcp-scan":"02098eae1d3bc31891981c6db388ebdb95c43623af2074fec869d72aa16fa042"}} +{"preset":"defaults","runtime_hashes":{"forward":"c081da4676a58b9255ef9206c10edea8e1c092dcbd246b6851d42e8015cdb902","forward-mcp-listener":"e11a9f8947735cfb0472eecd433f9475001534f70977720f6072ea9a33b84732","mcp-proxy":"e11a9f8947735cfb0472eecd433f9475001534f70977720f6072ea9a33b84732","mcp-scan":"b3643a982b8fc372c1066315bbe2b35220a88fed6b23966f6057e47b6799987d"}} +{"preset":"strict","runtime_hashes":{"forward":"c61ba235de340275166d2a573be4c2661b725e1269827a4c5c7d52249e038134","forward-mcp-listener":"c61ba235de340275166d2a573be4c2661b725e1269827a4c5c7d52249e038134","mcp-proxy":"c61ba235de340275166d2a573be4c2661b725e1269827a4c5c7d52249e038134","mcp-scan":"c61ba235de340275166d2a573be4c2661b725e1269827a4c5c7d52249e038134"}} +{"preset":"balanced","runtime_hashes":{"forward":"476193c2b2269a98de3faddc30967d7da010b1c7a6ad19da49648c30e191ffb9","forward-mcp-listener":"476193c2b2269a98de3faddc30967d7da010b1c7a6ad19da49648c30e191ffb9","mcp-proxy":"476193c2b2269a98de3faddc30967d7da010b1c7a6ad19da49648c30e191ffb9","mcp-scan":"476193c2b2269a98de3faddc30967d7da010b1c7a6ad19da49648c30e191ffb9"}} +{"preset":"audit","runtime_hashes":{"forward":"e4b2b8250f57604e37a605b9590a0eed0ddfa833d0b5d3c925fdfe5dd2149428","forward-mcp-listener":"e4b2b8250f57604e37a605b9590a0eed0ddfa833d0b5d3c925fdfe5dd2149428","mcp-proxy":"e4b2b8250f57604e37a605b9590a0eed0ddfa833d0b5d3c925fdfe5dd2149428","mcp-scan":"e4b2b8250f57604e37a605b9590a0eed0ddfa833d0b5d3c925fdfe5dd2149428"}} +{"preset":"claude-code","runtime_hashes":{"forward":"396da27c724df54fad5a5b0242052d125daf7c0408f8193896f496e1503479f1","forward-mcp-listener":"396da27c724df54fad5a5b0242052d125daf7c0408f8193896f496e1503479f1","mcp-proxy":"396da27c724df54fad5a5b0242052d125daf7c0408f8193896f496e1503479f1","mcp-scan":"396da27c724df54fad5a5b0242052d125daf7c0408f8193896f496e1503479f1"}} +{"preset":"cursor","runtime_hashes":{"forward":"e2b408a2b56b33843ad9ad25e5e2575b661a7350ba2490156a8523af4d15da2d","forward-mcp-listener":"e2b408a2b56b33843ad9ad25e5e2575b661a7350ba2490156a8523af4d15da2d","mcp-proxy":"e2b408a2b56b33843ad9ad25e5e2575b661a7350ba2490156a8523af4d15da2d","mcp-scan":"e2b408a2b56b33843ad9ad25e5e2575b661a7350ba2490156a8523af4d15da2d"}} +{"preset":"generic-agent","runtime_hashes":{"forward":"6ce9b59110c67fc36bceb47ee427bc725a06ce9c9d86e7cf3285bcc31eac84c8","forward-mcp-listener":"6ce9b59110c67fc36bceb47ee427bc725a06ce9c9d86e7cf3285bcc31eac84c8","mcp-proxy":"6ce9b59110c67fc36bceb47ee427bc725a06ce9c9d86e7cf3285bcc31eac84c8","mcp-scan":"6ce9b59110c67fc36bceb47ee427bc725a06ce9c9d86e7cf3285bcc31eac84c8"}} {"preset":"hostile-model","runtime_hashes":{"forward":"31625bc0405ef368b175431121e2b4ee09f9676fb6c9a3171a8d7f7cdd4666a6","forward-mcp-listener":"31625bc0405ef368b175431121e2b4ee09f9676fb6c9a3171a8d7f7cdd4666a6","mcp-proxy":"31625bc0405ef368b175431121e2b4ee09f9676fb6c9a3171a8d7f7cdd4666a6","mcp-scan":"31625bc0405ef368b175431121e2b4ee09f9676fb6c9a3171a8d7f7cdd4666a6"}} From 21b78d1978e490e1d3c1ce369e00feaabcbf3737 Mon Sep 17 00:00:00 2001 From: luckyPipewrench Date: Sun, 8 Nov 2026 23:15:05 -0500 Subject: [PATCH 5/6] perf(scanner): parse the bearer once per header when binding matches Each JWT-shaped match reparsed and rehashed the whole Authorization value, so a large value with many matches cost matches x length. Parse it once per header value and share the result; a candidate without a shared parse still parses on demand. --- internal/scanner/credential_audience.go | 47 ++++++++++++++++++---- internal/scanner/credential_signin_test.go | 36 +++++++++++++++++ 2 files changed, 75 insertions(+), 8 deletions(-) diff --git a/internal/scanner/credential_audience.go b/internal/scanner/credential_audience.go index 8dd8d1be8a..bd94635f52 100644 --- a/internal/scanner/credential_audience.go +++ b/internal/scanner/credential_audience.go @@ -59,6 +59,27 @@ type credentialAudienceCandidate struct { // value. A carrier that reads claims from the header value can only bind // them to a match found in that raw value. encoded string + // bearer, when set, is headerValue already parsed once for every + // candidate that shares it; nil parses on demand. + bearer *parsedBearer +} + +// parsedBearer is an Authorization value split into the Bearer scheme and its +// single token, with the token's SHA-256 identity. Matches of one header share +// it so binding a match costs one comparison instead of reparsing and +// rehashing the whole value for every match. +type parsedBearer struct { + token string + digest [32]byte + ok bool +} + +func parseBearer(headerValue string) *parsedBearer { + fields := strings.Fields(headerValue) + if len(fields) != 2 || !strings.EqualFold(fields[0], "Bearer") { + return &parsedBearer{} + } + return &parsedBearer{token: fields[1], digest: sha256.Sum256([]byte(fields[1])), ok: true} } // CredentialAudienceAuthorizationHeaderSurface distinguishes Authorization from other @@ -378,12 +399,17 @@ func registryBearerAllowed(candidate credentialAudienceCandidate, host, target, // Findings retain a SHA-256 value identity rather than credential text. Missing // identity, a substring, or a different token must never borrow this bearer. func matchedBearerToken(candidate credentialAudienceCandidate) (string, bool) { - fields := strings.Fields(candidate.headerValue) - if len(fields) != 2 || !strings.EqualFold(fields[0], "Bearer") || - !candidate.hasValueIdentity || sha256.Sum256([]byte(fields[1])) != candidate.valueIdentity { + if !candidate.hasValueIdentity { return "", false } - return fields[1], true + bearer := candidate.bearer + if bearer == nil { + bearer = parseBearer(candidate.headerValue) + } + if !bearer.ok || bearer.digest != candidate.valueIdentity { + return "", false + } + return bearer.token, true } // registryJWTAudienceMatches reports whether token is a registry JWT whose @@ -693,11 +719,11 @@ func (s *Scanner) credentialAudienceAllowsAt(pattern *compiledPattern, target, s return s.credentialAudienceAllowsWithHeaderAt(pattern, target, surface, "", "", now) } -func (s *Scanner) credentialAudienceAllowsWithHeader(pattern *compiledPattern, target, surface, headerValue, matchedValue string) (CredentialAudienceAllow, bool) { - return s.credentialAudienceAllowsWithHeaderAt(pattern, target, surface, headerValue, matchedValue, s.currentTime()) +func (s *Scanner) credentialAudienceAllowsWithHeaderAt(pattern *compiledPattern, target, surface, headerValue, matchedValue string, now time.Time) (CredentialAudienceAllow, bool) { + return s.credentialAudienceAllowsWithBearerAt(pattern, target, surface, headerValue, nil, matchedValue, now) } -func (s *Scanner) credentialAudienceAllowsWithHeaderAt(pattern *compiledPattern, target, surface, headerValue, matchedValue string, now time.Time) (CredentialAudienceAllow, bool) { +func (s *Scanner) credentialAudienceAllowsWithBearerAt(pattern *compiledPattern, target, surface, headerValue string, bearer *parsedBearer, matchedValue string, now time.Time) (CredentialAudienceAllow, bool) { if pattern == nil { return CredentialAudienceAllow{}, false } @@ -711,6 +737,7 @@ func (s *Scanner) credentialAudienceAllowsWithHeaderAt(pattern *compiledPattern, headerValue: headerValue, valueIdentity: sha256.Sum256([]byte(matchedValue)), hasValueIdentity: matchedValue != "", + bearer: bearer, }}, target, surface, now) if len(keep) != 1 || keep[0] || len(allows) != 1 { return CredentialAudienceAllow{}, false @@ -816,6 +843,7 @@ func (s *Scanner) filterTextDLPMatchesForDestination(matches []TextDLPMatch, tar return matches, nil } candidates := make([]credentialAudienceCandidate, len(matches)) + bearer := parseBearer(headerValue) for i, match := range matches { candidates[i].patternName = match.PatternName candidates[i].hosts = match.credentialAudienceHosts @@ -827,6 +855,7 @@ func (s *Scanner) filterTextDLPMatchesForDestination(matches []TextDLPMatch, tar candidates[i].valueIdentity = match.valueIdentity candidates[i].hasValueIdentity = match.hasValueIdentity candidates[i].encoded = match.Encoded + candidates[i].bearer = bearer } keep, allows := filterCredentialAudienceAt(candidates, target, surface, s.currentTime()) filtered := make([]TextDLPMatch, 0, len(matches)) @@ -858,8 +887,10 @@ func (s *Scanner) ScrubAuthorizedCredentialFromJoinedHeaders(headerName, value, continue } headerValue := value + bearer := parseBearer(headerValue) + now := s.currentTime() value = pattern.re.ReplaceAllStringFunc(value, func(matchedValue string) string { - if _, ok := s.credentialAudienceAllowsWithHeader(pattern, target, surface, headerValue, matchedValue); ok { + if _, ok := s.credentialAudienceAllowsWithBearerAt(pattern, target, surface, headerValue, bearer, matchedValue, now); ok { return authorizedCredentialPlaceholder } return matchedValue diff --git a/internal/scanner/credential_signin_test.go b/internal/scanner/credential_signin_test.go index 2175c1e2dc..6e77da2aec 100755 --- a/internal/scanner/credential_signin_test.go +++ b/internal/scanner/credential_signin_test.go @@ -215,3 +215,39 @@ func TestSignInBearerCustomPattern(t *testing.T) { t.Fatalf("custom JWT pattern earned allowance: retained=%v records=%v", retained, allows) } } + +// Binding many JWT-shaped matches in one header must parse and hash the +// bearer once, and a shared parse must decide exactly like an on-demand one. +func TestMatchedBearerTokenSharedParse(t *testing.T) { + token := strings.Join([]string{"eyJh", "bGciOiJub25lIn0", ".eyJpc3MiOiJ4In0", ".sig"}, "") + header := "Bearer " + token + shared := parseBearer(header) + for _, tc := range []struct { + name string + header string + matched string + want bool + }{ + {"whole bearer", header, token, true}, + {"substring", header, token[:10], false}, + {"other token", header, token + "x", false}, + {"two tokens", "Bearer " + token + " " + token, token, false}, + {"basic scheme", "Basic " + token, token, false}, + {"empty match", header, "", false}, + } { + t.Run(tc.name, func(t *testing.T) { + candidate := credentialAudienceCandidate{headerValue: tc.header, valueIdentity: sha256.Sum256([]byte(tc.matched)), hasValueIdentity: tc.matched != ""} + _, onDemand := matchedBearerToken(candidate) + if onDemand != tc.want { + t.Fatalf("on-demand parse = %v, want %v", onDemand, tc.want) + } + candidate.bearer = parseBearer(tc.header) + if _, got := matchedBearerToken(candidate); got != onDemand { + t.Fatalf("shared parse = %v, on-demand = %v", got, onDemand) + } + }) + } + if !shared.ok || shared.token != token { + t.Fatal("shared parse lost the bearer token") + } +} From 5d6a7987cb313ca697f3584df0c644e21e2dc42c Mon Sep 17 00:00:00 2001 From: luckyPipewrench Date: Mon, 9 Nov 2026 06:43:28 -0500 Subject: [PATCH 6/6] docs(scanner): state what the sign-in claim check does not prove The sign-in bearer rule compares unverified iss and client_id claims, so a token from another issuer that copies them passes; it still reaches only the listed host and path. --- docs/security/provider-key-dlp-coverage.md | 2 +- internal/scanner/credential_signin.go | 7 ++++--- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/docs/security/provider-key-dlp-coverage.md b/docs/security/provider-key-dlp-coverage.md index d71afba7e3..22c50d5cf9 100644 --- a/docs/security/provider-key-dlp-coverage.md +++ b/docs/security/provider-key-dlp-coverage.md @@ -85,7 +85,7 @@ Coding clients that sign in with OAuth send the access token, a JWT, as the `Aut | `api.x.ai` | `/v1/...` | `https://auth.x.ai` | `b1a00492-073a-47ea-816f-4c329264a828` | [CLIProxyAPI](https://github.com/router-for-me/CLIProxyAPI) `v8.0.51`: `internal/auth/xai/types.go` | | `cli-chat-proxy.grok.com` | `/v1/...` | `https://auth.x.ai` | `b1a00492-073a-47ea-816f-4c329264a828` | Same | -Pipelock can't verify the token's signature, so the claims are a routing check, not proof of origin. A JWT from any other issuer or client stays blocked at these hosts, and a token with forged claims can reach only the issuer's own API host. Subdomains, other paths, escaped or dot path segments, cleartext, other headers, URL queries, and bodies don't qualify. +Pipelock can't verify the token's signature, so the claims are a routing check, not proof of origin. A JWT whose `iss` or `client_id` claim names another issuer or client stays blocked at these hosts. A token from another issuer that copies the listed claims passes this check, but it can reach only the listed host and path for those claims. Subdomains, other paths, escaped or dot path segments, cleartext, other headers, URL queries, and bodies don't qualify. ### Download grant bounds diff --git a/internal/scanner/credential_signin.go b/internal/scanner/credential_signin.go index 96c5b3bbbd..f6e9940701 100755 --- a/internal/scanner/credential_signin.go +++ b/internal/scanner/credential_signin.go @@ -34,9 +34,10 @@ type signInBearerRoute struct { // // The proxy cannot verify the token's signature. A token earns a route only // when its own iss and client_id claims name that provider and client, so a -// JWT from any other issuer or client keeps the block, and a forged token can -// reach only the issuer's own API host. The registry bearer rule makes the -// same trade. +// JWT whose claims name another issuer or client keeps the block. A token +// from another issuer that copies these claims passes, but it can reach only +// the route host and path those claims name. The registry bearer rule makes +// the same trade. var signInBearerRoutes = []signInBearerRoute{ {host: "chatgpt.com", prefix: "/backend-api", issuer: "https://auth.openai.com", clientID: "app_EMoamEEZ73f0CkXaXp7hrann"}, {host: "api.x.ai", prefix: "/v1", issuer: "https://auth.x.ai", clientID: "b1a00492-073a-47ea-816f-4c329264a828"},