From 138d62e8ec99bcde90f73096fceb13f606837cec Mon Sep 17 00:00:00 2001 From: Anthony Ettinger Date: Thu, 3 Sep 2026 11:08:19 +0000 Subject: [PATCH] fix(scan): stop reporting a DSN whose password slot is a template MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `secret-database-url` matched any connection string with a credential segment, so a tool that builds DSNs at run time got one `high` per profile. terrablue/devdb is the clean case: four findings, all of them the literal template strings that are the repository's core data — `postgres://{{.user}}:{{.pass}}@{{.host}}:{{.port}}/{{.name}}` and its mongo and redis siblings. Exempt the credential segment when an interpolation fills the password slot: Go and Handlebars `{{…}}`, shell and JavaScript `${…}`, bare `$VAR`, printf `%s`. This is a stronger claim than the neighbouring `user:pass` exemption rather than a looser one — that rule reads English words and infers intent, while these cannot occur in a credential at all, since the value that reaches the driver is whatever the template engine substitutes. Only the password slot decides it, so `postgres://{{.user}}:hunter2@` stays reported at `high`. devdb goes 4 findings to 0, verified through the built CLI; the full scan suite is 351 passing. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_017CnhqMwFo6ceywCRLdU2EG --- .../src/__tests__/devdb-regression.test.ts | 58 +++++++++++++++++++ packages/scan/src/secret-rules.ts | 20 +++++++ 2 files changed, 78 insertions(+) create mode 100644 packages/scan/src/__tests__/devdb-regression.test.ts diff --git a/packages/scan/src/__tests__/devdb-regression.test.ts b/packages/scan/src/__tests__/devdb-regression.test.ts new file mode 100644 index 0000000..11ab19b --- /dev/null +++ b/packages/scan/src/__tests__/devdb-regression.test.ts @@ -0,0 +1,58 @@ +import { describe, expect, it } from 'vitest'; +import { scanText } from '../text'; + +// The four `high` findings on terrablue/devdb, copied verbatim from its +// `profiles.go`. devdb's whole job is building connection strings, so the +// templates it substitutes at run time are the repository's core data — a +// scanner that calls them leaked credentials has nothing left to say about it. +const PROFILES_GO = ` +type profile struct { + dsn string // "postgres://{{.user}}:{{.pass}}@{{.host}}:{{.port}}/{{.name}}" +} + +var profiles = map[string]profile{ + "postgres": { + port: 5432, + dsn: "postgres://{{.user}}:{{.pass}}@{{.host}}:{{.port}}/{{.name}}", + }, + "mongo": { + port: 27017, + dsn: "mongodb://{{.user}}:{{.pass}}@{{.host}}:{{.port}}/{{.name}}", + }, + "redis": { + port: 6379, + dsn: "redis://:{{.pass}}@{{.host}}:{{.port}}", + }, +} +`; + +describe('a DSN whose credential slot is a template', () => { + it('is not four high findings', () => { + expect(scanText('profiles.go', PROFILES_GO)).toHaveLength(0); + }); + + it('exempts the other interpolation syntaxes that sit in the same slot', () => { + const dsns = [ + 'mysql://${DB_USER}:${DB_PASS}@localhost:3306/app', + 'postgres://$PGUSER:$PGPASSWORD@localhost:5432/app', + 'postgres://%s:%s@%s:%d/%s', + 'amqp://{{ user }}:{{ password }}@broker:5672', + ].join('\n'); + expect(scanText('dsn.go', dsns)).toHaveLength(0); + }); + + it('still reports a real credential beside a templated host', () => { + const leaked = 'dsn := "postgres://devdb:Kd93mQpZx2@{{.host}}:{{.port}}/devdb"'; + const findings = scanText('profiles.go', leaked); + expect(findings).toHaveLength(1); + expect(findings[0]!.ruleId).toBe('secret-database-url'); + expect(findings[0]!.severity).toBe('high'); + }); + + it('still reports a real password behind a templated user', () => { + // Only the password slot decides this. A templated *user* beside a literal + // password is the case the exemption must not swallow. + const leaked = 'dsn := "postgres://{{.user}}:Kd93mQpZx2@localhost:5432/devdb"'; + expect(scanText('profiles.go', leaked)).toHaveLength(1); + }); +}); diff --git a/packages/scan/src/secret-rules.ts b/packages/scan/src/secret-rules.ts index 98df958..e14c871 100644 --- a/packages/scan/src/secret-rules.ts +++ b/packages/scan/src/secret-rules.ts @@ -205,6 +205,14 @@ export const SECRET_RULES: readonly SecretRule[] = [ * placeholder — not a guess that something "looks like a test value". A * generous allow-list here is how a scanner talks itself out of a real finding. */ +/** + * A template expression, in the syntaxes that reach a connection string: Go + * and Handlebars `{{…}}`, shell and JavaScript `${…}`, bare `$VAR`, and + * printf `%s`/`%d`/`%v`. Anchored to the end of the credential segment so it + * has to fill the slot rather than merely appear somewhere near it. + */ +const INTERPOLATION = String.raw`(?:\{\{[^}]*\}\}|\$\{[^}]*\}|\$[A-Za-z_]\w*|%[sdv])`; + const KNOWN_PLACEHOLDERS = [ // Deliberately NOT here: AWS's published documentation key/secret pair // (`AKIAIOSFODNN7EXAMPLE`, `wJalrXUtnFEMI/…`). GitHub allow-lists them, and @@ -229,6 +237,18 @@ const KNOWN_PLACEHOLDERS = [ // Both halves have to be metasyntactic. `root:hunter2@` is not exempt, since // a real password beside a common username is the case this must not swallow. /:\/\/(?:user(?:name)?|admin|root|dbuser|myuser):(?:pass(?:word|wd)?|secret|dbpass|mypassword)@/i, + // A DSN whose password slot is an interpolation the runtime substitutes: + // `postgres://{{.user}}:{{.pass}}@{{.host}}/{{.name}}`. This is a stronger + // claim than the metasyntactic pair above rather than a looser one — that + // rule reads English words and infers intent, whereas `{{…}}`, `${…}` and + // `%s` cannot appear in a credential at all, because the value that reaches + // the driver is whatever the template engine puts there. A tool that builds + // connection strings holds these by the hundred and none of them is a secret. + // + // Only the password slot decides it. The username is not a credential, so a + // literal one beside a templated password leaks nothing, while the reverse — + // `postgres://{{.user}}:hunter2@` — is a real password and stays reported. + new RegExp(String.raw`://[^\s'"@/]*:${INTERPOLATION}@`), ]; /**