mirror of https://github.com/garrytan/gstack.git
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:
parent
d4e1b4dc31
commit
f6e3297b3d
|
|
@ -254,6 +254,22 @@ export function insideUuid(match: RegExpExecArray): boolean {
|
||||||
|
|
||||||
// ── The taxonomy ─────────────────────────────────────────────────────────────
|
// ── 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[] = [
|
export const PATTERNS: RedactPattern[] = [
|
||||||
// ===== HIGH — genuinely-secret credentials (block) =====
|
// ===== HIGH — genuinely-secret credentials (block) =====
|
||||||
{
|
{
|
||||||
|
|
@ -437,15 +453,8 @@ export const PATTERNS: RedactPattern[] = [
|
||||||
category: "secret",
|
category: "secret",
|
||||||
description: "Database URL with embedded password",
|
description: "Database URL with embedded password",
|
||||||
regex: /\b((?:postgres(?:ql)?|mysql|mongodb(?:\+srv)?|redis|amqp):\/\/[^:\s/@]+:[^@\s/]+@[^\s/]+)/,
|
regex: /\b((?:postgres(?:ql)?|mysql|mongodb(?:\+srv)?|redis|amqp):\/\/[^:\s/@]+:[^@\s/]+@[^\s/]+)/,
|
||||||
// Skip when the password segment is itself a placeholder.
|
// Skip when the password segment is itself a placeholder/interpolation.
|
||||||
validate: (span) => {
|
validate: (span) => !urlPasswordIsPlaceholder(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);
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "creds.basic_auth_url",
|
id: "creds.basic_auth_url",
|
||||||
|
|
@ -453,14 +462,8 @@ export const PATTERNS: RedactPattern[] = [
|
||||||
category: "secret",
|
category: "secret",
|
||||||
description: "HTTP(S) URL with embedded basic-auth credentials",
|
description: "HTTP(S) URL with embedded basic-auth credentials",
|
||||||
regex: /(https?:\/\/[^:\s/@]+:[^@\s/]+@[^\s/]+)/,
|
regex: /(https?:\/\/[^:\s/@]+:[^@\s/]+@[^\s/]+)/,
|
||||||
validate: (span) => {
|
// Skip when the password segment is itself a placeholder/interpolation.
|
||||||
const m = span.match(/:\/\/[^:]+:([^@]+)@/);
|
validate: (span) => !urlPasswordIsPlaceholder(span),
|
||||||
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);
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
|
|
||||||
// ===== MEDIUM — demoted credential-shaped (high-FP / context-variable) =====
|
// ===== MEDIUM — demoted credential-shaped (high-FP / context-variable) =====
|
||||||
|
|
|
||||||
|
|
@ -114,6 +114,13 @@ describe("HIGH credential patterns", () => {
|
||||||
// Assembled at runtime so this file's own diff never contains a
|
// Assembled at runtime so this file's own diff never contains a
|
||||||
// credential-shaped literal (the prepush guard scans exact pushed bytes).
|
// credential-shaped literal (the prepush guard scans exact pushed bytes).
|
||||||
expect(ids("postgres://admin:" + "hun" + "ter2@db.internal/app")).toContain("db.url_with_password");
|
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)", () => {
|
test("all HIGH patterns block (exit 3)", () => {
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue