Skip to content
Merged
2 changes: 1 addition & 1 deletion docs/security/provider-key-dlp-coverage.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
14 changes: 8 additions & 6 deletions internal/config/canonical_golden_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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) {
Expand Down
2 changes: 1 addition & 1 deletion internal/config/canonical_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
5 changes: 2 additions & 3 deletions internal/config/credential_audience_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
Expand All @@ -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)
Expand All @@ -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])
}
}
5 changes: 3 additions & 2 deletions internal/config/dlp_keywords_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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))
Expand Down
8 changes: 6 additions & 2 deletions internal/config/dlp_patterns.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion internal/config/schema.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
112 changes: 112 additions & 0 deletions internal/proxy/credential_signin_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
)

Expand Down Expand Up @@ -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, ""},
Expand Down Expand Up @@ -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)
})
}
}

Expand All @@ -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)
}
})
}
}
}
Loading
Loading