diff --git a/.env.example b/.env.example index aa83901..0321676 100644 --- a/.env.example +++ b/.env.example @@ -28,8 +28,11 @@ DATABASE_URL=postgres://localhost:5432/eppp # Admin-session secret — REQUIRED and at least 32 characters (the config # schema's required field; Security-and-Operations §32/§26). Generate a fresh -# one with `openssl rand -hex 32` and replace the placeholder below. -EPPP_SESSION_SECRET=change-me-to-a-random-32-character-secret +# one with `openssl rand -hex 32` and replace the placeholder below. The +# 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) ------ diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml index dd0cc61..b92c349 100644 --- a/.gitea/workflows/ci.yml +++ b/.gitea/workflows/ci.yml @@ -294,8 +294,11 @@ jobs: # source (HOST/PORT/DATABASE_URL/EPPP_SESSION_SECRET), and contains # placeholder values only — no credential URI, no long secret-looking value, # and no compose default credential — with mutation probes proving the - # assertions are non-vacuous. The test needs no dependencies, so the job - # only installs Node. + # assertions are non-vacuous. It also locks the fail-closed EPPP_SESSION_SECRET + # 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: name: .env.example placeholders only (E00-S04-T05) runs-on: ubuntu-latest diff --git a/tests/env-example.test.mjs b/tests/env-example.test.mjs index c058cb1..a7f3bf3 100644 --- a/tests/env-example.test.mjs +++ b/tests/env-example.test.mjs @@ -21,6 +21,17 @@ * 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.) @@ -48,6 +59,16 @@ 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]+@/; @@ -91,6 +112,17 @@ function isPlaceholderValue(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 @@ -104,7 +136,7 @@ function parseAssignments(content) { 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: "${raw}"`, + `line ${index + 1} is not a comment, blank line, or KEY=value assignment: "${maskValue(raw)}"`, ); } assignments.push({ key: match[1], value: match[2] }); @@ -121,7 +153,7 @@ function assertTemplateHasPlaceholdersOnly(content) { for (const forbidden of FORBIDDEN_VALUES) { assert.ok( !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) { assert.ok( 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)); }); +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 // --------------------------------------------------------------------------- @@ -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)', () => { const content = read(ENV_EXAMPLE_PATH); const mutated = content.replace( - 'EPPP_SESSION_SECRET=change-me-to-a-random-32-character-secret', - 'EPPP_SESSION_SECRET=aB3dE9fG0hI1jK2lM3nO4pQ5rS6tU7vW8xY9zA0', + '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/);