diff --git a/docs/security/provider-key-dlp-coverage.md b/docs/security/provider-key-dlp-coverage.md index 756e94af1..d71afba7e 100644 --- a/docs/security/provider-key-dlp-coverage.md +++ b/docs/security/provider-key-dlp-coverage.md @@ -13,7 +13,7 @@ Body, URL, unrelated-header, and WebSocket-frame copies of header-only credentia A separate git-over-HTTPS rule lets `GitHub Token`, `GitHub Fine-Grained PAT`, and `GitLab PAT` travel as `Authorization: Basic` to the git host (`github.com`, `gitlab.com`, or a declared enterprise host), and only on a git transport path: `/info/refs?service=git-upload-pack` or `?service=git-receive-pack`, `/git-upload-pack`, `/git-receive-pack`, or a Git LFS path under `/info/lfs/`, as defined by gitprotocol-http(5) and the Git LFS batch and locking API documents. The path is checked as forwarded; a percent-encoded path or one containing `..`, `.`, or `//` segments doesn't qualify. Basic on any other path at the git host, Bearer or token at `github.com`, and Basic at `api.github.com` all block. GitLab Bearer and `PRIVATE-TOKEN` use `/api/v4` at `gitlab.com`, and `GitLab PAT` also uses exactly `/api/graphql`; declared operator-owned hosts retain their instance API root. -`JWT Token` has separate release-download query, GHCR Bearer, and qualifying registry Basic-password rules. `Azure SAS Token` has query-only download rules. See the shared notes below the table. `dlp.github_enterprise_hosts` and `dlp.gitlab_hosts` add exact operator-named hosts for enterprise and self-managed instances. A declared host is trusted with the credential: if it's attacker-controlled, it receives the token by design. AWS secret keys and private keys have no audience. Compiled audience allowances don't use `exempt_domains` or `suppress`. The audience is available without a paid license. +`JWT Token` has separate release-download query, GHCR Bearer, qualifying registry Basic-password, and provider sign-in Bearer rules. `Azure SAS Token` has query-only download rules. See the shared notes below the table. `dlp.github_enterprise_hosts` and `dlp.gitlab_hosts` add exact operator-named hosts for enterprise and self-managed instances. A declared host is trusted with the credential: if it's attacker-controlled, it receives the token by design. AWS secret keys and private keys have no audience. Compiled audience allowances don't use `exempt_domains` or `suppress`. The audience is available without a paid license. | Rule | Shape | Compiled API endpoint and carrier | Source | |------|-------|--------------------------|--------| @@ -40,7 +40,7 @@ A separate git-over-HTTPS rule lets `GitHub Token`, `GitHub Fine-Grained PAT`, a | Pinecone API Key | `pcsk_` + 36+ alphanumeric chars | `api.pinecone.io` (`Api-Key`; root-level control-plane routes). Data-plane hosts require explicit operator policy | [Pinecone authentication](https://docs.pinecone.io/guides/get-started/authentication) | | Groq API Key | `gsk_` + 48+ alphanumeric chars | `api.groq.com/openai/v1` (`Authorization: Bearer`) | [Groq API keys](https://console.groq.com/docs/api-keys) | | xAI API Key | `xai-` + 80+ token chars | `api.x.ai/v1` (`Authorization: Bearer`) | [xAI API reference](https://docs.x.ai/docs/api-reference) | -| JWT Token | three base64url segments | `release-assets.githubusercontent.com` ([release grant in an HTTPS URL query](#download-grant-bounds)); `ghcr.io` ([registry JWT in HTTPS `Authorization: Bearer`](#registry-carriers)); JWT matches within a qualifying GitHub-token [registry Basic password](#registry-carriers), without JWT claim checks. Other carriers don't qualify | Release redirect shape observed locally; [registry token specification](https://distribution.github.io/distribution/spec/auth/jwt/) | +| JWT Token | three base64url segments | `release-assets.githubusercontent.com` ([release grant in an HTTPS URL query](#download-grant-bounds)); `ghcr.io` ([registry JWT in HTTPS `Authorization: Bearer`](#registry-carriers)); JWT matches within a qualifying GitHub-token [registry Basic password](#registry-carriers), without JWT claim checks; `chatgpt.com/backend-api`, `api.x.ai/v1`, `cli-chat-proxy.grok.com/v1` ([sign-in JWT in HTTPS `Authorization: Bearer`](#sign-in-bearer-carriers)). Other carriers don't qualify | Release redirect shape observed locally; [registry token specification](https://distribution.github.io/distribution/spec/auth/jwt/) | | Azure SAS Token | `sig=` + base64 HMAC-SHA256 | HTTPS URL query only: beside a qualifying release grant at `release-assets.githubusercontent.com`; attestation bundles at `tmaproduction.blob.core.windows.net` or `tmastaging.blob.core.windows.net` under `/attestations/`; Actions logs/artifacts at the exact `productionresultssa0` through `productionresultssa19`, `productionresultssa30` through `productionresultssa49`, and `productionresultssa100` through `productionresultssa199` accounts under `blob.core.windows.net`, beneath `/actions-results/`. [Download bounds](#download-grant-bounds) apply | [GitHub attestation bundle example](https://docs.github.com/en/rest/orgs/attestations); Actions results accounts from GitHub's meta API `domains.actions` | Route prefixes match whole path segments. Escaped paths, repeated slashes, and `.` or `..` segments don't earn a provider API exception. Other vendor hosts and routes need explicit operator policy. This supported endpoint set is deliberately narrower than a vendor-wide domain wildcard. The endpoint rules and their vendor sources are in [`credential_endpoints.go`](../../internal/scanner/credential_endpoints.go). @@ -75,6 +75,18 @@ Registry Basic requires HTTPS and the exact `Authorization: Basic` carrier at `g The separate GHCR registry JWT rule requires HTTPS `Authorization: Bearer` at `ghcr.io`, an `aud` string or audience-array entry naming that host, and a nonempty `access` array. JWT matches within a qualifying GitHub-token Basic password use the whole-password rule above, without these JWT claim checks. RubyGems GitHub-token Bearer authentication uses the normal provider audience; RubyGems isn't a registry Basic audience. [GitHub's RubyGems documentation](https://docs.github.com/en/packages/working-with-a-github-packages-registry/working-with-the-rubygems-registry) specifies a classic PAT for personal-token authentication; this DLP audience doesn't establish fine-grained PAT support. These filters don't authenticate a signature or prove who issued a token. The compiled host and carrier rules are defined in [`dlp_patterns.go`](../../internal/config/dlp_patterns.go) and [`credential_audience.go`](../../internal/scanner/credential_audience.go). +### Sign-in bearer carriers + +Coding clients that sign in with OAuth send the access token, a JWT, as the `Authorization: Bearer` value to their vendor's own API. The `JWT Token` match is filtered only when the whole header is `Bearer` plus one token, over HTTPS, at one of these exact hosts and under its path, and the token's `iss` and `client_id` claims equal the listed values: + +| Host | Path | `iss` | `client_id` | Source | +|---|---|---|---|---| +| `chatgpt.com` | `/backend-api/...` | `https://auth.openai.com` | `app_EMoamEEZ73f0CkXaXp7hrann` | [Codex CLI](https://github.com/openai/codex) `rust-v0.167.0`: `codex-rs/login/src/server.rs`, `codex-rs/login/src/auth/manager.rs`, `codex-rs/cloud-config/src/bundle_loader.rs` | +| `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. + ### Download grant bounds Release query JWTs require the compiled header/claim shape, `iss=github.com`, the destination host in `aud`, and a positive `exp - nbf` span of at most 3600 seconds. This is Pipelock's local cap based on observed grants, not a provider maximum. Attestation bundle SAS requires one of the two exact table hosts, an unencoded, normalized path beneath `/attestations/`, read-only blob fields (`sp=r`, `sr=b`, `spr=https`), and each required signed field once in its documented format, including `st` and `se`. Its positive `se - st` span must be at most 24 hours. Both rules check the current time against the grant's validity window with Pipelock's 15-minute clock-skew tolerance. diff --git a/internal/proxy/credential_signin_test.go b/internal/proxy/credential_signin_test.go new file mode 100644 index 000000000..60c4f1358 --- /dev/null +++ b/internal/proxy/credential_signin_test.go @@ -0,0 +1,129 @@ +// Copyright 2026 Pipelock contributors +// SPDX-License-Identifier: Apache-2.0 + +package proxy + +import ( + "encoding/base64" + "encoding/json" + "fmt" + "net/http" + "strings" + "testing" + + "github.com/luckyPipewrench/pipelock/internal/config" + "github.com/luckyPipewrench/pipelock/internal/scanner" +) + +func signInTestJWT(t *testing.T, claims map[string]any) string { + t.Helper() + payload, err := json.Marshal(claims) + if err != nil { + t.Fatal(err) + } + return base64.RawURLEncoding.EncodeToString([]byte(`{"alg":"none"}`)) + "." + + base64.RawURLEncoding.EncodeToString(payload) + "." + strings.Repeat("a", 24) +} + +// The sign-in Bearer must pass the whole request-header scan, including the +// joined-header copy, which is where a header set with more than one value is +// decided. Scanner-level tests do not reach that path. +func TestSignInBearer_RequestHeaderScan(t *testing.T) { + codex := signInTestJWT(t, map[string]any{"iss": "https://auth.openai.com", "client_id": "app_EMoamEEZ73f0CkXaXp7hrann"}) + other := signInTestJWT(t, map[string]any{"iss": "https://auth.vendor.example", "client_id": "app_other"}) + wrongClient := signInTestJWT(t, map[string]any{"iss": "https://auth.openai.com", "client_id": "app_other"}) + grok := signInTestJWT(t, map[string]any{"iss": "https://auth.x.ai", "client_id": "b1a00492-073a-47ea-816f-4c329264a828"}) + grokWrongClient := signInTestJWT(t, map[string]any{"iss": "https://auth.x.ai", "client_id": "app_other"}) + grokWrongIssuer := signInTestJWT(t, map[string]any{"iss": "https://auth.openai.com", "client_id": "b1a00492-073a-47ea-816f-4c329264a828"}) + const target = "https://chatgpt.com/backend-api/codex/responses" + + 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 string + headers http.Header + target string + clean bool + allOnly bool // the carrier header is outside the sensitive list + want string // this finding itself must remain, not just another block + }{ + {"codex client headers", http.Header{ + "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, ""}, + {"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, ""}, + {"grok at api.x.ai", http.Header{"Authorization": {"Bearer " + grok}, "User-Agent": {"x"}}, "https://api.x.ai/v1/chat/completions", true, false, ""}, + {"grok at the cli chat proxy", http.Header{"Authorization": {"Bearer " + grok}, "User-Agent": {"x"}}, "https://cli-chat-proxy.grok.com/v1/responses", true, false, ""}, + {"grok client id mismatch at api.x.ai", http.Header{"Authorization": {"Bearer " + grokWrongClient}, "User-Agent": {"x"}}, "https://api.x.ai/v1/chat/completions", false, false, "JWT Token"}, + {"grok issuer mismatch at the cli chat proxy", http.Header{"Authorization": {"Bearer " + grokWrongIssuer}, "User-Agent": {"x"}}, "https://cli-chat-proxy.grok.com/v1/responses", false, false, "JWT Token"}, + {"codex issuer with another client id", http.Header{"Authorization": {"Bearer " + wrongClient}, "User-Agent": {"x"}}, target, false, false, "JWT Token"}, + {"chatgpt.com outside the backend API", http.Header{"Authorization": {"Bearer " + codex}, "User-Agent": {"x"}}, "https://chatgpt.com/api/other", false, false, "JWT Token"}, + {"foreign token", http.Header{"Authorization": {"Bearer " + other}, "User-Agent": {"x"}}, target, false, false, ""}, + {"other host", http.Header{"Authorization": {"Bearer " + codex}, "User-Agent": {"x"}}, "https://api.vendor.example/backend-api/codex", false, false, ""}, + {"wss target", http.Header{"Authorization": {"Bearer " + codex}, "User-Agent": {"x"}}, "wss://chatgpt.com/backend-api/codex/responses", false, false, ""}, + {"percent-encoded foreign token ahead of the sign-in token", http.Header{ + "Authorization": {"Bearer " + percentEncodeAll(other) + codex}, "User-Agent": {"x"}, + }, target, false, false, "JWT Token"}, + {"base64 foreign token ahead of the sign-in token", http.Header{ + "Authorization": {"Bearer " + base64.StdEncoding.EncodeToString([]byte(other+"!")) + codex}, "User-Agent": {"x"}, + }, target, false, false, "JWT Token"}, + {"unrelated secret beside token", http.Header{ + "Authorization": {"Bearer " + codex + " AKIA" + strings.Repeat("A", 16)}, "User-Agent": {"x"}, + }, target, false, false, "AWS Access ID"}, + {"secret split across headers", http.Header{ + "Authorization": {"Bearer " + codex + "AKIA" + strings.Repeat("A", 8)}, "X-Split": {"C" + strings.Repeat("A", 7)}, + }, target, false, true, ""}, + } { + if tc.allOnly && mode != config.HeaderModeAll { + continue + } + t.Run(mode+"/"+tc.name, func(t *testing.T) { + var allows []scanner.CredentialAudienceAllow + result := scanRequestHeadersForTargetWithAudience(t.Context(), tc.headers, cfg, sc, tc.target, nil, + func(allow scanner.CredentialAudienceAllow) error { allows = append(allows, allow); return nil }) + clean := result == nil || result.Clean + if clean != tc.clean { + t.Fatalf("clean=%t want %t result=%#v allows=%#v", clean, tc.clean, result, allows) + } + // A clean result alone would also pass if the scan were skipped; the + // sign-in allowance for this destination must have been recorded. + if tc.clean { + host := strings.SplitN(strings.TrimPrefix(tc.target, "https://"), "/", 2)[0] + recorded := false + for _, a := range allows { + recorded = recorded || (a.PatternName == "JWT Token" && a.Destination == host) + } + if !recorded { + t.Fatalf("no JWT Token allowance recorded for %s: %#v", host, allows) + } + } + if tc.want != "" { + found := false + for _, m := range result.DLPMatches { + found = found || m.PatternName == tc.want + } + if !found { + t.Fatalf("%s finding was dropped: %#v", tc.want, result.DLPMatches) + } + } + }) + } + } +} + +// percentEncodeAll encodes every byte, so the value carries no plaintext token. +func percentEncodeAll(v string) string { + var b strings.Builder + for i := 0; i < len(v); i++ { + fmt.Fprintf(&b, "%%%02X", v[i]) + } + return b.String() +} diff --git a/internal/scanner/credential_audience.go b/internal/scanner/credential_audience.go index 1bcf44102..5354847a2 100644 --- a/internal/scanner/credential_audience.go +++ b/internal/scanner/credential_audience.go @@ -53,6 +53,10 @@ type credentialAudienceCandidate struct { gitHosts []string registryHosts []string headerValue string + // 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 } // CredentialAudienceAuthorizationHeaderSurface distinguishes Authorization from other @@ -194,6 +198,7 @@ func filterCredentialAudienceAt(candidates []credentialAudienceCandidate, target !releaseGrantSASCandidateAllowed(candidate, host, target, surface, now) && !registryBasicAllowed(candidate, host, target, surface) && !registryBearerAllowed(candidate, host, target, surface) && + !signInBearerAllowed(candidate, host, target, surface) && !githubBlobSASCandidateAllowed(candidate, host, target, surface, now) { continue } @@ -806,6 +811,7 @@ func (s *Scanner) filterTextDLPMatchesForDestination(matches []TextDLPMatch, tar candidates[i].gitHosts = match.credentialAudienceGitHosts candidates[i].registryHosts = match.credentialAudienceRegistryHosts candidates[i].headerValue = headerValue + candidates[i].encoded = match.Encoded } keep, allows := filterCredentialAudienceAt(candidates, target, surface, s.currentTime()) filtered := make([]TextDLPMatch, 0, len(matches)) diff --git a/internal/scanner/credential_signin.go b/internal/scanner/credential_signin.go new file mode 100755 index 000000000..49c6acd80 --- /dev/null +++ b/internal/scanner/credential_signin.go @@ -0,0 +1,93 @@ +// Copyright 2026 Pipelock contributors +// SPDX-License-Identifier: Apache-2.0 + +package scanner + +import ( + "encoding/json" + "net/url" + "strings" +) + +// signInBearerRoute is one provider API that accepts a sign-in access token, +// a JWT, as the Authorization bearer. +type signInBearerRoute struct { + host string + prefix string + issuer string + clientID string +} + +// signInBearerRoutes are the coding clients that sign in with OAuth and send +// the resulting JWT access token to their vendor's own API. Sources: +// - Codex CLI signed in with ChatGPT, openai/codex at rust-v0.167.0: +// codex-rs/login/src/server.rs DEFAULT_ISSUER, codex-rs/login/src/auth/manager.rs +// CLIENT_ID, and the https://chatgpt.com/backend-api/ base URL in +// codex-rs/cloud-config/src/bundle_loader.rs. +// https://github.com/openai/codex +// - The xAI Grok CLI OAuth client, router-for-me/CLIProxyAPI at v8.0.51: +// internal/auth/xai/types.go Issuer, ClientID, DefaultAPIBaseURL and +// CLIChatProxyBaseURL. +// https://github.com/router-for-me/CLIProxyAPI +// +// 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. +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"}, + {host: "cli-chat-proxy.grok.com", prefix: "/v1", issuer: "https://auth.x.ai", clientID: "b1a00492-073a-47ea-816f-4c329264a828"}, +} + +// signInBearerAllowed accepts a JWT Token match only as the whole +// Authorization Bearer value, over https, at an exact route host and under +// its path prefix, when the token's iss and client_id equal that route's. +func signInBearerAllowed(candidate credentialAudienceCandidate, host, target, surface string) bool { + // The claims are read from the header value, so they describe the matched + // token only when the match is in that raw value. The whitespace view only + // 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 || + (candidate.encoded != "" && candidate.encoded != "whitespace") { + return false + } + parsed, err := url.Parse(target) + if err != nil || !strings.EqualFold(parsed.Scheme, "https") { + return false + } + path := parsed.EscapedPath() + if strings.ContainsAny(path, "%\\") || strings.Contains(path, "//") { + return false + } + for _, segment := range strings.Split(path, "/") { + if segment == "." || segment == ".." { + return false + } + } + fields := strings.Fields(candidate.headerValue) + if len(fields) != 2 || !strings.EqualFold(fields[0], "Bearer") { + return false + } + parts := strings.Split(fields[1], ".") + if len(parts) != 3 { + return false + } + var claims map[string]json.RawMessage + if !decodeJWTSegment(parts[1], 0, &claims) { + return false + } + var issuer, clientID string + if !jsonField(claims, "iss", &issuer) || !jsonField(claims, "client_id", &clientID) { + return false + } + for _, route := range signInBearerRoutes { + if host == route.host && strings.HasPrefix(path, route.prefix+"/") && + issuer == route.issuer && clientID == route.clientID { + return true + } + } + return false +} diff --git a/internal/scanner/credential_signin_test.go b/internal/scanner/credential_signin_test.go new file mode 100755 index 000000000..06a69cc83 --- /dev/null +++ b/internal/scanner/credential_signin_test.go @@ -0,0 +1,142 @@ +// Copyright 2026 Pipelock contributors +// SPDX-License-Identifier: Apache-2.0 + +package scanner + +import ( + "context" + "encoding/base64" + "strings" + "testing" +) + +func TestSignInBearerAudience(t *testing.T) { + t.Parallel() + s := MustNew(credentialAudienceTestConfig()) + codex := registryJWT(t, map[string]any{ + "iss": "https://auth.openai.com", "aud": []string{"https://api.openai.com/v1"}, + "client_id": "app_EMoamEEZ73f0CkXaXp7hrann", + }) + xai := registryJWT(t, map[string]any{ + "iss": "https://auth.x.ai", "aud": "b1a00492-073a-47ea-816f-4c329264a828", + "client_id": "b1a00492-073a-47ea-816f-4c329264a828", + }) + otherClient := registryJWT(t, map[string]any{"iss": "https://auth.openai.com", "client_id": "app_other"}) + otherIssuer := registryJWT(t, map[string]any{"iss": "https://auth.vendor.example", "client_id": "app_EMoamEEZ73f0CkXaXp7hrann"}) + noClient := registryJWT(t, map[string]any{"iss": "https://auth.openai.com"}) + issArray := registryJWT(t, map[string]any{"iss": []string{"https://auth.openai.com"}, "client_id": "app_EMoamEEZ73f0CkXaXp7hrann"}) + registry := registryJWT(t, map[string]any{"aud": "ghcr.io", "access": []any{map[string]any{"type": "repository"}}}) + dupIss := base64.RawURLEncoding.EncodeToString([]byte(`{"alg":"none"}`)) + "." + + base64.RawURLEncoding.EncodeToString([]byte(`{"iss":"https://auth.vendor.example","iss":"https://auth.openai.com","client_id":"app_EMoamEEZ73f0CkXaXp7hrann"}`)) + + "." + strings.Repeat("a", 16) + + cases := []struct { + name, token, header, value, target string + allow bool + host string + }{ + {"codex responses", codex, "Authorization", "Bearer " + codex, "https://chatgpt.com/backend-api/codex/responses", true, "chatgpt.com"}, + {"codex wham", codex, "Authorization", "Bearer " + codex, "https://chatgpt.com/backend-api/wham/accounts/check", true, "chatgpt.com"}, + {"codex host case and dot", codex, "Authorization", "Bearer " + codex, "https://ChatGPT.com./backend-api/codex/models", true, "chatgpt.com"}, + {"xai chat proxy", xai, "Authorization", "Bearer " + xai, "https://cli-chat-proxy.grok.com/v1/chat/completions", true, "cli-chat-proxy.grok.com"}, + {"xai api", xai, "Authorization", "Bearer " + xai, "https://api.x.ai/v1/responses", true, "api.x.ai"}, + {"codex token at xai blocked", codex, "Authorization", "Bearer " + codex, "https://api.x.ai/v1/responses", false, ""}, + {"xai token at chatgpt blocked", xai, "Authorization", "Bearer " + xai, "https://chatgpt.com/backend-api/codex/responses", false, ""}, + {"other client blocked", otherClient, "Authorization", "Bearer " + otherClient, "https://chatgpt.com/backend-api/codex/responses", false, ""}, + {"other issuer blocked", otherIssuer, "Authorization", "Bearer " + otherIssuer, "https://chatgpt.com/backend-api/codex/responses", false, ""}, + {"missing client_id blocked", noClient, "Authorization", "Bearer " + noClient, "https://chatgpt.com/backend-api/codex/responses", false, ""}, + {"iss array blocked", issArray, "Authorization", "Bearer " + issArray, "https://chatgpt.com/backend-api/codex/responses", false, ""}, + {"duplicate iss blocked", dupIss, "Authorization", "Bearer " + dupIss, "https://chatgpt.com/backend-api/codex/responses", false, ""}, + {"registry jwt at chatgpt blocked", registry, "Authorization", "Bearer " + registry, "https://chatgpt.com/backend-api/codex/responses", false, ""}, + {"path outside prefix blocked", codex, "Authorization", "Bearer " + codex, "https://chatgpt.com/", false, ""}, + {"prefix without segment blocked", codex, "Authorization", "Bearer " + codex, "https://chatgpt.com/backend-api", false, ""}, + {"string prefix blocked", codex, "Authorization", "Bearer " + codex, "https://chatgpt.com/backend-apix/codex", false, ""}, + {"dot segment blocked", codex, "Authorization", "Bearer " + codex, "https://chatgpt.com/backend-api/../upload", false, ""}, + {"escaped path blocked", codex, "Authorization", "Bearer " + codex, "https://chatgpt.com/backend-api/%2e%2e/upload", false, ""}, + {"double slash blocked", codex, "Authorization", "Bearer " + codex, "https://chatgpt.com/backend-api//codex", false, ""}, + {"subdomain blocked", codex, "Authorization", "Bearer " + codex, "https://evil.chatgpt.com/backend-api/codex", false, ""}, + {"lookalike blocked", codex, "Authorization", "Bearer " + codex, "https://chatgpt.com.evil.example/backend-api/codex", false, ""}, + {"other host blocked", codex, "Authorization", "Bearer " + codex, "https://api.vendor.example/backend-api/codex", false, ""}, + {"cleartext blocked", codex, "Authorization", "Bearer " + codex, "http://chatgpt.com/backend-api/codex/responses", false, ""}, + {"basic scheme blocked", codex, "Authorization", "Basic " + codex, "https://chatgpt.com/backend-api/codex/responses", false, ""}, + {"extra field blocked", codex, "Authorization", "Bearer " + codex + " extra", "https://chatgpt.com/backend-api/codex/responses", false, ""}, + {"other header blocked", codex, "X-Api-Key", codex, "https://chatgpt.com/backend-api/codex/responses", false, ""}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + matches := s.ScanTextForDLP(context.Background(), tc.token).Matches + if !matchRetained(matches, "JWT Token") { + t.Fatalf("fixture did not match the JWT pattern") + } + retained, allows := s.FilterHeaderDLPMatches(matches, tc.target, tc.header, tc.value) + if tc.allow { + if matchRetained(retained, "JWT Token") || !audienceAllowFor(allows, "JWT Token") { + t.Fatalf("retained=%v allows=%#v", matchRetained(retained, "JWT Token"), allows) + } + if allows[0].Destination != tc.host || allows[0].Surface != "header" { + t.Fatalf("allow record = %#v", allows[0]) + } + return + } + if !matchRetained(retained, "JWT Token") || audienceAllowFor(allows, "JWT Token") { + t.Fatalf("JWT allowed: retained=%v allows=%#v", matchRetained(retained, "JWT Token"), allows) + } + }) + } +} + +// The route is earned only by the JWT pattern; another pattern's match in the +// same Authorization value gets nothing from 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} + if signInBearerAllowed(c, "chatgpt.com", "https://chatgpt.com/backend-api/codex", CredentialAudienceAuthorizationHeaderSurface) { + t.Fatal("non-JWT pattern earned the sign-in route") + } + c.patternName = "JWT Token" + if signInBearerAllowed(c, "chatgpt.com", "https://chatgpt.com/backend-api/codex", credentialAudienceURLQuerySurface) { + t.Fatal("URL query surface earned the sign-in route") + } + if !signInBearerAllowed(c, "chatgpt.com", "https://chatgpt.com/backend-api/codex", CredentialAudienceAuthorizationHeaderSurface) { + t.Fatal("valid route refused") + } + if signInBearerAllowed(c, "chatgpt.com", "://bad", CredentialAudienceAuthorizationHeaderSurface) { + t.Fatal("unparseable target allowed") + } + c.headerValue = "Bearer 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" + if signInBearerAllowed(c, "chatgpt.com", "https://chatgpt.com/backend-api/codex", CredentialAudienceAuthorizationHeaderSurface) { + t.Fatal("undecodable payload allowed") + } +} + +// signInBearerAllowed decides the scheme and header shape itself, so a caller +// that reaches it with a wss target or a malformed header value is refused here +// and not only by the callers' own checks. +func TestSignInBearerAllowedSchemeAndHeaderShape(t *testing.T) { + t.Parallel() + codex := registryJWT(t, map[string]any{"iss": "https://auth.openai.com", "client_id": "app_EMoamEEZ73f0CkXaXp7hrann"}) + const surface = CredentialAudienceAuthorizationHeaderSurface + for _, tc := range []struct { + name, target, value string + allow bool + }{ + {"https bearer", "https://chatgpt.com/backend-api/codex", "Bearer " + codex, true}, + {"wss target", "wss://chatgpt.com/backend-api/codex", "Bearer " + codex, false}, + {"http target", "http://chatgpt.com/backend-api/codex", "Bearer " + codex, false}, + {"basic scheme", "https://chatgpt.com/backend-api/codex", "Basic " + codex, false}, + {"extra field", "https://chatgpt.com/backend-api/codex", "Bearer " + codex + " extra", false}, + {"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} + if got := signInBearerAllowed(c, "chatgpt.com", tc.target, surface); got != tc.allow { + t.Errorf("%s: allowed=%t want %t", tc.name, got, tc.allow) + } + } +} diff --git a/release/surfaces.json b/release/surfaces.json index 4bde9dd35..8109b903a 100644 --- a/release/surfaces.json +++ b/release/surfaces.json @@ -1734,6 +1734,24 @@ "expected": "2.1.310", "count": 3, "reason": "Fixed Anthropic client artifact used as the endpoint source, verified by its own version output and SHA-256 in this document; not the current Pipelock release." + }, + { + "id": "docs-provider-key-codex-signin-source", + "path": "docs/security/provider-key-dlp-coverage.md", + "pattern": "(?v?0\\.167\\.0)(?![A-Za-z0-9-]|\\.[0-9])", + "expected": "0.167.0", + "count": 1, + "reason": "Fixed OpenAI Codex client tag used as the sign-in bearer source in this document; not the current Pipelock release.", + "format": "version" + }, + { + "id": "docs-provider-key-xai-signin-source", + "path": "docs/security/provider-key-dlp-coverage.md", + "pattern": "(?v?8\\.0\\.51)(?![A-Za-z0-9-]|\\.[0-9])", + "expected": "8.0.51", + "count": 1, + "reason": "Fixed CLIProxyAPI tag used as the xAI sign-in bearer source in this document; not the current Pipelock release.", + "format": "version" } ], "external": [