From f6e3297b3d9c2eb6e714763d8cc63acf8ddaa2de Mon Sep 17 00:00:00 2001 From: Garry Tan Date: Sat, 15 Aug 2026 16:49:39 -0700 Subject: [PATCH] fix(redact): interpolation allowance can't swallow a real $word password MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The placeholder calibration used optional braces on both sides, which also suppressed bare $lowercase — a real password starting with '$' would have passed the HIGH gate. Interpolation now means ${identifier} (braced, any case) or bare $UPPER_SNAKE only; both connection-string patterns share one validator so they can't drift. Pins added for the bare-$word block, $UPPER allowance, and mismatched-brace block. --- lib/redact-patterns.ts | 37 ++++++++++++++++++++----------------- test/redact-engine.test.ts | 7 +++++++ 2 files changed, 27 insertions(+), 17 deletions(-) diff --git a/lib/redact-patterns.ts b/lib/redact-patterns.ts index e5931fd9e..2870c888f 100644 --- a/lib/redact-patterns.ts +++ b/lib/redact-patterns.ts @@ -254,6 +254,22 @@ export function insideUuid(match: RegExpExecArray): boolean { // ── The taxonomy ───────────────────────────────────────────────────────────── +/** + * URL-embedded passwords that are interpolation forms, not credentials: + * `${identifier}` (bash or JS template, any case) or bare `$UPPER_SNAKE` + * (shell convention). Bare lowercase `$word` stays BLOCKED — a real password + * that merely starts with `$` (e.g. `$` + a dictionary word) must not slip + * through the HIGH gate just because it looks vaguely variable-shaped. + * Shared by db.url_with_password and creds.basic_auth_url so the two + * validators cannot drift. + */ +const INTERPOLATED_PASSWORD_RE = /^(\$\{[A-Za-z_][A-Za-z0-9_]*\}|\$[A-Z_][A-Z0-9_]*)$/; +function urlPasswordIsPlaceholder(span: string): boolean { + const m = span.match(/:\/\/[^:]+:([^@]+)@/); + const pw = m?.[1] ?? ""; + return pw === "" || isPlaceholderSpan(pw) || INTERPOLATED_PASSWORD_RE.test(pw); +} + export const PATTERNS: RedactPattern[] = [ // ===== HIGH — genuinely-secret credentials (block) ===== { @@ -437,15 +453,8 @@ export const PATTERNS: RedactPattern[] = [ category: "secret", description: "Database URL with embedded password", regex: /\b((?:postgres(?:ql)?|mysql|mongodb(?:\+srv)?|redis|amqp):\/\/[^:\s/@]+:[^@\s/]+@[^\s/]+)/, - // Skip when the password segment is itself a placeholder. - validate: (span) => { - const m = span.match(/:\/\/[^:]+:([^@]+)@/); - const pw = m?.[1] ?? ""; - // Any $VAR / ${identifier} interpolation is code, not a credential — - // covers bash ${DB_PASS} and JS template `${dbPass}` alike (the - // uppercase-only form flagged ported TS templates as pushed secrets). - return !isPlaceholderSpan(pw) && pw !== "" && !/^\$\{?[A-Za-z_][A-Za-z0-9_]*\}?$/.test(pw); - }, + // Skip when the password segment is itself a placeholder/interpolation. + validate: (span) => !urlPasswordIsPlaceholder(span), }, { id: "creds.basic_auth_url", @@ -453,14 +462,8 @@ export const PATTERNS: RedactPattern[] = [ category: "secret", description: "HTTP(S) URL with embedded basic-auth credentials", regex: /(https?:\/\/[^:\s/@]+:[^@\s/]+@[^\s/]+)/, - validate: (span) => { - const m = span.match(/:\/\/[^:]+:([^@]+)@/); - const pw = m?.[1] ?? ""; - // Any $VAR / ${identifier} interpolation is code, not a credential — - // covers bash ${DB_PASS} and JS template `${dbPass}` alike (the - // uppercase-only form flagged ported TS templates as pushed secrets). - return !isPlaceholderSpan(pw) && pw !== "" && !/^\$\{?[A-Za-z_][A-Za-z0-9_]*\}?$/.test(pw); - }, + // Skip when the password segment is itself a placeholder/interpolation. + validate: (span) => !urlPasswordIsPlaceholder(span), }, // ===== MEDIUM — demoted credential-shaped (high-FP / context-variable) ===== diff --git a/test/redact-engine.test.ts b/test/redact-engine.test.ts index 77b91b81e..1f6a0a00c 100644 --- a/test/redact-engine.test.ts +++ b/test/redact-engine.test.ts @@ -114,6 +114,13 @@ describe("HIGH credential patterns", () => { // Assembled at runtime so this file's own diff never contains a // credential-shaped literal (the prepush guard scans exact pushed bytes). expect(ids("postgres://admin:" + "hun" + "ter2@db.internal/app")).toContain("db.url_with_password"); + // Bare $UPPER_SNAKE is shell convention → suppressed; bare $lowercase is + // NOT an interpolation form — a real password starting with `$` must + // still block (both-braces-optional would have let it through). + expect(ids("postgres://user:$DB_PASSWORD@host/app")).not.toContain("db.url_with_password"); + expect(ids("postgres://admin:$" + "hun" + "ter2@db.internal/app")).toContain("db.url_with_password"); + // Mismatched brace is not an interpolation either. + expect(ids("postgres://admin:${dbPass@db.internal/app")).toContain("db.url_with_password"); }); test("all HIGH patterns block (exit 3)", () => {