fix(redact): interpolation allowance can't swallow a real $word password

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.
This commit is contained in:
Garry Tan 2026-08-15 16:49:39 -07:00
parent d4e1b4dc31
commit f6e3297b3d
No known key found for this signature in database
GPG Key ID: C1F69E85C74EFE1D
2 changed files with 27 additions and 17 deletions

View File

@ -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) =====

View File

@ -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)", () => {