From dce6cac05b3a546fa1c9d6f99694cc197522ed24 Mon Sep 17 00:00:00 2001 From: bot-implementer Date: Sun, 30 Aug 2026 05:47:42 +0000 Subject: [PATCH] [E00-S04-T05] .env.example contains placeholders only (#404) Co-authored-by: bot-implementer --- .env.example | 47 +++++ .gitea/workflows/ci.yml | 24 +++ .gitignore | 5 +- compose.yaml | 3 +- docs/development/non-container.md | 8 +- packages/config/package.json | 2 +- packages/config/src/index.ts | 4 +- tests/env-example.test.mjs | 277 ++++++++++++++++++++++++++++++ 8 files changed, 361 insertions(+), 9 deletions(-) create mode 100644 .env.example create mode 100644 tests/env-example.test.mjs diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..0321676 --- /dev/null +++ b/.env.example @@ -0,0 +1,47 @@ +# EPPP configuration template — [E00-S04-T05] +# +# Copy this file to `.env` and fill in real values: +# +# cp .env.example .env +# +# Every value in this file is a PLACEHOLDER — the template intentionally ships +# no real secrets. Real `.env` files stay git-ignored (`.env`, `.env.*` in +# `.gitignore`), so a committed example can never leak a local secret. Never +# commit a real `.env`. + +# --- Server configuration (read by @personal-blog/config, E00-S04-T04) ------- + +# Interface the HTTP server binds — a hostname or IPv4/IPv6 address. +# Default: 0.0.0.0 (all interfaces — the container default). +HOST=0.0.0.0 + +# Port the HTTP server listens on — an integer in the valid TCP range +# (1-65535). Default: 3000. +PORT=3000 + +# PostgreSQL connection string (optional). When unset, the app reports ready +# immediately and skips the startup migration run (the local non-container +# developer path). When set, the shape is: +# postgres://:@:5432/ +# (add your own credentials; a local no-credential default is shown below) +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. 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) ------ + +# PostgreSQL database name / user / password and host port for the `db` +# service (compose.yaml interpolates these with dev defaults). +POSTGRES_DB=eppp +POSTGRES_USER=eppp +POSTGRES_PASSWORD=change-me-db-password +POSTGRES_PORT=5432 + +# Host port for the `app` service. Default: 3000. +APP_PORT=3000 diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml index 230eaff..b92c349 100644 --- a/.gitea/workflows/ci.yml +++ b/.gitea/workflows/ci.yml @@ -287,6 +287,30 @@ jobs: - name: Run config schema test suite run: node --test tests/config-schema.test.mjs + # E00-S04-T05: the static assertions of tests/env-example.test.mjs gate every + # PR — the suite locks in the committed `.env.example` template: it exists at + # the repo root, is un-ignored in .gitignore (real `.env` files stay ignored + # while the example is tracked), documents every configuration environment + # 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. 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 + steps: + - uses: actions/checkout@v4 + - name: Install Node.js 24 + uses: actions/setup-node@v4 + with: + node-version: '24' + - name: Run .env.example test suite + run: node --test tests/env-example.test.mjs + # E00-S03-T01: the static assertions of tests/compose-config.test.mjs (db # image pinned to postgres:18.6-bookworm, health gate, volume persistence, # build platforms) gate every PR (the docker-gated real-stack probes inside diff --git a/.gitignore b/.gitignore index 03e8a15..0ef358b 100644 --- a/.gitignore +++ b/.gitignore @@ -6,9 +6,12 @@ node_modules/ dist/ coverage/ -# Local environment files (a committed .env.example lands in E00-S04) +# Local environment files: real .env files are ignored, while the committed +# .env.example template (E00-S04-T05) is explicitly un-ignored so it stays +# tracked. .env .env.* +!.env.example # Logs *.log diff --git a/compose.yaml b/compose.yaml index 856db96..54b8d86 100644 --- a/compose.yaml +++ b/compose.yaml @@ -49,7 +49,8 @@ # removing any embedded secret. Tests: tests/secrets-not-embedded.test.mjs. # # All values have defaults so `docker compose up -d` works from a clean clone -# without a .env file (a committed .env.example template lands in E00-S04). +# without a .env file (the committed .env.example template, E00-S04-T05, +# lists the overridable variables with placeholder values). # Since E00-S04-T02 the app validates its required settings at startup: the # admin-session secret `EPPP_SESSION_SECRET` (the schema's required field, # Security-and-Operations §32/§26) is provided here with a dev-only default — diff --git a/docs/development/non-container.md b/docs/development/non-container.md index 34e3f8e..f85c1a5 100644 --- a/docs/development/non-container.md +++ b/docs/development/non-container.md @@ -17,7 +17,7 @@ The workspace is a pnpm monorepo with three package groups: | --- | --- | --- | | `apps/` | `apps/server` (`@personal-blog/server`) | Public server application. Serves the application health endpoint (E00-S02-T03) gated on the startup migration run (E00-S03-T06), fails fast at startup with a field-specific error when a required setting is missing (E00-S04-T02), redacts secret values from all log output (E00-S04-T03), and reads all of its settings through the config package's environment adapter — no `process.env` reads in the server (E00-S04-T04); the Fastify 5 application shell lands in a later story. | | `packages/` | `packages/core` (`@personal-blog/core`) | Application core (site identity, content primitives). Bootstrap placeholder. | -| `packages/` | `packages/config` (`@personal-blog/config`) | Configuration service. Owns the TypeBox/Ajv configuration schema for the validated config fields (E00-S04-T01), the field-specific startup error for a missing required setting (E00-S04-T02), the secret redaction layer (E00-S04-T03) and the environment adapter — the workspace's single owner of `process.env` reads, mapping `HOST`/`PORT`/`DATABASE_URL`/`EPPP_SESSION_SECRET` onto the validated config (E00-S04-T04) and validating `HOST` as a hostname or IP address at the adapter boundary; the `.env.example` template (E00-S04-T05) lands in a later task. | +| `packages/` | `packages/config` (`@personal-blog/config`) | Configuration service. Owns the TypeBox/Ajv configuration schema for the validated config fields (E00-S04-T01), the field-specific startup error for a missing required setting (E00-S04-T02), the secret redaction layer (E00-S04-T03) and the environment adapter — the workspace's single owner of `process.env` reads, mapping `HOST`/`PORT`/`DATABASE_URL`/`EPPP_SESSION_SECRET` onto the validated config (E00-S04-T04) and validating `HOST` as a hostname or IP address at the adapter boundary; the committed `.env.example` template (E00-S04-T05) documents every variable with placeholder values only. | | `packages/` | `packages/database-postgres` (`@personal-blog/database-postgres`) | PostgreSQL database adapter package. Single owner of the `pg`/Kysely driver imports (E00-S03-T02); the migration ledger (`schema_migrations`, E00-S03-T03), the migration advisory lock (E00-S03-T04) and the migration runner with its failure diagnostic (E00-S03-T05) are implemented here. The server depends on this package to run the startup migrations behind its readiness gate (E00-S03-T06). | | `extensions/` | `extensions/example` (`@personal-blog/example-extension`) | Example extension exercising the `extensions/` group. Bootstrap placeholder. | @@ -99,8 +99,8 @@ compiled application entrypoint. Three things to know: must be set in the environment — if it is missing, the process fails fast with a field-specific startup error (`missing required setting: sessionSecret`) that names the missing field instead of booting. Provide it - in your shell or a local `.env` file (the `.env.example` template lands in - E00-S04). + in your shell or a local `.env` file copied from the committed + `.env.example` template (E00-S04-T05). 3. Since [E00-S02-T03], `apps/server` serves the **application health endpoint**: starting it opens an HTTP server on port 3000 answering `GET /health`, so the process stays up. Since [E00-S03-T06] the endpoint @@ -170,7 +170,7 @@ pnpm --filter @personal-blog/server start # requires EPPP_SESSION_SECRET (see | `ERR_PNPM_UNSUPPORTED_ENGINE` on install | Your Node version is outside the supported 24.x engine line (`engines.node` in the root `package.json`, enforced by `engineStrict: true` in `pnpm-workspace.yaml`). Install Node 24.x (e.g. via `nvm`, `fnm` or another version manager). | | `start` exits immediately with no output | The server crashed or exited at startup — check the process output. Since [E00-S02-T03] the entrypoint serves `GET /health` on port 3000 and stays up; a missing `pnpm build` (stale/absent `dist/`) is the usual cause (see [Run](#run)). | | `start` fails with `missing required setting: sessionSecret` | Since [E00-S04-T02] the server validates its required settings at startup: the admin-session secret `EPPP_SESSION_SECRET` (≥ 32 chars) is missing or too short — set it in your shell or a local `.env` file (see [Run](#run)). | -| `.env` files | `.env`/`.env.*` are git-ignored; a committed `.env.example` template lands with the environment story (E00-S04). | +| `.env` files | `.env`/`.env.*` are git-ignored; the committed `.env.example` template (E00-S04-T05) shows placeholder values only. | ## Out of scope diff --git a/packages/config/package.json b/packages/config/package.json index 8e8ad19..27d7304 100644 --- a/packages/config/package.json +++ b/packages/config/package.json @@ -3,7 +3,7 @@ "version": "0.0.0", "private": true, "type": "module", - "description": "EPPP configuration service. Owns the TypeBox/Ajv configuration schema (E00-S04-T01), the field-specific startup error for a missing required setting (E00-S04-T02), the secret redaction layer (E00-S04-T03) and the environment adapter — the single owner of process.env reads, validating HOST as a hostname/IP at the adapter boundary (E00-S04-T04); the .env.example template (E00-S04-T05) lands in a later task.", + "description": "EPPP configuration service. Owns the TypeBox/Ajv configuration schema (E00-S04-T01), the field-specific startup error for a missing required setting (E00-S04-T02), the secret redaction layer (E00-S04-T03) and the environment adapter — the single owner of process.env reads, validating HOST as a hostname/IP at the adapter boundary (E00-S04-T04); the committed .env.example template (E00-S04-T05) ships placeholder values only.", "scripts": { "build": "tsc -p tsconfig.json", "typecheck": "tsc -p tsconfig.json --noEmit" diff --git a/packages/config/src/index.ts b/packages/config/src/index.ts index 7658be0..ffc3d60 100644 --- a/packages/config/src/index.ts +++ b/packages/config/src/index.ts @@ -26,8 +26,8 @@ * reads `process.env` directly. `HOST` is validated at the adapter boundary * as a hostname or IP address before it is used for binding or logged, so * arbitrary env content is never echoed verbatim into the startup log. The - * `.env.example` template (E00-S04-T05) builds on this boundary in a later - * task. + * committed `.env.example` template (E00-S04-T05) documents the same + * variables with placeholder values only. */ export { configSchema } from './schema.js'; diff --git a/tests/env-example.test.mjs b/tests/env-example.test.mjs new file mode 100644 index 0000000..a7f3bf3 --- /dev/null +++ b/tests/env-example.test.mjs @@ -0,0 +1,277 @@ +/** + * .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/); +});