Skip to content
Merged
16 changes: 14 additions & 2 deletions docs/security/provider-key-dlp-coverage.md
Original file line number Diff line number Diff line change
Expand Up @@ -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: `<repo>/info/refs?service=git-upload-pack` or `?service=git-receive-pack`, `<repo>/git-upload-pack`, `<repo>/git-receive-pack`, or a Git LFS path under `<repo>/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 |
|------|-------|--------------------------|--------|
Expand All @@ -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).
Expand Down Expand Up @@ -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.
Expand Down
129 changes: 129 additions & 0 deletions internal/proxy/credential_signin_test.go
Original file line number Diff line number Diff line change
@@ -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, ""},
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
{"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()
}
6 changes: 6 additions & 0 deletions internal/scanner/credential_audience.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
}
Expand Down Expand Up @@ -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))
Expand Down
93 changes: 93 additions & 0 deletions internal/scanner/credential_signin.go
Original file line number Diff line number Diff line change
@@ -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
}
Comment thread
luckyPipewrench marked this conversation as resolved.
Resolved
Loading
Loading