Skip to content

Commit 8a38035

Browse files
committed
fix(plugins): stop secret scrub from eating plain URLs
1 parent 6d157e2 commit 8a38035

2 files changed

Lines changed: 59 additions & 14 deletions

File tree

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

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,39 @@ MIIEpAIBAAKCAQEA7
6666
"DATABASE_URL=sqlite:///local.db",
6767
);
6868
});
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+
});
69102
});
70103

71104
describe("scrubSecretShapedValue", () => {

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

Lines changed: 26 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -36,24 +36,36 @@ function isConnectionStringEnvKey(key: string): boolean {
3636
);
3737
}
3838

39-
// True when a connection-string value actually carries credentials: a URI with
40-
// userinfo (scheme://user:pass@host/...) or embedded password material.
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.
4145
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-
);
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(":");
4762
}
4863

4964
function isSecretEnvKey(key: string, value: string): boolean {
50-
if (
51-
key === "API_KEY" ||
52-
/(?:SECRET|TOKEN|PASSWORD|PRIVATE|CREDENTIAL|AUTH)/.test(key)
53-
) {
65+
if (/(?:SECRET|TOKEN|PASSWORD|PRIVATE|CREDENTIAL|AUTH)/.test(key)) {
5466
return true;
5567
}
56-
// Bare-KEY class (OPENAI_KEY, STRIPE_KEY, *_API_KEY): ENV-assignment scope
68+
// Bare-KEY class (OPENAI_KEY, STRIPE_KEY, API_KEY): ENV-assignment scope
5769
// only. The (^|_) boundary keeps plain words ending in KEY (MONKEY) out.
5870
if (/(?:^|_)KEY$/.test(key)) return true;
5971
if (isConnectionStringEnvKey(key)) return carriesConnectionCredentials(value);
@@ -91,8 +103,8 @@ export function scrubSecretShapedContent(text: string): string {
91103
ENV_ASSIGNMENT,
92104
(match, key: string, value: string) => {
93105
if (!isSecretEnvKey(key, value)) return match;
94-
const prefixEnd = match.lastIndexOf(key);
95-
const prefix = match.slice(0, prefixEnd);
106+
const assignment = `${key}=${value}`;
107+
const prefix = match.slice(0, match.length - assignment.length);
96108
return `${prefix}${key}=${CREDENTIAL_REDACTION}`;
97109
},
98110
);

0 commit comments

Comments
 (0)