Skip to content

Commit 510d686

Browse files
Merge pull request #1471 from corbitsdev/cl-10761-secret-scrub-misses-bare-key-names-in-json-objecttext-paths
fix(secret-scrub): redact bare-KEY names in JSON object and text paths
2 parents a7dfdd9 + f589b17 commit 510d686

2 files changed

Lines changed: 144 additions & 7 deletions

File tree

‎src/plugins/tool-result-secret-scrub.test.ts‎

Lines changed: 119 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -253,6 +253,125 @@ describe("scrubSecretShapedValue key handling", () => {
253253
});
254254
});
255255

256+
describe("bare-KEY names in JSON paths", () => {
257+
test("redacts bare-KEY names in JSON objects", () => {
258+
const out = scrubSecretShapedValue({
259+
OPENAI_KEY: "sk-live-abc123",
260+
nested: { MY_API_KEY: "stripe-live-xyz" },
261+
});
262+
expect(out).toEqual({
263+
OPENAI_KEY: CREDENTIAL_REDACTION,
264+
nested: { MY_API_KEY: CREDENTIAL_REDACTION },
265+
});
266+
});
267+
268+
test("redacts bare-KEY names in JSON text", () => {
269+
const pretty = scrubSecretShapedContent('{"OPENAI_KEY": "sk-live-abc123"}');
270+
expect(pretty).toBe(`{"OPENAI_KEY": "${CREDENTIAL_REDACTION}"}`);
271+
const compact = scrubSecretShapedContent('{"OPENAI_KEY":"sk-live-abc123"}');
272+
expect(compact).toBe(`{"OPENAI_KEY":"${CREDENTIAL_REDACTION}"}`);
273+
expect(compact).not.toContain("sk-live-abc123");
274+
});
275+
276+
test("leaves MONKEY-style words and ordinary key fields alone", () => {
277+
const value = scrubSecretShapedValue({
278+
MONKEY: "banana",
279+
monkey: "banana",
280+
key: "ctrl+s",
281+
hotkey: "ctrl+s",
282+
});
283+
expect(value).toEqual({
284+
MONKEY: "banana",
285+
monkey: "banana",
286+
key: "ctrl+s",
287+
hotkey: "ctrl+s",
288+
});
289+
const text = '{"MONKEY": "banana", "key": "ctrl+s"}';
290+
expect(scrubSecretShapedContent(text)).toBe(text);
291+
});
292+
});
293+
294+
describe("query secrets keep characters after quotes", () => {
295+
test("does not leave a tail after a quote in password, api_key, or token", () => {
296+
for (const name of ["password", "api_key", "token"] as const) {
297+
for (const quote of ["'", '"'] as const) {
298+
const text = `GET https://provider.invalid/v1?${name}=p${quote}LEAKTAIL&format=json`;
299+
const out = scrubSecretShapedContent(text);
300+
expect(out).toBe(
301+
`GET https://provider.invalid/v1?${name}=${CREDENTIAL_REDACTION}&format=json`,
302+
);
303+
expect(out).not.toContain("LEAKTAIL");
304+
}
305+
}
306+
});
307+
});
308+
309+
describe("storage key names stay readable", () => {
310+
test("keeps partition, sort, primary, cache, and prompt_cache keys", () => {
311+
const value = {
312+
partition_key: "tenant",
313+
sort_key: 2,
314+
primary_key: ["id"],
315+
cache_key: [1, 2],
316+
prompt_cache_key: 9,
317+
OPENAI_KEY: "sk-live-abc123",
318+
api_key: ["still-secret"],
319+
};
320+
expect(scrubSecretShapedValue(value)).toEqual({
321+
partition_key: "tenant",
322+
sort_key: 2,
323+
primary_key: ["id"],
324+
cache_key: [1, 2],
325+
prompt_cache_key: 9,
326+
OPENAI_KEY: CREDENTIAL_REDACTION,
327+
api_key: CREDENTIAL_REDACTION,
328+
});
329+
330+
const text =
331+
'{"partition_key":"tenant","sort_key":2,"primary_key":["id"],"cache_key":[1],"prompt_cache_key":"sess","OPENAI_KEY":"sk-live-abc123","api_key":"still-secret"}';
332+
const scrubbed = scrubSecretShapedContent(text);
333+
expect(scrubbed).toContain('"partition_key":"tenant"');
334+
expect(scrubbed).toContain('"sort_key":2');
335+
expect(scrubbed).toContain('"primary_key":["id"]');
336+
expect(scrubbed).toContain('"cache_key":[1]');
337+
expect(scrubbed).toContain('"prompt_cache_key":"sess"');
338+
expect(scrubbed).not.toContain("sk-live-abc123");
339+
expect(scrubbed).not.toContain("still-secret");
340+
341+
const query =
342+
"GET https://provider.invalid/v1?sort_key=created_at&partition_key=tenant&format=json";
343+
expect(scrubSecretShapedContent(query)).toBe(query);
344+
});
345+
});
346+
347+
describe("bare-KEY names in query parameters", () => {
348+
test("redacts ?OPENAI_KEY= among benign params, adjacent params survive", () => {
349+
const text =
350+
"GET https://provider.invalid/v1?model=test&OPENAI_KEY=live-value&format=json";
351+
const out = scrubSecretShapedContent(text);
352+
expect(out).toBe(
353+
`GET https://provider.invalid/v1?model=test&OPENAI_KEY=${CREDENTIAL_REDACTION}&format=json`,
354+
);
355+
expect(out).not.toContain("live-value");
356+
});
357+
358+
test("redacts bare-KEY query params case-insensitively with quote bounds", () => {
359+
const out = scrubSecretShapedContent(
360+
'{"url": "https://provider.invalid/v1?openai_key=live-value&model=test"}',
361+
);
362+
expect(out).toBe(
363+
`{"url": "https://provider.invalid/v1?openai_key=${CREDENTIAL_REDACTION}&model=test"}`,
364+
);
365+
expect(out).not.toContain("live-value");
366+
});
367+
368+
test("leaves MONKEY-style and ordinary query params alone", () => {
369+
const text =
370+
"GET https://provider.invalid/v1?MONKEY=banana&model=test&format=json";
371+
expect(scrubSecretShapedContent(text)).toBe(text);
372+
});
373+
});
374+
256375
describe("toolResultSecretScrubPlugin", () => {
257376
const next =
258377
(content: ToolResult["content"], isError = false) =>

‎src/plugins/tool-result-secret-scrub.ts‎

Lines changed: 25 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -18,11 +18,24 @@ const API_KEY_PATTERNS: RegExp[] = [
1818
/\bBasic\s+[a-zA-Z0-9+/]{8,}={0,2}/g,
1919
];
2020

21-
const SECRET_QUERY_PARAMETER =
22-
/([?&](?:api[_-]?key|access[_-]?token|token|password|secret|credential)=)(?!\[redacted: looks like a credential\])[^&#\s]+/gi;
23-
24-
const JSON_CREDENTIAL_FIELD =
25-
/("(?:api[_-]?key|access[_-]?token|token|password|secret|credential|authorization)"\s*:\s*")([^"\r\n]+)(")/gi;
21+
// A *_key name is a credential only when a secret marker is its own
22+
// underscore-separated segment (api_key, MY_API_KEY, private_key, OPENAI_KEY).
23+
// Storage roles such as partition_key, sort_key, primary_key, cache_key, and
24+
// prompt_cache_key have no such segment, so they stay. Marker-free vendor keys
25+
// are left alone on purpose: missing a rare key beats wiping database keys.
26+
const CREDENTIAL_KEY_MARKER =
27+
"api|secret|private|access|auth|token|password|credential|openai";
28+
const MARKER_KEY_SUFFIX = `(?:[a-z0-9]+_)*(?:${CREDENTIAL_KEY_MARKER})_key`;
29+
30+
const SECRET_QUERY_PARAMETER = new RegExp(
31+
`([?&](?:api[_-]?key|access[_-]?token|token|password|secret|credential|${MARKER_KEY_SUFFIX})=)(?!\\[redacted: looks like a credential\\])[^&#\\s]+`,
32+
"gi",
33+
);
34+
35+
const JSON_CREDENTIAL_FIELD = new RegExp(
36+
`("(?:api[_-]?key|access[_-]?token|token|password|secret|credential|authorization|${MARKER_KEY_SUFFIX})"\\s*:\\s*")([^"\\r\\n]+)(")`,
37+
"gi",
38+
);
2639

2740
// Grep/shell lines often look like path:line:KEY=value
2841
const ENV_ASSIGNMENT = /(?:^|:)([A-Z][A-Z0-9_]+)=([^\n]+)/gm;
@@ -113,8 +126,13 @@ export function scrubSecretShapedContent(text: string): string {
113126
}
114127

115128
const JSON_SAFE_ERROR = "Tool result is not JSON-safe";
116-
const CREDENTIAL_FIELD =
117-
/^(?:api[_ -]?key|access[_ -]?token|token|password|secret|credential|authorization|auth)$/i;
129+
// Bare-KEY class (OPENAI_KEY, MY_API_KEY). The marker segment keeps storage
130+
// roles (partition_key, sort_key, cache_key) readable, and requiring _key
131+
// keeps MONKEY and a field named exactly "key" readable.
132+
const CREDENTIAL_FIELD = new RegExp(
133+
`^(?:api[_ -]?key|access[_ -]?token|token|password|secret|credential|authorization|auth|${MARKER_KEY_SUFFIX})$`,
134+
"i",
135+
);
118136

119137
/**
120138
* Returns a detached JSON-safe value without invoking input accessors or custom

0 commit comments

Comments
 (0)