Skip to content

Commit 170480e

Browse files
Merge pull request #1476 from corbitsdev/cl-10756-secret-scrub-key-class
fix(plugins): widen secret-scrub to bare-KEY and credentialed URLs
2 parents 3293edd + 8a38035 commit 170480e

2 files changed

Lines changed: 125 additions & 13 deletions

File tree

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

Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,63 @@ 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+
});
69+
70+
test("leaves scoped npm URLs and email-query URLs readable", () => {
71+
const registry = "REGISTRY_URL=https://registry.npmjs.org/@scope/pkg";
72+
const docs = "DOCS_URL=https://medium.com/@octocat/hello";
73+
const support =
74+
"SUPPORT_URL=https://example.com/help?email=dev@example.com";
75+
expect(scrubSecretShapedContent(registry)).toBe(registry);
76+
expect(scrubSecretShapedContent(docs)).toBe(docs);
77+
expect(scrubSecretShapedContent(support)).toBe(support);
78+
});
79+
80+
test("redacts user:pass@host connection URLs", () => {
81+
const text = "DATABASE_URL=postgres://admin:foo@db.internal:5432/appdb";
82+
const out = scrubSecretShapedContent(text);
83+
expect(out).toBe(`DATABASE_URL=${CREDENTIAL_REDACTION}`);
84+
expect(out).not.toContain("admin:foo");
85+
});
86+
87+
test("redacts the whole env value when it contains its own key", () => {
88+
const text = "OPENAI_KEY=proj-OPENAI_KEY-live-value-9z";
89+
const out = scrubSecretShapedContent(text);
90+
expect(out).toBe(`OPENAI_KEY=${CREDENTIAL_REDACTION}`);
91+
expect(out).not.toContain("proj-");
92+
expect(out).not.toContain("live-value-9z");
93+
});
94+
95+
test("redacts Password= DSNs that lack a scheme", () => {
96+
const text =
97+
"DATABASE_URL=Server=db.internal;Database=appdb;User Id=admin;Password=s3cret-pw;";
98+
const out = scrubSecretShapedContent(text);
99+
expect(out).toBe(`DATABASE_URL=${CREDENTIAL_REDACTION}`);
100+
expect(out).not.toContain("s3cret-pw");
101+
});
45102
});
46103

47104
describe("scrubSecretShapedValue", () => {
@@ -219,6 +276,24 @@ describe("toolResultSecretScrubPlugin", () => {
219276
expect(result.content).toContain(CREDENTIAL_REDACTION);
220277
expect(result.content).not.toContain("supersecretvalue");
221278
});
279+
test("scrubs bare-KEY and DATABASE_URL through the shared middleware path (CL-10756)", async () => {
280+
const plugin = toolResultSecretScrubPlugin();
281+
if (plugin.middleware === undefined)
282+
throw new Error("expected middleware plugin");
283+
const handler = plugin.middleware(
284+
next(
285+
"secrets/.env:7:OPENAI_KEY=proj-live-value-9z\nsecrets/.env:2:DATABASE_URL=postgres://admin:s3cret-pw@db.internal:5432/appdb",
286+
),
287+
);
288+
const result = await handler(
289+
{ id: "c2", name: "read_file", arguments: { path: "secrets/.env" } },
290+
new AbortController().signal,
291+
);
292+
expect(result.content).toContain(`OPENAI_KEY=${CREDENTIAL_REDACTION}`);
293+
expect(result.content).toContain(`DATABASE_URL=${CREDENTIAL_REDACTION}`);
294+
expect(result.content).not.toContain("proj-live-value-9z");
295+
expect(result.content).not.toContain("s3cret-pw");
296+
});
222297
test("scrubs error results without stringifying object content", async () => {
223298
const plugin = toolResultSecretScrubPlugin();
224299
if (plugin.middleware === undefined)

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

Lines changed: 50 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -27,17 +27,51 @@ 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 (
36-
key === "API_KEY" ||
37-
/(?:SECRET|TOKEN|PASSWORD|PRIVATE|CREDENTIAL|AUTH)/.test(key)
35+
/(?:_|^)(?:URL|URI|DSN)$/.test(key) || key.includes("CONNECTION_STRING")
3836
);
3937
}
4038

39+
// True when a connection-string value actually carries credentials: URI
40+
// userinfo of the form scheme://user:pass@host (a colon in the authority
41+
// before @, and before the next / or ?), or embedded password= material.
42+
// ADO-style DSNs carry Password= without a scheme, so the password check
43+
// does not require ://. An @ in the path or query (npm scopes, profile
44+
// URLs, email query params) is not userinfo.
45+
function carriesConnectionCredentials(value: string): boolean {
46+
return /password=/i.test(value) || hasCredentialUserinfo(value);
47+
}
48+
49+
function hasCredentialUserinfo(value: string): boolean {
50+
const schemeSep = value.indexOf("://");
51+
if (schemeSep < 0) return false;
52+
const rest = value.slice(schemeSep + 3);
53+
const path = rest.indexOf("/");
54+
const query = rest.indexOf("?");
55+
let authorityEnd = rest.length;
56+
if (path >= 0) authorityEnd = Math.min(authorityEnd, path);
57+
if (query >= 0) authorityEnd = Math.min(authorityEnd, query);
58+
const authority = rest.slice(0, authorityEnd);
59+
const at = authority.indexOf("@");
60+
if (at < 0) return false;
61+
return authority.slice(0, at).includes(":");
62+
}
63+
64+
function isSecretEnvKey(key: string, value: string): boolean {
65+
if (/(?:SECRET|TOKEN|PASSWORD|PRIVATE|CREDENTIAL|AUTH)/.test(key)) {
66+
return true;
67+
}
68+
// Bare-KEY class (OPENAI_KEY, STRIPE_KEY, API_KEY): ENV-assignment scope
69+
// only. The (^|_) boundary keeps plain words ending in KEY (MONKEY) out.
70+
if (/(?:^|_)KEY$/.test(key)) return true;
71+
if (isConnectionStringEnvKey(key)) return carriesConnectionCredentials(value);
72+
return false;
73+
}
74+
4175
function replaceAll(
4276
text: string,
4377
pattern: RegExp,
@@ -65,12 +99,15 @@ export function scrubSecretShapedContent(text: string): string {
6599
`${prefix}${CREDENTIAL_REDACTION}${suffix}`,
66100
);
67101

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-
});
102+
result = result.replace(
103+
ENV_ASSIGNMENT,
104+
(match, key: string, value: string) => {
105+
if (!isSecretEnvKey(key, value)) return match;
106+
const assignment = `${key}=${value}`;
107+
const prefix = match.slice(0, match.length - assignment.length);
108+
return `${prefix}${key}=${CREDENTIAL_REDACTION}`;
109+
},
110+
);
74111

75112
return result;
76113
}

0 commit comments

Comments
 (0)