/** * .env.example test — locks in the [E00-S04-T05] guarantee that the committed * `.env.example` template at the repo root contains placeholders only and no * real secret values. * * Acceptance criteria covered (each test fails without the committed state): * - ".env.example contains placeholders only" → the file exists at the repo * root and every assignment value is a placeholder (an explicit * `change-me`/`<…>`-style marker) or a benign non-secret default (bind * address, port, local database/user name); no value is a long * random-looking token without a placeholder marker, no value embeds a * credential URI, every line is a comment, a blank line, or a well-formed * `KEY=value` assignment, no variable is repeated, and the file documents * every configuration environment source (`HOST`/`PORT`/`DATABASE_URL`/ * `EPPP_SESSION_SECRET`). * - "no real secret values appear in the example file" → the same value * predicate rejects secret-shaped values, the compose dev-default * credential value appears nowhere in the file (values or comments), and * `.gitignore` keeps real `.env` files ignored while un-ignoring the * committed `.env.example`. The mutation probes below prove the * assertions are non-vacuous (a secret-looking value, a credential URI, a * compose default credential, a missing required variable, a dropped * `!.env.example` negation, or a malformed line all break the criterion). * - "EPPP_SESSION_SECRET fails closed" → the template's placeholder is * shorter than the schema's 32-character minimum, so an unedited * `cp .env.example .env` is rejected at startup instead of booting with a * publicly known secret (security finding F3; the config-package * rejection of a change-me marker stays out of scope, E00-S04-T01). * - "the branch stays gitleaks-clean" → the secret-shaped mutation-probe * literal is built at runtime from short non-secret fragments, never * embedded verbatim in the source tree (security finding F2). * - "assertion messages never echo a raw secret/placeholder value" → every * message that could carry a value from the template masks it * (security finding F4). * * Run: `node --test tests/env-example.test.mjs` * (node:test — built into Node >= 18; no dependencies, lockfile untouched.) */ import test from 'node:test'; import assert from 'node:assert/strict'; import { existsSync, readFileSync } from 'node:fs'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; const REPO_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); const read = (relPath) => readFileSync(path.join(REPO_ROOT, relPath), 'utf8'); const exists = (relPath) => existsSync(path.join(REPO_ROOT, relPath)); const ENV_EXAMPLE_PATH = '.env.example'; /** The config schema's environment sources (E00-S04-T01/T04) the template must document. */ const REQUIRED_VARS = ['HOST', 'PORT', 'DATABASE_URL', 'EPPP_SESSION_SECRET']; /** Compose dev-default credential values that must never appear in the template. */ const FORBIDDEN_VALUES = ['postgres://eppp:eppp@db:5432/eppp']; /** A long random-looking value (JWT/API-key/token-shaped literal). */ const LONG_SECRET_RE = /^[A-Za-z0-9+/=_-]{32,}$/; /** * A long random-looking, secret-shaped value used as a mutation probe. Built * at runtime by joining short non-secret fragments so no secret-shaped * literal is ever embedded verbatim in the source tree — the branch stays * gitleaks-clean (security finding F2). */ const SECRET_SHAPED_PROBE = [ 'aB3dE', '9fG0h', 'I1jK2', 'lM3nO', '4pQ5r', 'S6tU7', 'vW8xY', '9zA0', ].join(''); /** A credential URI (`scheme://user:pass@host`) embedded as a literal value. */ const CREDENTIAL_URI_RE = /:\/\/[^/\s]+:[^@\s]+@/; /** Explicit placeholder markers a value may carry. */ const PLACEHOLDER_MARKERS = [ 'change-me', 'changeme', 'change_me', 'your-', 'replace', 'example', 'xxx', '<', '>', ]; /** Benign non-secret defaults the template may show as values. */ const BENIGN_VALUES = new Set([ '0.0.0.0', // HOST container default '3000', // PORT / APP_PORT default '5432', // POSTGRES_PORT default 'localhost', // local bind/db host 'eppp', // local database/user name (non-secret) 'postgres://localhost:5432/eppp', // DATABASE_URL shape without embedded credentials ]); /** * True when an assignment value is a placeholder, never a real secret. * * A value is a placeholder when it is a benign non-secret default or carries * an explicit placeholder marker; a marked value is still rejected when it * embeds a credential URI or reproduces a compose default credential. Any * other value — including a long random-looking token — is not a placeholder. */ function isPlaceholderValue(value) { if (value === '') return false; if (BENIGN_VALUES.has(value)) return true; if (PLACEHOLDER_MARKERS.some((marker) => value.includes(marker))) { return !CREDENTIAL_URI_RE.test(value) && !FORBIDDEN_VALUES.includes(value); } return false; } /** * Masks a value for an assertion message (security finding F4): a failing * assertion echoes a truncated value with its length, never the raw string, * so a real secret that ever lands in the template cannot leak into CI logs. */ function maskValue(value) { const s = String(value); if (s.length <= 8) return ''; return `${s.slice(0, 4)}...<${s.length} chars>`; } /** * Parses the template into `{ key, value }` assignments, ignoring blank lines * and `#` comment lines. Throws a descriptive Error on a malformed line so a * stray non-assignment line cannot silently pass. */ function parseAssignments(content) { const assignments = []; content.split(/\r?\n/).forEach((raw, index) => { const line = raw.trim(); if (line === '' || line.startsWith('#')) return; const match = /^([A-Z][A-Z0-9_]*)=(.*)$/.exec(line); if (!match) { throw new Error( `line ${index + 1} is not a comment, blank line, or KEY=value assignment: "${maskValue(raw)}"`, ); } assignments.push({ key: match[1], value: match[2] }); }); return assignments; } /** * Asserts the acceptance criteria for the given template content — used by * the committed-state test and by the mutation probes (which must make it * throw). */ function assertTemplateHasPlaceholdersOnly(content) { for (const forbidden of FORBIDDEN_VALUES) { assert.ok( !content.includes(forbidden), `the template must not contain the compose default credential value "${maskValue(forbidden)}"`, ); } const assignments = parseAssignments(content); assert.ok(assignments.length > 0, 'the template must contain at least one assignment'); const keys = assignments.map(({ key }) => key); assert.equal(new Set(keys).size, keys.length, 'the template must not repeat a variable'); for (const required of REQUIRED_VARS) { assert.ok( keys.includes(required), `the template must document the required variable "${required}"`, ); } for (const { key, value } of assignments) { assert.ok( isPlaceholderValue(value), `"${key}" must be a placeholder value, got: "${maskValue(value)}"`, ); } } // --------------------------------------------------------------------------- // Tests // --------------------------------------------------------------------------- test('a committed .env.example exists at the repo root', () => { assert.ok(exists(ENV_EXAMPLE_PATH), `"${ENV_EXAMPLE_PATH}" must exist at the repo root`); }); test('.gitignore keeps real .env files ignored while un-ignoring the committed example', () => { const gitignore = read('.gitignore'); assert.ok(gitignore.includes('.env'), 'real .env files must stay git-ignored'); assert.ok(gitignore.includes('.env.*'), 'real .env.* files must stay git-ignored'); assert.ok( gitignore.includes('!.env.example'), 'the committed .env.example must be un-ignored (negation pattern)', ); }); test('.env.example contains placeholders only and no real secret values', () => { assertTemplateHasPlaceholdersOnly(read(ENV_EXAMPLE_PATH)); }); test("EPPP_SESSION_SECRET's placeholder is shorter than the schema's 32-character minimum (fails closed)", () => { const content = read(ENV_EXAMPLE_PATH); const match = /^EPPP_SESSION_SECRET=(.*)$/m.exec(content); assert.ok(match, 'the template must document EPPP_SESSION_SECRET'); assert.ok( match[1].length < 32, `the EPPP_SESSION_SECRET placeholder must be shorter than the schema's 32-character minimum so an unedited cp .env.example .env is rejected at startup (got ${match[1].length} chars)`, ); }); // --------------------------------------------------------------------------- // Non-vacuous probes — the assertions above really do fail on violations // --------------------------------------------------------------------------- test('a compose default credential value in the template fails the criterion (mutation probe)', () => { const content = read(ENV_EXAMPLE_PATH); const mutated = content.replace( 'DATABASE_URL=postgres://localhost:5432/eppp', 'DATABASE_URL=postgres://eppp:eppp@db:5432/eppp', ); assert.notEqual(mutated, content, 'the mutation must actually replace the DATABASE_URL value'); assert.throws( () => assertTemplateHasPlaceholdersOnly(mutated), /compose default credential|placeholder/, ); }); test('a long secret-looking value fails the criterion (mutation probe)', () => { const content = read(ENV_EXAMPLE_PATH); const mutated = content.replace( 'EPPP_SESSION_SECRET=change-me', `EPPP_SESSION_SECRET=${SECRET_SHAPED_PROBE}`, ); assert.notEqual(mutated, content, 'the mutation must actually replace the session secret'); assert.throws(() => assertTemplateHasPlaceholdersOnly(mutated), /placeholder/); }); test('a credential URI value fails the criterion (mutation probe)', () => { const content = read(ENV_EXAMPLE_PATH); const mutated = content.replace( 'DATABASE_URL=postgres://localhost:5432/eppp', 'DATABASE_URL=postgres://alice:supersecret@db.example.com:5432/eppp', ); assert.notEqual(mutated, content, 'the mutation must actually replace the DATABASE_URL value'); assert.throws(() => assertTemplateHasPlaceholdersOnly(mutated), /placeholder/); }); test('a missing required variable fails the criterion (mutation probe)', () => { const content = read(ENV_EXAMPLE_PATH); const mutated = content.replace(/^EPPP_SESSION_SECRET=.*$/m, ''); assert.notEqual( mutated, content, 'the mutation must actually remove the EPPP_SESSION_SECRET assignment', ); assert.throws(() => assertTemplateHasPlaceholdersOnly(mutated), /EPPP_SESSION_SECRET/); }); test('dropping the !.env.example negation fails the criterion (mutation probe)', () => { const gitignore = read('.gitignore'); const mutated = gitignore.replace('!.env.example', ''); assert.notEqual(mutated, gitignore, 'the mutation must actually drop the negation pattern'); assert.throws( () => { assert.ok(mutated.includes('!.env.example'), 'the committed .env.example must be un-ignored'); }, /un-ignored/, ); }); test('a malformed non-assignment line fails the criterion (mutation probe)', () => { const content = read(ENV_EXAMPLE_PATH); const mutated = `${content}\nTHIS IS NOT A VALID LINE\n`; assert.throws(() => assertTemplateHasPlaceholdersOnly(mutated), /not a comment/); });