Skip to content

Commit a2e5024

Browse files
committed
perf(plugins): skip secret scrub passes without a secret shape
A single literal superset scan rejects clean tool output before the PEM, API key, query, JSON, and env redaction passes. The scan matches every string those patterns match, so redaction outcomes stay the same.
1 parent f5e3cd2 commit a2e5024

2 files changed

Lines changed: 263 additions & 0 deletions

File tree

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

Lines changed: 167 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,10 @@
11
import { describe, test, expect } from "bun:test";
22
import {
33
CREDENTIAL_REDACTION,
4+
countExpensiveSecretScrubPasses,
45
scrubSecretShapedContent,
56
scrubSecretShapedValue,
7+
secretScrubPatternHits,
68
} from "./tool-result-secret-scrub.js";
79
import { toolResultSecretScrubPlugin } from "./tool-result-secret-scrub-plugin.js";
810
import { resultTruncationPlugin } from "./result-truncation-plugin.js";
@@ -462,3 +464,168 @@ describe("toolResultSecretScrubPlugin", () => {
462464
expect(result.content.split(CREDENTIAL_REDACTION).length - 1).toBe(1);
463465
});
464466
});
467+
468+
const SENTINEL = "zzzsecretvalue";
469+
470+
function expectEnteredAndRedacted(text: string): void {
471+
expect(secretScrubPatternHits(text)).toBe(true);
472+
const passes = countExpensiveSecretScrubPasses(() => {
473+
const out = scrubSecretShapedContent(text);
474+
expect(out).not.toContain(SENTINEL);
475+
expect(out).toContain(CREDENTIAL_REDACTION);
476+
});
477+
expect(passes).toBe(1);
478+
}
479+
480+
function expectEnteredUnchanged(text: string): void {
481+
expect(secretScrubPatternHits(text)).toBe(true);
482+
const passes = countExpensiveSecretScrubPasses(() => {
483+
expect(scrubSecretShapedContent(text)).toBe(text);
484+
});
485+
expect(passes).toBe(1);
486+
}
487+
488+
function expectSkipped(text: string): void {
489+
const passes = countExpensiveSecretScrubPasses(() => {
490+
expect(scrubSecretShapedContent(text)).toBe(text);
491+
});
492+
expect(passes).toBe(0);
493+
expect(secretScrubPatternHits(text)).toBe(false);
494+
}
495+
496+
describe("secret scrub prefilter", () => {
497+
test("a large clean fixture does not enter the scrub passes", () => {
498+
const clean = "The quick brown fox jumps over the lazy dog. ".repeat(5000);
499+
expectSkipped(clean);
500+
const secret = `sk-${"a".repeat(6)}${SENTINEL}`;
501+
expectEnteredAndRedacted(`${clean}${secret}`);
502+
});
503+
504+
test("prefilter state does not skip a later secret", () => {
505+
const first = `sk-${"a".repeat(6)}${SENTINEL}`;
506+
const second = `ghp_${"b".repeat(22)}${SENTINEL}`;
507+
const passes = countExpensiveSecretScrubPasses(() => {
508+
expect(scrubSecretShapedContent(first)).not.toContain(SENTINEL);
509+
expect(scrubSecretShapedContent(second)).not.toContain(SENTINEL);
510+
});
511+
expect(passes).toBe(2);
512+
});
513+
514+
test("every scrub pattern still redacts and enters the passes", () => {
515+
const pem = (kind: string) => {
516+
const label = kind.length === 0 ? "PRIVATE KEY" : `${kind} PRIVATE KEY`;
517+
return `-----BEGIN ${label}-----\n${SENTINEL}\n-----END ${label}-----`;
518+
};
519+
for (const kind of ["", "RSA", "EC", "OPENSSH", "ENCRYPTED"]) {
520+
expectEnteredAndRedacted(pem(kind));
521+
}
522+
523+
expectEnteredAndRedacted(`prefix sk-${"a".repeat(6)}${SENTINEL} tail`);
524+
expectEnteredAndRedacted(`ghp_${"a".repeat(22)}${SENTINEL}`);
525+
expectEnteredAndRedacted(`gho_${"a".repeat(22)}${SENTINEL}`);
526+
for (const flavor of ["b", "a", "p", "r", "s"]) {
527+
expectEnteredAndRedacted(`xox${flavor}-${SENTINEL}`);
528+
}
529+
const akia = `AKIA${"A".repeat(16)}`;
530+
expect(secretScrubPatternHits(akia)).toBe(true);
531+
const akiaPasses = countExpensiveSecretScrubPasses(() => {
532+
expect(scrubSecretShapedContent(akia)).not.toContain(akia);
533+
});
534+
expect(akiaPasses).toBe(1);
535+
expectEnteredAndRedacted(`Bearer ${"a".repeat(6)}${SENTINEL}`);
536+
expectEnteredAndRedacted(`Bearer\t${"a".repeat(6)}${SENTINEL}`);
537+
expectEnteredAndRedacted(`Basic ${SENTINEL}`);
538+
expectEnteredAndRedacted(`Basic\n${SENTINEL}==`);
539+
540+
const queryKeys = [
541+
"apikey",
542+
"api_key",
543+
"api-key",
544+
"ApiKey",
545+
"accesstoken",
546+
"access_token",
547+
"access-token",
548+
"AccessToken",
549+
"token",
550+
"TOKEN",
551+
"password",
552+
"secret",
553+
"credential",
554+
];
555+
const markers = [
556+
"api",
557+
"secret",
558+
"private",
559+
"access",
560+
"auth",
561+
"token",
562+
"password",
563+
"credential",
564+
"openai",
565+
];
566+
for (const prefix of ["", "my_", "a1_", "my_app_"]) {
567+
for (const marker of markers) {
568+
queryKeys.push(`${prefix}${marker}_key`);
569+
queryKeys.push(`${prefix}${marker}_key`.toUpperCase());
570+
}
571+
}
572+
for (const key of queryKeys) {
573+
expectEnteredAndRedacted(
574+
`GET https://provider.invalid/v1?${key}=${SENTINEL}&format=json`,
575+
);
576+
expectEnteredAndRedacted(`{"${key}":"${SENTINEL}"}`);
577+
expectEnteredAndRedacted(`{ "${key}" : "${SENTINEL}" }`);
578+
}
579+
expectEnteredAndRedacted(`{"authorization":"${SENTINEL}"}`);
580+
expectEnteredAndRedacted(`{ "Authorization" : "${SENTINEL}" }`);
581+
582+
expectEnteredAndRedacted(`API_KEY=${SENTINEL}`);
583+
expectEnteredAndRedacted(`STRIPE_KEY=${SENTINEL}`);
584+
expectEnteredAndRedacted(`GITHUB_KEY=${SENTINEL}`);
585+
expectEnteredAndRedacted(`OPENAI_KEY=${SENTINEL}`);
586+
expectEnteredAndRedacted(`./app/.env:3:TOKEN=${SENTINEL}`);
587+
expectEnteredAndRedacted(`\nPASSWORD=${SENTINEL}`);
588+
expectEnteredAndRedacted(`note:PRIVATE=${SENTINEL}`);
589+
expectEnteredAndRedacted(`CREDENTIAL=${SENTINEL}`);
590+
expectEnteredAndRedacted(`AUTH=${SENTINEL}`);
591+
expectEnteredAndRedacted(`SECRET=${SENTINEL}`);
592+
expectEnteredAndRedacted(`OPENAI_KEY=proj-OPENAI_KEY-${SENTINEL}`);
593+
expectEnteredAndRedacted(
594+
`DATABASE_URL=postgres://admin:${SENTINEL}@db.internal:5432/appdb`,
595+
);
596+
expectEnteredAndRedacted(
597+
`DATABASE_URL=Server=db.internal;Password=${SENTINEL};`,
598+
);
599+
expectEnteredAndRedacted(
600+
`FOO_URI=postgres://admin:${SENTINEL}@db.internal/app`,
601+
);
602+
expectEnteredAndRedacted(`FOO_DSN=Password=${SENTINEL}`);
603+
expectEnteredAndRedacted(`FOO_CONNECTION_STRING=Password=${SENTINEL}`);
604+
});
605+
606+
test("env matches that are not secrets still enter the passes", () => {
607+
expectEnteredUnchanged("MONKEY=banana");
608+
expectEnteredUnchanged("AB=cd");
609+
expectEnteredUnchanged("DATABASE_URL=sqlite:///local.db");
610+
expectEnteredUnchanged(
611+
"REGISTRY_URL=https://registry.npmjs.org/@scope/pkg",
612+
);
613+
expectEnteredUnchanged("DOCS_URL=https://medium.com/@octocat/hello");
614+
expectEnteredUnchanged(
615+
"SUPPORT_URL=https://example.com/help?email=dev@example.com",
616+
);
617+
});
618+
619+
test("prose and storage keys do not enter the scrub passes", () => {
620+
expectSkipped(
621+
"See docs/authentication.md for how API keys are issued and rotated. No secrets in this paragraph.",
622+
);
623+
expectSkipped(
624+
"GET https://provider.invalid/v1?sort_key=created_at&partition_key=tenant&format=json",
625+
);
626+
expectSkipped(
627+
'{"partition_key":"tenant","sort_key":2,"primary_key":["id"],"cache_key":[1],"prompt_cache_key":"sess"}',
628+
);
629+
expectSkipped('{"MONKEY": "banana", "key": "ctrl+s"}');
630+
});
631+
});

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

Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
import { AsyncLocalStorage } from "node:async_hooks";
2+
13
// Scrub secret-shaped substrings before untrusted text reaches a transcript or
24
// terminal. Complements secret-guard (path denylist) for secrets that surface
35
// in tool output and upstream diagnostics.
@@ -93,7 +95,101 @@ function replaceAll(
9395
return text.replace(pattern, replacement);
9496
}
9597

98+
// One scan before the full-text passes. Each alternative drops constraints
99+
// from exactly one pattern above, so any match of those patterns is a match
100+
// here. Extra matches are safe: they only pay for the passes. /i is required
101+
// for query and JSON case-folding; it also over-matches the case-sensitive
102+
// patterns. Not global — RegExp.test would otherwise keep lastIndex.
103+
//
104+
// PEM: every match contains "PRIVATE KEY-----".
105+
// API keys: sk- / ghp_ / gho_ / xox[baprs]- / AKIA / Bearer\s / Basic\s
106+
// (\b and minimum lengths are stricter than these literals).
107+
// Query: the key is glued to "=". access[_-]?token always contains "token=".
108+
// Marker prefixes sit in front of "{marker}_key=", so they cannot hide it.
109+
// JSON: the same keys are glued to a closing quote, optional space, and ":".
110+
// access[_-]?token therefore contains token"\s*:. authorization is JSON-only.
111+
// Prefixed marker keys still contain {marker}_key"\s*:.
112+
// ENV: every match contains (?:^|:)[A-Z][A-Z0-9_]+= with /m. The value length
113+
// check is dropped. /i makes this alternative match lowercase too.
114+
const SECRET_SHAPE_PREFILTER = new RegExp(
115+
[
116+
"PRIVATE KEY-----",
117+
"sk-",
118+
"ghp_",
119+
"gho_",
120+
String.raw`xox[baprs]-`,
121+
"AKIA",
122+
String.raw`Bearer\s`,
123+
String.raw`Basic\s`,
124+
"api[-_]?key=",
125+
"token=",
126+
"password=",
127+
"secret=",
128+
"credential=",
129+
"secret_key=",
130+
"private_key=",
131+
"access_key=",
132+
"auth_key=",
133+
"token_key=",
134+
"password_key=",
135+
"credential_key=",
136+
"openai_key=",
137+
String.raw`api[-_]?key"\s*:`,
138+
String.raw`token"\s*:`,
139+
String.raw`password"\s*:`,
140+
String.raw`secret"\s*:`,
141+
String.raw`credential"\s*:`,
142+
String.raw`authorization"\s*:`,
143+
String.raw`secret_key"\s*:`,
144+
String.raw`private_key"\s*:`,
145+
String.raw`access_key"\s*:`,
146+
String.raw`auth_key"\s*:`,
147+
String.raw`token_key"\s*:`,
148+
String.raw`password_key"\s*:`,
149+
String.raw`credential_key"\s*:`,
150+
String.raw`openai_key"\s*:`,
151+
String.raw`(?:^|:)[A-Z][A-Z0-9_]+=`,
152+
].join("|"),
153+
"im",
154+
);
155+
156+
const expensivePassCounter = new AsyncLocalStorage<{ count: number }>();
157+
158+
function noteExpensiveSecretScrubPass(): void {
159+
const counter = expensivePassCounter.getStore();
160+
if (counter !== undefined) counter.count += 1;
161+
}
162+
163+
/** Runs `run` and reports entries into the full-text scrub passes. */
164+
export function countExpensiveSecretScrubPasses(run: () => void): number {
165+
const counter = { count: 0 };
166+
expensivePassCounter.run(counter, run);
167+
return counter.count;
168+
}
169+
170+
function patternHits(pattern: RegExp, text: string): boolean {
171+
pattern.lastIndex = 0;
172+
try {
173+
return pattern.test(text);
174+
} finally {
175+
pattern.lastIndex = 0;
176+
}
177+
}
178+
179+
/** True when any scrub pattern matches. Test seam for the prefilter. */
180+
export function secretScrubPatternHits(text: string): boolean {
181+
return (
182+
patternHits(PEM_BLOCK, text) ||
183+
API_KEY_PATTERNS.some((pattern) => patternHits(pattern, text)) ||
184+
patternHits(SECRET_QUERY_PARAMETER, text) ||
185+
patternHits(JSON_CREDENTIAL_FIELD, text) ||
186+
patternHits(ENV_ASSIGNMENT, text)
187+
);
188+
}
189+
96190
export function scrubSecretShapedContent(text: string): string {
191+
if (!SECRET_SHAPE_PREFILTER.test(text)) return text;
192+
noteExpensiveSecretScrubPass();
97193
let result = text;
98194

99195
result = replaceAll(result, PEM_BLOCK, CREDENTIAL_REDACTION);

0 commit comments

Comments
 (0)