Skip to content

Commit d6ec1c6

Browse files
authored
fix(secrets): prevent capture and sentinel credential retention (#102420)
* fix(secrets): harden sentinel capture lifecycle * chore: leave release notes to release automation
1 parent 1d4d847 commit d6ec1c6

15 files changed

Lines changed: 330 additions & 84 deletions

packages/ai/src/providers/anthropic.test.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -152,7 +152,7 @@ describe("Anthropic provider", () => {
152152
});
153153

154154
it("keeps sentinel-backed Foundry Authorization headers on bearer routing", async () => {
155-
const sentinel = "oc-sent-v1-0123456789abcdef01234567";
155+
const sentinel = "oc-sent-v2.AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA.end";
156156
configureAiTransportHost({
157157
buildModelFetch: () => async () => new Response(null, { status: 500 }),
158158
resolveSecretSentinel: (value) => value.replaceAll(sentinel, "Bearer entra-access-token"),

packages/ai/src/providers/google-client-auth.test.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,7 @@ import { streamGoogle } from "./google.js";
3232
const context = {
3333
messages: [{ role: "user", content: "hello", timestamp: 0 }],
3434
} satisfies Context;
35-
const sentinel = "oc-sent-v1-0123456789abcdef01234567";
35+
const sentinel = "oc-sent-v2.AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA.end";
3636

3737
function googleModel(): Model<"google-generative-ai"> {
3838
return {

packages/ai/src/providers/openai-chatgpt-responses.test.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -129,7 +129,7 @@ describe("streamOpenAICodexResponses transport", () => {
129129
const realToken = createJwt({
130130
"https://api.openai.com/auth": { chatgpt_account_id: "acct-sentinel" },
131131
});
132-
const sentinel = "oc-sent-v1-0123456789abcdef01234567";
132+
const sentinel = "oc-sent-v2.AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA.end";
133133
configureAiTransportHost({
134134
resolveSecretSentinel: (value) => value.replaceAll(sentinel, realToken),
135135
});

packages/ai/src/providers/openai-responses.test.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -69,17 +69,17 @@ describe("OpenAI Responses provider", () => {
6969
baseUrl: "https://gateway.ai.cloudflare.com/v1/account/gateway/openai",
7070
}),
7171
context,
72-
{ apiKey: "oc-sent-v1-0123456789abcdef01234567" },
72+
{ apiKey: "oc-sent-v2.AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA.end" },
7373
).result();
7474

7575
const config = openAiMockState.configs[0] as {
7676
apiKey?: string;
7777
defaultHeaders?: Record<string, string | null>;
7878
fetch?: unknown;
7979
};
80-
expect(config.apiKey).toBe("oc-sent-v1-0123456789abcdef01234567");
80+
expect(config.apiKey).toBe("oc-sent-v2.AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA.end");
8181
expect(config.defaultHeaders?.["cf-aig-authorization"]).toBe(
82-
"Bearer oc-sent-v1-0123456789abcdef01234567",
82+
"Bearer oc-sent-v2.AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA.end",
8383
);
8484
expect(config.fetch).toBe(hostFetch);
8585
});

src/agents/provider-local-service.test.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -191,7 +191,7 @@ describe("provider local service", () => {
191191

192192
it("rejects unknown sentinels before starting a local service", async () => {
193193
const port = await freePort();
194-
const unknown = "oc-sent-v1-fedcba987654321001234567";
194+
const unknown = "oc-sent-v2.AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA.end";
195195
const model = attachModelProviderLocalService(
196196
{
197197
id: "demo",

src/agents/provider-secret-egress.test.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -79,7 +79,7 @@ describe("unwrapModelHeaderSentinelsForProviderEgress", () => {
7979
{
8080
auth: {
8181
mode: "authorization-bearer",
82-
token: "oc-sent-v1-00000000000000000000dead",
82+
token: "oc-sent-v2.AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA.end",
8383
},
8484
},
8585
);

src/agents/provider-transport-fetch.test.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -261,7 +261,7 @@ describe("buildGuardedModelFetch", () => {
261261
});
262262

263263
it("rejects unknown sentinel-shaped values before guarded fetch", async () => {
264-
const unknown = "oc-sent-v1-fedcba987654321001234567";
264+
const unknown = "oc-sent-v2.AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA.end";
265265
await expect(
266266
buildGuardedModelFetch(sentinelModel())("https://api.openai.com/v1/responses", {
267267
headers: { Authorization: `Bearer ${unknown}` },

src/agents/provider-transport-fetch.ts

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ import type { Model } from "../llm/types.js";
3030
import { createSubsystemLogger } from "../logging/subsystem.js";
3131
import { resolveDebugProxySettings } from "../proxy-capture/env.js";
3232
import {
33+
containsSecretSentinel,
3334
resolveSecretSentinel,
3435
SECRET_SENTINEL_PATTERN,
3536
swapSecretSentinelsInText,
@@ -770,15 +771,15 @@ function headersContainSecretSentinel(headers: HeadersInit | undefined): boolean
770771
return false;
771772
}
772773
for (const value of new Headers(headers).values()) {
773-
if (value.includes("oc-sent-v1-")) {
774+
if (containsSecretSentinel(value)) {
774775
return true;
775776
}
776777
}
777778
return false;
778779
}
779780

780781
function swapSecretSentinelsInUrl(url: string): { text: string; unknown: string[] } {
781-
if (!url.includes("oc-sent-v1-")) {
782+
if (!containsSecretSentinel(url)) {
782783
return { text: url, unknown: [] };
783784
}
784785
const unknown = new Set<string>();
@@ -798,7 +799,7 @@ function swapSecretSentinelsForEgress(params: { url: string; headers?: HeadersIn
798799
url: string;
799800
headers?: Headers;
800801
} {
801-
if (!params.url.includes("oc-sent-v1-") && !headersContainSecretSentinel(params.headers)) {
802+
if (!containsSecretSentinel(params.url) && !headersContainSecretSentinel(params.headers)) {
802803
return { url: params.url };
803804
}
804805
const urlSwap = swapSecretSentinelsInUrl(params.url);

src/logging/redact.test.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,16 @@ describe("registered exact secret values", () => {
7373
expect(redactSensitiveText(`raw ${secret}`, { mode: "off" })).not.toContain(secret);
7474
});
7575

76+
it("masks JSON-escaped registered values", () => {
77+
const secret = 'quoted-"secret\\line\nvalue';
78+
registerSecretValueForRedaction(secret);
79+
80+
const json = JSON.stringify({ credential: secret });
81+
expect(redactSensitiveText(json, { mode: "off" })).not.toContain(
82+
JSON.stringify(secret).slice(1, -1),
83+
);
84+
});
85+
7686
it("evicts the oldest value after 512 registrations", () => {
7787
const first = "exact-registry-value-000";
7888
registerSecretValueForRedaction(first);

src/logging/secret-redaction-registry.ts

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -34,19 +34,31 @@ export function registerSecretValueForRedaction(value: string): void {
3434
if (value.length < MIN_SECRET_VALUE_LENGTH) {
3535
return;
3636
}
37-
registerOneSecretValue(value);
3837
// URL egress percent-encodes injected values; redact that surface form too.
3938
const encoded = encodeURIComponent(value);
4039
if (encoded !== value) {
4140
registerOneSecretValue(encoded);
4241
}
42+
// Captured structured payloads are serialized before persistence, so retain
43+
// the JSON string-content form for credentials with escaped characters.
44+
const jsonEscaped = JSON.stringify(value).slice(1, -1);
45+
if (jsonEscaped !== value) {
46+
registerOneSecretValue(jsonEscaped);
47+
}
48+
// Keep the raw value newest so bounded-registry eviction cannot drop the
49+
// active credential while retaining only a transformed representation.
50+
registerOneSecretValue(value);
4351
}
4452

4553
/** Returns whether a value has SecretRef provenance in the process registry. */
4654
export function isSecretValueRegisteredForRedaction(value: string): boolean {
4755
return registeredValues.has(value);
4856
}
4957

58+
export function hasRegisteredSecretValuesForRedaction(): boolean {
59+
return registeredValues.size > 0;
60+
}
61+
5062
/** Replaces registered exact values while preserving the caller's mask convention. */
5163
export function redactRegisteredSecretValues(
5264
text: string,

0 commit comments

Comments
 (0)