diff --git a/docs/security/provider-key-dlp-coverage.md b/docs/security/provider-key-dlp-coverage.md index d71afba7e..22c50d5cf 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/config/canonical_golden_test.go b/internal/config/canonical_golden_test.go index 59f25e0b5..a859da132 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 @@ -1280,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/canonical_test.go b/internal/config/canonical_test.go index 3f295c538..8c313031d 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 b81a0336b..00375b2cb 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_keywords_test.go b/internal/config/dlp_keywords_test.go index eaab60a09..ec9910914 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)) diff --git a/internal/config/dlp_patterns.go b/internal/config/dlp_patterns.go index 199ec2a50..35d8efcdd 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 bd447bf5f..3aa8b46ad 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 60c4f1358..921ed1091 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/proxy/testdata/decision-inventory-policy.golden.jsonl b/internal/proxy/testdata/decision-inventory-policy.golden.jsonl index 12b4dd5bb..135a1b350 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":"6824aa712aff521d7b5a45f6cd6477fb04216387d854cbce6a34835be9affc8b","forward-mcp-listener":"6824aa712aff521d7b5a45f6cd6477fb04216387d854cbce6a34835be9affc8b","mcp-proxy":"6824aa712aff521d7b5a45f6cd6477fb04216387d854cbce6a34835be9affc8b","mcp-scan":"6824aa712aff521d7b5a45f6cd6477fb04216387d854cbce6a34835be9affc8b"}} -{"preset":"balanced","runtime_hashes":{"forward":"a3e6614c9c33f8b150c7981b46cdf5239085d5f615d9210a020ed61eacf6d4fa","forward-mcp-listener":"a3e6614c9c33f8b150c7981b46cdf5239085d5f615d9210a020ed61eacf6d4fa","mcp-proxy":"a3e6614c9c33f8b150c7981b46cdf5239085d5f615d9210a020ed61eacf6d4fa","mcp-scan":"a3e6614c9c33f8b150c7981b46cdf5239085d5f615d9210a020ed61eacf6d4fa"}} -{"preset":"audit","runtime_hashes":{"forward":"e650e8b0b47580fd7e1d30a75456b6987742cf2b6564e2043c6b048418219202","forward-mcp-listener":"e650e8b0b47580fd7e1d30a75456b6987742cf2b6564e2043c6b048418219202","mcp-proxy":"e650e8b0b47580fd7e1d30a75456b6987742cf2b6564e2043c6b048418219202","mcp-scan":"e650e8b0b47580fd7e1d30a75456b6987742cf2b6564e2043c6b048418219202"}} -{"preset":"claude-code","runtime_hashes":{"forward":"ea018524e4b220bf034552015c8276daecdfc48906675aeeebad0c3747d1e2fc","forward-mcp-listener":"ea018524e4b220bf034552015c8276daecdfc48906675aeeebad0c3747d1e2fc","mcp-proxy":"ea018524e4b220bf034552015c8276daecdfc48906675aeeebad0c3747d1e2fc","mcp-scan":"ea018524e4b220bf034552015c8276daecdfc48906675aeeebad0c3747d1e2fc"}} -{"preset":"cursor","runtime_hashes":{"forward":"499443aeb0de8b1c81f9f4b5d5d42a3c197257ba8646637095f72a181256b430","forward-mcp-listener":"499443aeb0de8b1c81f9f4b5d5d42a3c197257ba8646637095f72a181256b430","mcp-proxy":"499443aeb0de8b1c81f9f4b5d5d42a3c197257ba8646637095f72a181256b430","mcp-scan":"499443aeb0de8b1c81f9f4b5d5d42a3c197257ba8646637095f72a181256b430"}} -{"preset":"generic-agent","runtime_hashes":{"forward":"c1021e0f964201eb17fe93e91aad4dd198e81664027ad9808986c1c2c6c31862","forward-mcp-listener":"c1021e0f964201eb17fe93e91aad4dd198e81664027ad9808986c1c2c6c31862","mcp-proxy":"c1021e0f964201eb17fe93e91aad4dd198e81664027ad9808986c1c2c6c31862","mcp-scan":"c1021e0f964201eb17fe93e91aad4dd198e81664027ad9808986c1c2c6c31862"}} +{"preset":"defaults","runtime_hashes":{"forward":"c081da4676a58b9255ef9206c10edea8e1c092dcbd246b6851d42e8015cdb902","forward-mcp-listener":"e11a9f8947735cfb0472eecd433f9475001534f70977720f6072ea9a33b84732","mcp-proxy":"e11a9f8947735cfb0472eecd433f9475001534f70977720f6072ea9a33b84732","mcp-scan":"b3643a982b8fc372c1066315bbe2b35220a88fed6b23966f6057e47b6799987d"}} +{"preset":"strict","runtime_hashes":{"forward":"0958ec89f957b989dcb55a5d655877ad32a989255be6718209c1a53b99784ab0","forward-mcp-listener":"0958ec89f957b989dcb55a5d655877ad32a989255be6718209c1a53b99784ab0","mcp-proxy":"0958ec89f957b989dcb55a5d655877ad32a989255be6718209c1a53b99784ab0","mcp-scan":"0958ec89f957b989dcb55a5d655877ad32a989255be6718209c1a53b99784ab0"}} +{"preset":"balanced","runtime_hashes":{"forward":"21eca64ca0727944ffee8f924daccbb5f12b84934aee78d043e5ba7ce3576538","forward-mcp-listener":"21eca64ca0727944ffee8f924daccbb5f12b84934aee78d043e5ba7ce3576538","mcp-proxy":"21eca64ca0727944ffee8f924daccbb5f12b84934aee78d043e5ba7ce3576538","mcp-scan":"21eca64ca0727944ffee8f924daccbb5f12b84934aee78d043e5ba7ce3576538"}} +{"preset":"audit","runtime_hashes":{"forward":"d98b176172689c7fa0e6d49cfd336f55976323c16129c55ab7bcfe243bc30187","forward-mcp-listener":"d98b176172689c7fa0e6d49cfd336f55976323c16129c55ab7bcfe243bc30187","mcp-proxy":"d98b176172689c7fa0e6d49cfd336f55976323c16129c55ab7bcfe243bc30187","mcp-scan":"d98b176172689c7fa0e6d49cfd336f55976323c16129c55ab7bcfe243bc30187"}} +{"preset":"claude-code","runtime_hashes":{"forward":"560d4666e61c24d633f854902badde55ee9b45548a83c26a30bee7a040c13549","forward-mcp-listener":"560d4666e61c24d633f854902badde55ee9b45548a83c26a30bee7a040c13549","mcp-proxy":"560d4666e61c24d633f854902badde55ee9b45548a83c26a30bee7a040c13549","mcp-scan":"560d4666e61c24d633f854902badde55ee9b45548a83c26a30bee7a040c13549"}} +{"preset":"cursor","runtime_hashes":{"forward":"a0fe1784bc463abb899c551506bdab5c658d2b569833d964bd809f9cdf232bbe","forward-mcp-listener":"a0fe1784bc463abb899c551506bdab5c658d2b569833d964bd809f9cdf232bbe","mcp-proxy":"a0fe1784bc463abb899c551506bdab5c658d2b569833d964bd809f9cdf232bbe","mcp-scan":"a0fe1784bc463abb899c551506bdab5c658d2b569833d964bd809f9cdf232bbe"}} +{"preset":"generic-agent","runtime_hashes":{"forward":"ff6aa66f6f27caae6d8727c999287d76be8c17eb3a6556b750b1b37609c35840","forward-mcp-listener":"ff6aa66f6f27caae6d8727c999287d76be8c17eb3a6556b750b1b37609c35840","mcp-proxy":"ff6aa66f6f27caae6d8727c999287d76be8c17eb3a6556b750b1b37609c35840","mcp-scan":"ff6aa66f6f27caae6d8727c999287d76be8c17eb3a6556b750b1b37609c35840"}} {"preset":"hostile-model","runtime_hashes":{"forward":"e20fa56955ccc3642a2f7c828353b51ae3eefe1393013f008d4d2130c3389d49","forward-mcp-listener":"e20fa56955ccc3642a2f7c828353b51ae3eefe1393013f008d4d2130c3389d49","mcp-proxy":"e20fa56955ccc3642a2f7c828353b51ae3eefe1393013f008d4d2130c3389d49","mcp-scan":"e20fa56955ccc3642a2f7c828353b51ae3eefe1393013f008d4d2130c3389d49"}} diff --git a/internal/scanner/core.go b/internal/scanner/core.go index 719c63af8..66b0ef6bf 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 5354847a2..bd94635f5 100644 --- a/internal/scanner/credential_audience.go +++ b/internal/scanner/credential_audience.go @@ -49,14 +49,37 @@ 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. 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 @@ -123,7 +146,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 +391,25 @@ func registryBearerAllowed(candidate credentialAudienceCandidate, host, target, if err != nil || !strings.EqualFold(parsed.Scheme, "https") { return false } - fields := strings.Fields(candidate.headerValue) - if len(fields) != 2 || !strings.EqualFold(fields[0], "Bearer") { - 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) { + if !candidate.hasValueIdentity { + return "", false + } + bearer := candidate.bearer + if bearer == nil { + bearer = parseBearer(candidate.headerValue) + } + if !bearer.ok || bearer.digest != candidate.valueIdentity { + return "", false } - return registryJWTAudienceMatches(fields[1], host) + return bearer.token, true } // registryJWTAudienceMatches reports whether token is a registry JWT whose @@ -679,14 +716,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) 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 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 } @@ -698,6 +735,9 @@ func (s *Scanner) credentialAudienceAllowsWithHeaderAt(pattern *compiledPattern, gitHosts: pattern.credentialAudienceGitHosts, registryHosts: pattern.credentialAudienceRegistryHosts, 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 @@ -803,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 @@ -811,7 +852,10 @@ 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 + candidates[i].bearer = bearer } keep, allows := filterCredentialAudienceAt(candidates, target, surface, s.currentTime()) filtered := make([]TextDLPMatch, 0, len(matches)) @@ -842,10 +886,15 @@ 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 + bearer := parseBearer(headerValue) + now := s.currentTime() + value = pattern.re.ReplaceAllStringFunc(value, func(matchedValue string) string { + if _, ok := s.credentialAudienceAllowsWithBearerAt(pattern, target, surface, headerValue, bearer, matchedValue, now); 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 0a731c9d5..88c1fe07c 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_audience_url_query_test.go b/internal/scanner/credential_audience_url_query_test.go index fe70520e4..92108b058 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) } diff --git a/internal/scanner/credential_signin.go b/internal/scanner/credential_signin.go index 49c6acd80..f6e994070 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, @@ -32,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"}, @@ -50,7 +53,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 +70,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 06a69cc83..6e77da2ae 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,115 @@ 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) + } +} + +// 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") + } +} diff --git a/internal/scanner/registry_audience_test.go b/internal/scanner/registry_audience_test.go index 273554af7..924e5ef83 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 988664d1a..1e23ebfbb 100644 --- a/internal/scanner/scanner.go +++ b/internal/scanner/scanner.go @@ -484,7 +484,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 8286a694e..f8c92eeae 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 }