Skip to content

Commit f589b17

Browse files
committed
fix(secret-scrub): keep query tails and ordinary key fields
1 parent 923900a commit f589b17

2 files changed

Lines changed: 78 additions & 10 deletions

File tree

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

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -234,6 +234,59 @@ describe("bare-KEY names in JSON paths", () => {
234234
});
235235
});
236236

237+
describe("query secrets keep characters after quotes", () => {
238+
test("does not leave a tail after a quote in password, api_key, or token", () => {
239+
for (const name of ["password", "api_key", "token"] as const) {
240+
for (const quote of ["'", '"'] as const) {
241+
const text = `GET https://provider.invalid/v1?${name}=p${quote}LEAKTAIL&format=json`;
242+
const out = scrubSecretShapedContent(text);
243+
expect(out).toBe(
244+
`GET https://provider.invalid/v1?${name}=${CREDENTIAL_REDACTION}&format=json`,
245+
);
246+
expect(out).not.toContain("LEAKTAIL");
247+
}
248+
}
249+
});
250+
});
251+
252+
describe("storage key names stay readable", () => {
253+
test("keeps partition, sort, primary, cache, and prompt_cache keys", () => {
254+
const value = {
255+
partition_key: "tenant",
256+
sort_key: 2,
257+
primary_key: ["id"],
258+
cache_key: [1, 2],
259+
prompt_cache_key: 9,
260+
OPENAI_KEY: "sk-live-abc123",
261+
api_key: ["still-secret"],
262+
};
263+
expect(scrubSecretShapedValue(value)).toEqual({
264+
partition_key: "tenant",
265+
sort_key: 2,
266+
primary_key: ["id"],
267+
cache_key: [1, 2],
268+
prompt_cache_key: 9,
269+
OPENAI_KEY: CREDENTIAL_REDACTION,
270+
api_key: CREDENTIAL_REDACTION,
271+
});
272+
273+
const text =
274+
'{"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"}';
275+
const scrubbed = scrubSecretShapedContent(text);
276+
expect(scrubbed).toContain('"partition_key":"tenant"');
277+
expect(scrubbed).toContain('"sort_key":2');
278+
expect(scrubbed).toContain('"primary_key":["id"]');
279+
expect(scrubbed).toContain('"cache_key":[1]');
280+
expect(scrubbed).toContain('"prompt_cache_key":"sess"');
281+
expect(scrubbed).not.toContain("sk-live-abc123");
282+
expect(scrubbed).not.toContain("still-secret");
283+
284+
const query =
285+
"GET https://provider.invalid/v1?sort_key=created_at&partition_key=tenant&format=json";
286+
expect(scrubSecretShapedContent(query)).toBe(query);
287+
});
288+
});
289+
237290
describe("bare-KEY names in query parameters", () => {
238291
test("redacts ?OPENAI_KEY= among benign params, adjacent params survive", () => {
239292
const text =

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

Lines changed: 25 additions & 10 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|[^&#\s='"]*_key)=)(?!\[redacted: looks like a credential\])[^&#\s"']+/gi;
23-
24-
const JSON_CREDENTIAL_FIELD =
25-
/("(?:api[_-]?key|access[_-]?token|token|password|secret|credential|authorization|[^"]+_key)"\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;
@@ -76,11 +89,13 @@ export function scrubSecretShapedContent(text: string): string {
7689
}
7790

7891
const JSON_SAFE_ERROR = "Tool result is not JSON-safe";
79-
// Bare-KEY class (OPENAI_KEY, MY_API_KEY): the underscore boundary keeps
80-
// MONKEY-style words out, and the required prefix keeps an ordinary field
81-
// named exactly "key" readable.
82-
const CREDENTIAL_FIELD =
83-
/^(?:api[_ -]?key|access[_ -]?token|token|password|secret|credential|authorization|auth|.+_key)$/i;
92+
// Bare-KEY class (OPENAI_KEY, MY_API_KEY). The marker segment keeps storage
93+
// roles (partition_key, sort_key, cache_key) readable, and requiring _key
94+
// keeps MONKEY and a field named exactly "key" readable.
95+
const CREDENTIAL_FIELD = new RegExp(
96+
`^(?:api[_ -]?key|access[_ -]?token|token|password|secret|credential|authorization|auth|${MARKER_KEY_SUFFIX})$`,
97+
"i",
98+
);
8499

85100
/**
86101
* Returns a detached JSON-safe value without invoking input accessors or custom

0 commit comments

Comments
 (0)