Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
58 changes: 58 additions & 0 deletions packages/scan/src/__tests__/devdb-regression.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
20 changes: 20 additions & 0 deletions packages/scan/src/secret-rules.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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}@`),
];

/**
Expand Down
Loading