CI / Frozen lockfile install (pull_request) Successful in 53s
CI / Secrets not embedded (E00-S02-T08) (pull_request) Successful in 25s
CI / Database-postgres import isolation (E00-S03-T02) (pull_request) Successful in 24s
CI / Migration ledger (E00-S03-T03) (pull_request) Successful in 52s
CI / Migration advisory lock (E00-S03-T04) (pull_request) Successful in 49s
CI / Migration failure diagnostic (E00-S03-T05) (pull_request) Successful in 47s
CI / App readiness after migrations (E00-S03-T06) (pull_request) Successful in 1m8s
CI / Field-specific startup errors (E00-S04-T02) (pull_request) Successful in 1m2s
CI / Secret redaction from logs (E00-S04-T03) (pull_request) Successful in 1m5s
CI / Env adapter owns process.env (E00-S04-T04) (pull_request) Successful in 1m2s
CI / TypeBox/Ajv config schema (E00-S04-T01) (pull_request) Successful in 58s
CI / .env.example placeholders only (E00-S04-T05) (pull_request) Successful in 24s
CI / Compose config (E00-S03-T01) (pull_request) Successful in 25s
Addresses PR #404 review findings F2/F3/F4 on issue #186: - F3 (fail-closed): ship EPPP_SESSION_SECRET placeholder as `change-me` (9 chars), 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. - F2 (gitleaks-clean): build the secret-shaped mutation-probe literal at runtime from short non-secret fragments; the branch no longer embeds a secret-shaped literal in the test source. - F4 (masked messages): assertion messages mask/truncate values that come from the template instead of echoing the raw string. - test: add a fail-closed length test for the EPPP_SESSION_SECRET placeholder (< 32 chars); keep mutation probes non-vacuous.
278 lines
11 KiB
JavaScript
278 lines
11 KiB
JavaScript
/**
|
|
* .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 '<masked>';
|
|
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/);
|
|
});
|