[E00-S04-T03] Secrets automatically redact from logs #402

Merged
kpcto merged 3 commits from feature/184 into main 2026-08-30 04:00:44 +00:00
Member

What changed

Implements [E00-S04-T03] Secrets automatically redact from logs (#184): every log line the app writes is scrubbed of the config's secret values before it reaches stdout/stderr, and the resolved configuration is logged at startup with the secret values replaced by [REDACTED] (the issue's test plan: "log configuration and confirm secret values are redacted").

  • packages/config — the redaction layer (@personal-blog/config): new src/redact.ts owns which fields are secrets (the schema's secret field sessionSecret — Security-and-Operations §32/§26, plus the password embedded in a databaseUrl connection string) and exports:
    • redactConfig(config): Config — a copy of a config value with every secret replaced by [REDACTED] (secret fields by name; the databaseUrl password masked in place, postgres://user:[REDACTED]@host; an unparseable databaseUrl is replaced wholesale because its password cannot be isolated) — for logging the resolved configuration.
    • redactText(text, config): string — scrubs every occurrence of the config's secret values from free-form log text (e.g. an error message carrying a connection string), so a secret value is redacted even when it was not redacted by field.
    • REDACTED (the [REDACTED] placeholder) and SECRET_FIELD_NAMES (schema-derived, currently sessionSecret). The package boundary re-exports the layer. packages/config gains @types/node (devDep, same 24.13.3 pin as the server) and "types": ["node"] because redact.ts uses WHATWG URL (node:url) for connection-string parsing.
  • apps/server — the redacting logger: the entrypoint defines createLogger(config) (inlined in apps/server/src/index.ts so the committed server source stays directly executable under Node type stripping, which cannot resolve relative .js → .ts imports), which serializes each argument and passes the joined line through redactText before writing to stdout/stderr. ALL server output goes through it (console.log/console.error are gone — the suite rejects their reintroduction), and the server now logs its resolved configuration at startup: [config] resolved configuration: {"host":"0.0.0.0","port":…,"sessionSecret":"[REDACTED]",…}. The startup validation still runs before the logger is created, so the E00-S04-T02 field-specific startup error is unchanged.
  • .gitea/workflows/ci.yml: additive config-log-redaction job (Node 24, frozen install, builds config + database-postgres, runs the new suite) gating the criterion on every PR — matching the #396/#397 precedent.
  • docs/development/non-container.md: the package table and the Run section document the redacted resolved-configuration log line.
  • pnpm-lock.yaml: packages/config importer gains the @types/node devDep (already resolved at 24.13.3 from the server importer — no new package entries); pnpm install --frozen-lockfile passes.

Explicitly out of scope per the brief, not touched: field-specific startup error (E00-S04-T02, merged), the process.env access rule / environment adapter (E00-S04-T04 — the server still reads the env vars it needs directly, as before; the adapter that centralizes these reads lands with T04 and will feed the validated config into this same redaction layer), .env.example placeholders (E00-S04-T05).

Criterion → test table

Acceptance criterion Test (fails without the committed state)
secrets automatically redact from logs tests/config-log-redaction.test.mjs — "the config package exposes the secret redaction layer (redactConfig + redactText)" (redact.ts exports REDACTED, schema-derived SECRET_FIELD_NAMES = ['sessionSecret'], redactConfig, redactText; the databaseUrl password is masked in place); "the package boundary re-exports the redaction layer"; "the server logs through the redacting logger and logs its resolved configuration redacted" (the server imports redactConfig/redactText from @personal-blog/config, defines createLogger(config), scrubs every line via redactText before process.stdout/stderr.write, logs [config] resolved configuration: via redactConfig(config), and contains no bare console.log/console.error); mutation probes — renaming redactText/createLogger, changing the [REDACTED] placeholder, replacing the whole-value split/join scrub with a partial replace, unmasking the databaseUrl password, dropping a boundary re-export, writing a line without the redactText pass-through, reintroducing a bare console.log/console.error, and dropping the resolved-configuration log all fail; deterministic probes — the compiled @personal-blog/config boundary redacts the admin-session secret, masks the connection-string password, replaces an unparseable URL wholesale, scrubs free text, and leaves non-secret text unchanged; the server boot probe (the issue's test plan) boots the committed server and confirms stdout shows [config] resolved configuration: with "sessionSecret":"[REDACTED]"
log output contains no secret values tests/config-log-redaction.test.mjs — server boot probes: booting the committed server with a distinctive ≥ 32-char EPPP_SESSION_SECRET (and no DATABASE_URL) boots to GET /health 200 while no secret value appears in stdout/stderr; booting with DATABASE_URL=postgres://redact-user:redact-password@… (dead port → migration fails, app stays not-ready) logs the masked URL (postgres://redact-user:[REDACTED]@127.0.0.1:…), still logs the failure via the redacting logger, and neither the database password nor the raw connection string nor the admin-session secret appears anywhere in stdout/stderr; the compiled-boundary probe additionally asserts scrubbed output contains no secret value
the log-redaction criterion gates merges via the config-log-redaction job tests/config-log-redaction.test.mjs — "the config-log-redaction criterion is enforced in CI" (root test glob covers the suite; .gitea/workflows/ci.yml runs node --test tests/config-log-redaction.test.mjs and builds @personal-blog/config + @personal-blog/database-postgres first); .gitea/workflows/ci.yml — config-log-redaction job (additive, matching the security-reviewed #396/#397 precedent)

Test plan executed

  • node --test tests/config-log-redaction.test.mjs → 16 tests, 16 pass / 0 fail / 0 skip. The deterministic probes ran for real: the compiled boundary replaces the admin-session secret with [REDACTED], masks postgres://redact-user:redact-password@db:5432/redact-db → postgres://redact-user:[REDACTED]@db:5432/redact-db, replaces an unparseable URL wholesale, and scrubs free text (connecting with <secret> now → connecting with [REDACTED] now); booting the committed server with the distinctive secret logs [config] resolved configuration: with "sessionSecret":"[REDACTED]" and no secret value in stdout/stderr; booting with a dead-port DATABASE_URL logs the masked URL, stays not-ready (GET /health 503), and neither the password nor the raw connection string appears in the log output.
  • Affected existing suites: config-startup-error (17), config-schema (12), health-endpoint (7), secrets-not-embedded (static assertions incl. "the committed files copied into the image contain no default credential values" — the new files under apps/server//packages/config/ contain no credential literals), architecture-import (10), no-core-extension-imports (2), workspace-layout (8), workspace-config (6), strict-tsconfig (5), typescript-pin (3), database-postgres-imports (2), app-readiness (18 pass + 1 docker-gated skip) — all green.
  • Full suite (node --test "tests/**/*.test.mjs"): 243 tests — 219 pass / 9 fail / 15 skip; the 9 failures are the pre-existing Node-22 environment artifacts identical to the base-commit baseline documented in #397/#396 (this sandbox has Node 22 — the workspace engines gate requires Node ≥ 24): tests/frozen-install.test.mjs ×5 and tests/root-commands.test.mjs ×3 fail on ERR_PNPM_UNSUPPORTED_ENGINE, tests/node-engine.test.mjs ×1 asserts the runtime is Node 24.x. CI runs Node 24 where these pass.
  • Frozen install + lockfile: pnpm install --frozen-lockfile passes against the regenerated pnpm-lock.yaml (packages/config importer gains @types/node@24.13.3 — already resolved from the server importer, so no new package entries).
  • Build/typecheck: packages/config (with the new redaction module), packages/database-postgres and apps/server compile with tsc --noEmit exit 0 (strict base config, NodeNext, verbatimModuleSyntax, TypeScript 6.0.3). The Dockerfile needs no change: it already copies packages/config and apps/server (source + compiled dist), and the inlined logger lives in the existing entrypoint.

Risks / notes

  • Redaction scope: the config package treats sessionSecret as the secret field (schema-derived, Security-and-Operations §32/§26) and the password embedded in a DATABASE_URL connection string (a credential; masked in place in redactConfig and scrubbed from free text by redactText). An unparseable databaseUrl is replaced wholesale (its password cannot be isolated) — never logged raw. Non-secret fields pass through unchanged so operators still see the effective host/port.
  • Behavioral notes: the server now emits one additional startup line ([config] resolved configuration: …) — existing suites that boot the server assert with .match, not exact stdout, and are unaffected (verified). The migration-failure error is still logged with the same message via logger.error (an Error serializes to its message, as before). The startup-validation path (E00-S04-T02) is unchanged: assertValidConfig still runs before the logger is created, so a missing required setting still fails fast with missing required setting: sessionSecret (boot probes in the sibling suite still pass).
  • Type-stripping constraint: the redacting logger is inlined in apps/server/src/index.ts (rather than a separate logger.ts) because the committed server source must stay directly executable under Node type stripping (node apps/server/src/index.ts), which cannot resolve a relative ./logger.js import to logger.ts; a separate module would break the existing boot probes (health-endpoint, app-readiness, config-startup-error). The suite still rejects bare console.log/console.error with a mutation probe.
  • CI workflow change is strictly additive (new config-log-redaction job, no existing job removed/modified) and matches the security-reviewed #396/#397 precedent; per the review-checklist pipeline tripwire a human decision on the new merge gate may be requested.
  • Rollback note from the issue: revert the redaction changes — remove packages/config/src/redact.ts (+ boundary re-exports), the server's createLogger/redactText/redactConfig wiring and the resolved-configuration log line, the @types/node devDep + "types": ["node"] in packages/config, the config-log-redaction CI job and the test suite, and the docs note (no data migration involved).

Refs #184

## What changed Implements [E00-S04-T03] Secrets automatically redact from logs (#184): every log line the app writes is scrubbed of the config's secret values before it reaches stdout/stderr, and the resolved configuration is logged at startup with the secret values replaced by `[REDACTED]` (the issue's test plan: "log configuration and confirm secret values are redacted"). - **`packages/config` — the redaction layer** (`@personal-blog/config`): new `src/redact.ts` owns *which fields are secrets* (the schema's secret field `sessionSecret` — Security-and-Operations §32/§26, plus the password embedded in a `databaseUrl` connection string) and exports: - `redactConfig(config): Config` — a copy of a config value with every secret replaced by `[REDACTED]` (secret fields by name; the `databaseUrl` password masked in place, `postgres://user:[REDACTED]@host`; an unparseable `databaseUrl` is replaced wholesale because its password cannot be isolated) — for logging the resolved configuration. - `redactText(text, config): string` — scrubs every occurrence of the config's secret values from free-form log text (e.g. an error message carrying a connection string), so a secret value is redacted even when it was not redacted by field. - `REDACTED` (the `[REDACTED]` placeholder) and `SECRET_FIELD_NAMES` (schema-derived, currently `sessionSecret`). The package boundary re-exports the layer. `packages/config` gains `@types/node` (devDep, same 24.13.3 pin as the server) and `"types": ["node"]` because `redact.ts` uses WHATWG `URL` (`node:url`) for connection-string parsing. - **`apps/server` — the redacting logger**: the entrypoint defines `createLogger(config)` (inlined in `apps/server/src/index.ts` so the committed server source stays directly executable under Node type stripping, which cannot resolve relative `.js` → `.ts` imports), which serializes each argument and passes the joined line through `redactText` before writing to stdout/stderr. **ALL** server output goes through it (`console.log`/`console.error` are gone — the suite rejects their reintroduction), and the server now logs its resolved configuration at startup: `[config] resolved configuration: {"host":"0.0.0.0","port":…,"sessionSecret":"[REDACTED]",…}`. The startup validation still runs before the logger is created, so the E00-S04-T02 field-specific startup error is unchanged. - **`.gitea/workflows/ci.yml`**: additive `config-log-redaction` job (Node 24, frozen install, builds config + database-postgres, runs the new suite) gating the criterion on every PR — matching the #396/#397 precedent. - **`docs/development/non-container.md`**: the package table and the Run section document the redacted resolved-configuration log line. - **`pnpm-lock.yaml`**: `packages/config` importer gains the `@types/node` devDep (already resolved at 24.13.3 from the server importer — no new package entries); `pnpm install --frozen-lockfile` passes. Explicitly out of scope per the brief, **not touched**: field-specific startup error (E00-S04-T02, merged), the `process.env` access rule / environment adapter (E00-S04-T04 — the server still reads the env vars it needs directly, as before; the adapter that centralizes these reads lands with T04 and will feed the validated config into this same redaction layer), `.env.example` placeholders (E00-S04-T05). ## Criterion → test table | Acceptance criterion | Test (fails without the committed state) | | --- | --- | | secrets automatically redact from logs | `tests/config-log-redaction.test.mjs` — **"the config package exposes the secret redaction layer (redactConfig + redactText)"** (redact.ts exports `REDACTED`, schema-derived `SECRET_FIELD_NAMES = ['sessionSecret']`, `redactConfig`, `redactText`; the `databaseUrl` password is masked in place); **"the package boundary re-exports the redaction layer"**; **"the server logs through the redacting logger and logs its resolved configuration redacted"** (the server imports `redactConfig`/`redactText` from `@personal-blog/config`, defines `createLogger(config)`, scrubs every line via `redactText` before `process.stdout/stderr.write`, logs `[config] resolved configuration:` via `redactConfig(config)`, and contains **no bare `console.log`/`console.error`**); mutation probes — renaming `redactText`/`createLogger`, changing the `[REDACTED]` placeholder, replacing the whole-value split/join scrub with a partial replace, unmasking the `databaseUrl` password, dropping a boundary re-export, writing a line without the `redactText` pass-through, reintroducing a bare `console.log`/`console.error`, and dropping the resolved-configuration log all fail; deterministic probes — the **compiled `@personal-blog/config` boundary** redacts the admin-session secret, masks the connection-string password, replaces an unparseable URL wholesale, scrubs free text, and leaves non-secret text unchanged; the **server boot probe** (the issue's test plan) boots the committed server and confirms stdout shows `[config] resolved configuration:` with `"sessionSecret":"[REDACTED]"` | | log output contains no secret values | `tests/config-log-redaction.test.mjs` — **server boot probes**: booting the committed server with a distinctive ≥ 32-char `EPPP_SESSION_SECRET` (and no `DATABASE_URL`) boots to `GET /health` 200 while **no** secret value appears in stdout/stderr; booting with `DATABASE_URL=postgres://redact-user:redact-password@…` (dead port → migration fails, app stays not-ready) logs the **masked** URL (`postgres://redact-user:[REDACTED]@127.0.0.1:…`), still logs the failure via the redacting logger, and **neither the database password nor the raw connection string nor the admin-session secret appears anywhere in stdout/stderr**; the compiled-boundary probe additionally asserts scrubbed output contains no secret value | | the log-redaction criterion gates merges via the `config-log-redaction` job | `tests/config-log-redaction.test.mjs` — **"the config-log-redaction criterion is enforced in CI"** (root test glob covers the suite; `.gitea/workflows/ci.yml` runs `node --test tests/config-log-redaction.test.mjs` and builds `@personal-blog/config` + `@personal-blog/database-postgres` first); `.gitea/workflows/ci.yml` — **`config-log-redaction` job** (additive, matching the security-reviewed #396/#397 precedent) | ## Test plan executed - `node --test tests/config-log-redaction.test.mjs` → **16 tests, 16 pass / 0 fail / 0 skip**. The deterministic probes ran for real: the compiled boundary replaces the admin-session secret with `[REDACTED]`, masks `postgres://redact-user:redact-password@db:5432/redact-db` → `postgres://redact-user:[REDACTED]@db:5432/redact-db`, replaces an unparseable URL wholesale, and scrubs free text (`connecting with <secret> now` → `connecting with [REDACTED] now`); booting the committed server with the distinctive secret logs `[config] resolved configuration:` with `"sessionSecret":"[REDACTED]"` and **no** secret value in stdout/stderr; booting with a dead-port `DATABASE_URL` logs the masked URL, stays not-ready (`GET /health` 503), and neither the password nor the raw connection string appears in the log output. - **Affected existing suites**: `config-startup-error` (17), `config-schema` (12), `health-endpoint` (7), `secrets-not-embedded` (static assertions incl. "the committed files copied into the image contain no default credential values" — the new files under `apps/server/`/`packages/config/` contain no credential literals), `architecture-import` (10), `no-core-extension-imports` (2), `workspace-layout` (8), `workspace-config` (6), `strict-tsconfig` (5), `typescript-pin` (3), `database-postgres-imports` (2), `app-readiness` (18 pass + 1 docker-gated skip) — all green. - **Full suite** (`node --test "tests/**/*.test.mjs"`): **243 tests — 219 pass / 9 fail / 15 skip**; the 9 failures are the pre-existing Node-22 environment artifacts identical to the base-commit baseline documented in #397/#396 (this sandbox has Node 22 — the workspace engines gate requires Node ≥ 24): `tests/frozen-install.test.mjs` ×5 and `tests/root-commands.test.mjs` ×3 fail on `ERR_PNPM_UNSUPPORTED_ENGINE`, `tests/node-engine.test.mjs` ×1 asserts the runtime is Node 24.x. CI runs Node 24 where these pass. - **Frozen install + lockfile**: `pnpm install --frozen-lockfile` passes against the regenerated `pnpm-lock.yaml` (packages/config importer gains `@types/node@24.13.3` — already resolved from the server importer, so no new package entries). - **Build/typecheck**: `packages/config` (with the new redaction module), `packages/database-postgres` and `apps/server` compile with `tsc --noEmit` exit 0 (strict base config, NodeNext, verbatimModuleSyntax, TypeScript 6.0.3). The Dockerfile needs no change: it already copies `packages/config` and `apps/server` (source + compiled dist), and the inlined logger lives in the existing entrypoint. ## Risks / notes - **Redaction scope**: the config package treats `sessionSecret` as the secret field (schema-derived, Security-and-Operations §32/§26) **and** the password embedded in a `DATABASE_URL` connection string (a credential; masked in place in `redactConfig` and scrubbed from free text by `redactText`). An unparseable `databaseUrl` is replaced wholesale (its password cannot be isolated) — never logged raw. Non-secret fields pass through unchanged so operators still see the effective `host`/`port`. - **Behavioral notes**: the server now emits one additional startup line (`[config] resolved configuration: …`) — existing suites that boot the server assert with `.match`, not exact stdout, and are unaffected (verified). The migration-failure error is still logged with the same message via `logger.error` (an `Error` serializes to its message, as before). The startup-validation path (E00-S04-T02) is unchanged: `assertValidConfig` still runs before the logger is created, so a missing required setting still fails fast with `missing required setting: sessionSecret` (boot probes in the sibling suite still pass). - **Type-stripping constraint**: the redacting logger is inlined in `apps/server/src/index.ts` (rather than a separate `logger.ts`) because the committed server source must stay directly executable under Node type stripping (`node apps/server/src/index.ts`), which cannot resolve a relative `./logger.js` import to `logger.ts`; a separate module would break the existing boot probes (health-endpoint, app-readiness, config-startup-error). The suite still rejects bare `console.log`/`console.error` with a mutation probe. - **CI workflow change is strictly additive** (new `config-log-redaction` job, no existing job removed/modified) and matches the security-reviewed #396/#397 precedent; per the review-checklist pipeline tripwire a human decision on the new merge gate may be requested. - **Rollback note from the issue**: revert the redaction changes — remove `packages/config/src/redact.ts` (+ boundary re-exports), the server's `createLogger`/`redactText`/`redactConfig` wiring and the resolved-configuration log line, the `@types/node` devDep + `"types": ["node"]` in `packages/config`, the `config-log-redaction` CI job and the test suite, and the docs note (no data migration involved). Refs #184
bot-implementer added 2 commits 2026-08-30 03:53:22 +00:00
bot-implementer added 1 commit 2026-08-30 03:54:43 +00:00
docs: document the redacted resolved-configuration log in the non-container guide (E00-S04-T03)
CI / Frozen lockfile install (pull_request) Successful in 45s
CI / Secrets not embedded (E00-S02-T08) (pull_request) Successful in 25s
CI / Database-postgres import isolation (E00-S03-T02) (pull_request) Successful in 23s
CI / Migration ledger (E00-S03-T03) (pull_request) Successful in 48s
CI / Migration advisory lock (E00-S03-T04) (pull_request) Successful in 43s
CI / Migration failure diagnostic (E00-S03-T05) (pull_request) Successful in 46s
CI / App readiness after migrations (E00-S03-T06) (pull_request) Successful in 1m11s
CI / Field-specific startup errors (E00-S04-T02) (pull_request) Successful in 1m9s
CI / Secret redaction from logs (E00-S04-T03) (pull_request) Successful in 1m18s
CI / TypeBox/Ajv config schema (E00-S04-T01) (pull_request) Successful in 57s
CI / Compose config (E00-S03-T01) (pull_request) Successful in 26s
ae900589c3
bot-implementer force-pushed feature/184 from 25248461e0 to ae900589c3 2026-08-30 03:54:43 +00:00 Compare
kpcto merged commit 072f5c5785 into main 2026-08-30 04:00:44 +00:00
kpcto deleted branch feature/184 2026-08-30 04:00:44 +00:00
Sign in to join this conversation.