[E00-S04-T05] .env.example contains placeholders only #404
+5
-2
@@ -28,8 +28,11 @@ DATABASE_URL=postgres://localhost:5432/eppp
|
|||||||
|
|
||||||
# Admin-session secret — REQUIRED and at least 32 characters (the config
|
# Admin-session secret — REQUIRED and at least 32 characters (the config
|
||||||
# schema's required field; Security-and-Operations §32/§26). Generate a fresh
|
# schema's required field; Security-and-Operations §32/§26). Generate a fresh
|
||||||
# one with `openssl rand -hex 32` and replace the placeholder below.
|
# one with `openssl rand -hex 32` and replace the placeholder below. The
|
||||||
EPPP_SESSION_SECRET=change-me-to-a-random-32-character-secret
|
# placeholder is intentionally SHORTER than the 32-character minimum, so an
|
||||||
|
# unedited `cp .env.example .env` is rejected at startup (fails closed)
|
||||||
|
# instead of booting with a publicly known secret.
|
||||||
|
EPPP_SESSION_SECRET=change-me
|
||||||
|
|
||||||
# --- Docker Compose overrides (optional — compose.yaml has dev defaults) ------
|
# --- Docker Compose overrides (optional — compose.yaml has dev defaults) ------
|
||||||
|
|
||||||
|
|||||||
@@ -294,8 +294,11 @@ jobs:
|
|||||||
# source (HOST/PORT/DATABASE_URL/EPPP_SESSION_SECRET), and contains
|
# source (HOST/PORT/DATABASE_URL/EPPP_SESSION_SECRET), and contains
|
||||||
# placeholder values only — no credential URI, no long secret-looking value,
|
# placeholder values only — no credential URI, no long secret-looking value,
|
||||||
# and no compose default credential — with mutation probes proving the
|
# and no compose default credential — with mutation probes proving the
|
||||||
# assertions are non-vacuous. The test needs no dependencies, so the job
|
# assertions are non-vacuous. It also locks the fail-closed EPPP_SESSION_SECRET
|
||||||
# only installs Node.
|
# placeholder (shorter than the schema's 32-character minimum), builds the
|
||||||
|
# secret-shaped probe at runtime so the branch stays gitleaks-clean, and
|
||||||
|
# masks raw values in assertion messages. The test needs no dependencies, so
|
||||||
|
# the job only installs Node.
|
||||||
env-example:
|
env-example:
|
||||||
name: .env.example placeholders only (E00-S04-T05)
|
name: .env.example placeholders only (E00-S04-T05)
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
|||||||
@@ -21,6 +21,17 @@
|
|||||||
* assertions are non-vacuous (a secret-looking value, a credential URI, a
|
* assertions are non-vacuous (a secret-looking value, a credential URI, a
|
||||||
* compose default credential, a missing required variable, a dropped
|
* compose default credential, a missing required variable, a dropped
|
||||||
* `!.env.example` negation, or a malformed line all break the criterion).
|
* `!.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`
|
* Run: `node --test tests/env-example.test.mjs`
|
||||||
* (node:test — built into Node >= 18; no dependencies, lockfile untouched.)
|
* (node:test — built into Node >= 18; no dependencies, lockfile untouched.)
|
||||||
@@ -48,6 +59,16 @@ const FORBIDDEN_VALUES = ['postgres://eppp:eppp@db:5432/eppp'];
|
|||||||
/** A long random-looking value (JWT/API-key/token-shaped literal). */
|
/** A long random-looking value (JWT/API-key/token-shaped literal). */
|
||||||
const LONG_SECRET_RE = /^[A-Za-z0-9+/=_-]{32,}$/;
|
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. */
|
/** A credential URI (`scheme://user:pass@host`) embedded as a literal value. */
|
||||||
const CREDENTIAL_URI_RE = /:\/\/[^/\s]+:[^@\s]+@/;
|
const CREDENTIAL_URI_RE = /:\/\/[^/\s]+:[^@\s]+@/;
|
||||||
|
|
||||||
@@ -91,6 +112,17 @@ function isPlaceholderValue(value) {
|
|||||||
return false;
|
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
|
* Parses the template into `{ key, value }` assignments, ignoring blank lines
|
||||||
* and `#` comment lines. Throws a descriptive Error on a malformed line so a
|
* and `#` comment lines. Throws a descriptive Error on a malformed line so a
|
||||||
@@ -104,7 +136,7 @@ function parseAssignments(content) {
|
|||||||
const match = /^([A-Z][A-Z0-9_]*)=(.*)$/.exec(line);
|
const match = /^([A-Z][A-Z0-9_]*)=(.*)$/.exec(line);
|
||||||
if (!match) {
|
if (!match) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
`line ${index + 1} is not a comment, blank line, or KEY=value assignment: "${raw}"`,
|
`line ${index + 1} is not a comment, blank line, or KEY=value assignment: "${maskValue(raw)}"`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
assignments.push({ key: match[1], value: match[2] });
|
assignments.push({ key: match[1], value: match[2] });
|
||||||
@@ -121,7 +153,7 @@ function assertTemplateHasPlaceholdersOnly(content) {
|
|||||||
for (const forbidden of FORBIDDEN_VALUES) {
|
for (const forbidden of FORBIDDEN_VALUES) {
|
||||||
assert.ok(
|
assert.ok(
|
||||||
!content.includes(forbidden),
|
!content.includes(forbidden),
|
||||||
`the template must not contain the compose default credential value "${forbidden}"`,
|
`the template must not contain the compose default credential value "${maskValue(forbidden)}"`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -141,7 +173,7 @@ function assertTemplateHasPlaceholdersOnly(content) {
|
|||||||
for (const { key, value } of assignments) {
|
for (const { key, value } of assignments) {
|
||||||
assert.ok(
|
assert.ok(
|
||||||
isPlaceholderValue(value),
|
isPlaceholderValue(value),
|
||||||
`"${key}" must be a placeholder value, got: "${value}"`,
|
`"${key}" must be a placeholder value, got: "${maskValue(value)}"`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -168,6 +200,16 @@ test('.env.example contains placeholders only and no real secret values', () =>
|
|||||||
assertTemplateHasPlaceholdersOnly(read(ENV_EXAMPLE_PATH));
|
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
|
// Non-vacuous probes — the assertions above really do fail on violations
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -188,8 +230,8 @@ test('a compose default credential value in the template fails the criterion (mu
|
|||||||
test('a long secret-looking value fails the criterion (mutation probe)', () => {
|
test('a long secret-looking value fails the criterion (mutation probe)', () => {
|
||||||
const content = read(ENV_EXAMPLE_PATH);
|
const content = read(ENV_EXAMPLE_PATH);
|
||||||
const mutated = content.replace(
|
const mutated = content.replace(
|
||||||
'EPPP_SESSION_SECRET=change-me-to-a-random-32-character-secret',
|
'EPPP_SESSION_SECRET=change-me',
|
||||||
'EPPP_SESSION_SECRET=aB3dE9fG0hI1jK2lM3nO4pQ5rS6tU7vW8xY9zA0',
|
`EPPP_SESSION_SECRET=${SECRET_SHAPED_PROBE}`,
|
||||||
);
|
);
|
||||||
assert.notEqual(mutated, content, 'the mutation must actually replace the session secret');
|
assert.notEqual(mutated, content, 'the mutation must actually replace the session secret');
|
||||||
assert.throws(() => assertTemplateHasPlaceholdersOnly(mutated), /placeholder/);
|
assert.throws(() => assertTemplateHasPlaceholdersOnly(mutated), /placeholder/);
|
||||||
|
|||||||
Reference in New Issue
Block a user