Skip to content

Commit 6d157e2

Browse files
committed
fix(plugins): widen secret-scrub to bare-KEY and credentialed URLs
1 parent c63783c commit 6d157e2

2 files changed

Lines changed: 79 additions & 12 deletions

File tree

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

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,30 @@ MIIEpAIBAAKCAQEA7
4242
"See docs/authentication.md for how API keys are issued and rotated. No secrets in this paragraph.";
4343
expect(scrubSecretShapedContent(text)).toBe(text);
4444
});
45+
46+
test("redacts bare-KEY env assignments (CL-10756)", () => {
47+
for (const key of ["OPENAI_KEY", "STRIPE_KEY", "GITHUB_KEY"]) {
48+
const text = `./app/.env:7:${key}=proj-live-value-9z`;
49+
const out = scrubSecretShapedContent(text);
50+
expect(out).toContain(`${key}=${CREDENTIAL_REDACTION}`);
51+
expect(out).not.toContain("proj-live-value-9z");
52+
}
53+
});
54+
55+
test("redacts credential-bearing DATABASE_URL connection strings (CL-10756)", () => {
56+
const text =
57+
"./app/.env:2:DATABASE_URL=postgres://admin:s3cret-pw@db.internal:5432/appdb";
58+
const out = scrubSecretShapedContent(text);
59+
expect(out).toContain(`DATABASE_URL=${CREDENTIAL_REDACTION}`);
60+
expect(out).not.toContain("s3cret-pw");
61+
});
62+
63+
test("leaves non-secret KEY words and credential-less URLs alone (CL-10756)", () => {
64+
expect(scrubSecretShapedContent("MONKEY=banana")).toBe("MONKEY=banana");
65+
expect(scrubSecretShapedContent("DATABASE_URL=sqlite:///local.db")).toBe(
66+
"DATABASE_URL=sqlite:///local.db",
67+
);
68+
});
4569
});
4670

4771
describe("scrubSecretShapedValue", () => {
@@ -219,6 +243,24 @@ describe("toolResultSecretScrubPlugin", () => {
219243
expect(result.content).toContain(CREDENTIAL_REDACTION);
220244
expect(result.content).not.toContain("supersecretvalue");
221245
});
246+
test("scrubs bare-KEY and DATABASE_URL through the shared middleware path (CL-10756)", async () => {
247+
const plugin = toolResultSecretScrubPlugin();
248+
if (plugin.middleware === undefined)
249+
throw new Error("expected middleware plugin");
250+
const handler = plugin.middleware(
251+
next(
252+
"secrets/.env:7:OPENAI_KEY=proj-live-value-9z\nsecrets/.env:2:DATABASE_URL=postgres://admin:s3cret-pw@db.internal:5432/appdb",
253+
),
254+
);
255+
const result = await handler(
256+
{ id: "c2", name: "read_file", arguments: { path: "secrets/.env" } },
257+
new AbortController().signal,
258+
);
259+
expect(result.content).toContain(`OPENAI_KEY=${CREDENTIAL_REDACTION}`);
260+
expect(result.content).toContain(`DATABASE_URL=${CREDENTIAL_REDACTION}`);
261+
expect(result.content).not.toContain("proj-live-value-9z");
262+
expect(result.content).not.toContain("s3cret-pw");
263+
});
222264
test("scrubs error results without stringifying object content", async () => {
223265
const plugin = toolResultSecretScrubPlugin();
224266
if (plugin.middleware === undefined)

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

Lines changed: 37 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -27,15 +27,37 @@ const JSON_CREDENTIAL_FIELD =
2727
// Grep/shell lines often look like path:line:KEY=value
2828
const ENV_ASSIGNMENT = /(?:^|:)([A-Z][A-Z0-9_]+)=([^\n]+)/gm;
2929

30-
// Connection-string keys (DATABASE_URL and friends) are deliberately NOT
31-
// matched here. Widening this shape-classifier would redact every benign
32-
// connection string in tool output — a false-positive blast radius on a scrub
33-
// path, not a prompt path. The gap stays documented, not silently fixed.
34-
function isSecretEnvKey(key: string): boolean {
30+
// Connection-string env names (DATABASE_URL and friends). Redaction additionally
31+
// requires a credential-bearing value (see carriesConnectionCredentials), so
32+
// benign sqlite paths and bare hostnames in tool output stay readable.
33+
function isConnectionStringEnvKey(key: string): boolean {
3534
return (
35+
/(?:_|^)(?:URL|URI|DSN)$/.test(key) || key.includes("CONNECTION_STRING")
36+
);
37+
}
38+
39+
// True when a connection-string value actually carries credentials: a URI with
40+
// userinfo (scheme://user:pass@host/...) or embedded password material.
41+
function carriesConnectionCredentials(value: string): boolean {
42+
if (!value.includes("://")) return false;
43+
return (
44+
value.slice(value.indexOf("://") + 3).includes("@") ||
45+
/password=/i.test(value)
46+
);
47+
}
48+
49+
function isSecretEnvKey(key: string, value: string): boolean {
50+
if (
3651
key === "API_KEY" ||
3752
/(?:SECRET|TOKEN|PASSWORD|PRIVATE|CREDENTIAL|AUTH)/.test(key)
38-
);
53+
) {
54+
return true;
55+
}
56+
// Bare-KEY class (OPENAI_KEY, STRIPE_KEY, *_API_KEY): ENV-assignment scope
57+
// only. The (^|_) boundary keeps plain words ending in KEY (MONKEY) out.
58+
if (/(?:^|_)KEY$/.test(key)) return true;
59+
if (isConnectionStringEnvKey(key)) return carriesConnectionCredentials(value);
60+
return false;
3961
}
4062

4163
function replaceAll(
@@ -65,12 +87,15 @@ export function scrubSecretShapedContent(text: string): string {
6587
`${prefix}${CREDENTIAL_REDACTION}${suffix}`,
6688
);
6789

68-
result = result.replace(ENV_ASSIGNMENT, (match, key: string) => {
69-
if (!isSecretEnvKey(key)) return match;
70-
const prefixEnd = match.lastIndexOf(key);
71-
const prefix = match.slice(0, prefixEnd);
72-
return `${prefix}${key}=${CREDENTIAL_REDACTION}`;
73-
});
90+
result = result.replace(
91+
ENV_ASSIGNMENT,
92+
(match, key: string, value: string) => {
93+
if (!isSecretEnvKey(key, value)) return match;
94+
const prefixEnd = match.lastIndexOf(key);
95+
const prefix = match.slice(0, prefixEnd);
96+
return `${prefix}${key}=${CREDENTIAL_REDACTION}`;
97+
},
98+
);
7499

75100
return result;
76101
}

0 commit comments

Comments
 (0)