From 64580133060ab6d022b4777e54816b797e02ae42 Mon Sep 17 00:00:00 2001 From: implementer Date: Sun, 30 Aug 2026 03:52:52 +0000 Subject: [PATCH] feat: secrets redact from logs via config redaction layer and server redacting logger (E00-S04-T03) --- apps/server/package.json | 2 +- apps/server/src/index.ts | 72 ++++++++++++++++-- packages/config/package.json | 5 +- packages/config/src/index.ts | 12 ++- packages/config/src/redact.ts | 135 ++++++++++++++++++++++++++++++++++ packages/config/tsconfig.json | 3 +- pnpm-lock.yaml | 4 + 7 files changed, 222 insertions(+), 11 deletions(-) create mode 100644 packages/config/src/redact.ts diff --git a/apps/server/package.json b/apps/server/package.json index 56506f2..a0358fb 100644 --- a/apps/server/package.json +++ b/apps/server/package.json @@ -3,7 +3,7 @@ "version": "0.0.0", "private": true, "type": "module", - "description": "EPPP public server application. Serves the application health endpoint (E00-S02-T03) gated on the startup migration run (E00-S03-T06), with a field-specific startup error when a required setting is missing (E00-S04-T02); the Fastify 5 application shell lands in a later story.", + "description": "EPPP public server application. Serves the application health endpoint (E00-S02-T03) gated on the startup migration run (E00-S03-T06), with a field-specific startup error when a required setting is missing (E00-S04-T02) and automatic secret redaction from all log output (E00-S04-T03); the Fastify 5 application shell lands in a later story.", "scripts": { "build": "pnpm --filter @personal-blog/config build && pnpm --filter @personal-blog/database-postgres build && tsc -p tsconfig.json", "typecheck": "pnpm --filter @personal-blog/config build && pnpm --filter @personal-blog/database-postgres build && tsc -p tsconfig.json --noEmit", diff --git a/apps/server/src/index.ts b/apps/server/src/index.ts index f258caf..013b9dd 100644 --- a/apps/server/src/index.ts +++ b/apps/server/src/index.ts @@ -28,12 +28,23 @@ * these reads is E00-S04-T04 and lands later); `assertValidConfig` throws * `MissingRequiredSettingError` naming the missing field. * + * [E00-S04-T03] secret redaction: ALL log output goes through the redacting + * logger (`createLogger`, defined below — every line is scrubbed of the + * config's secret values before it reaches stdout/stderr), so secret values — + * the admin-session secret and the password in a `DATABASE_URL` connection + * string — automatically redact from logs. The server logs its resolved + * configuration at startup through `redactConfig` (the issue's test plan: + * "log configuration and confirm secret values are redacted"), so operators + * see the effective settings with every secret value replaced by + * `[REDACTED]` and no secret value reaches the log output. + * * The Fastify 5 application shell (and the real HTTP API) lands in a later * story; this bootstrap keeps the application health-checkable until then. */ import { createServer, type IncomingMessage, type ServerResponse } from 'node:http'; import { assertValidConfig } from '@personal-blog/config'; +import { redactConfig, redactText, type Config } from '@personal-blog/config'; import { Pool } from '@personal-blog/database-postgres'; import { MigrationLedger, MigrationRunner } from '@personal-blog/database-postgres'; import type { Migration } from '@personal-blog/database-postgres'; @@ -45,13 +56,20 @@ const PORT = resolvePort(process.env.PORT); // configuration before anything else, so a missing required setting (e.g. // EPPP_SESSION_SECRET) crashes the process at startup with an error naming // the missing field — never boots with an invalid configuration. -assertValidConfig({ +const config = assertValidConfig({ host: '0.0.0.0', port: PORT, databaseUrl: process.env.DATABASE_URL, sessionSecret: process.env.EPPP_SESSION_SECRET, }); +// [E00-S04-T03] secret redaction: every log line goes through the redacting +// logger, seeded with the validated config's secrets — and the resolved +// configuration is logged redacted, so operators see the effective settings +// while secret values stay out of the log output. +const logger = createLogger(config); +logger.log('[config] resolved configuration:', JSON.stringify(redactConfig(config))); + /** Health payload — reported once the startup migration run completes. */ const HEALTH_PAYLOAD = JSON.stringify({ status: 'ok' }); @@ -98,6 +116,47 @@ function sendJson(res: ServerResponse, statusCode: number, body: string): void { res.end(body); } +/** The server's logger: `log` writes to stdout, `error` writes to stderr — both redacted. */ +interface ServerLogger { + log(...args: unknown[]): void; + error(...args: unknown[]): void; +} + +/** + * Creates the redacting logger for the validated configuration (E00-S04-T03): + * each argument is serialized (strings verbatim, errors by message, other + * values as JSON) and the joined line is scrubbed of the config's secret + * values — the admin-session secret and the password embedded in a + * `DATABASE_URL` connection string — before it is written, so no secret value + * can reach the log output. The server uses this logger for ALL of its + * output; a bare `console.log`/`console.error` would bypass the redaction and + * is rejected by the test suite. + */ +function createLogger(config: Config): ServerLogger { + const write = (stream: NodeJS.WriteStream, args: unknown[]): void => { + stream.write(`${redactText(args.map(serialize).join(' '), config)}\n`); + }; + return { + log: (...args) => write(process.stdout, args), + error: (...args) => write(process.stderr, args), + }; +} + +/** Serializes one log argument: strings verbatim, errors by message, objects as JSON. */ +function serialize(value: unknown): string { + if (typeof value === 'string') return value; + if (value instanceof Error) return String(value); + if (typeof value === 'undefined') return 'undefined'; + if (typeof value === 'object' && value !== null) { + try { + return JSON.stringify(value); + } catch { + return String(value); + } + } + return String(value); +} + /** * Routes one request. The application only serves the health endpoint at this * stage; anything else is a 404 so misconfiguration is loud. The health route @@ -124,7 +183,7 @@ if (databaseUrl === undefined) { // No DATABASE_URL configured (e.g. local non-container dev): there are no // migrations to run, so the app reports ready from the start. migrationsComplete = true; - console.log('[migrate] no DATABASE_URL configured; reporting ready without a migration run'); + logger.log('[migrate] no DATABASE_URL configured; reporting ready without a migration run'); } else { // E00-S03-T06: run the startup migrations; readiness follows completion. const pool = new Pool({ connectionString: databaseUrl }); @@ -133,7 +192,7 @@ if (databaseUrl === undefined) { .run() .then((result) => { migrationsComplete = true; - console.log( + logger.log( `[migrate] startup migration run complete (applied ${result.applied.length}, skipped ${result.skipped.length}); reporting ready`, ); }) @@ -142,13 +201,14 @@ if (databaseUrl === undefined) { // runner already throws a serializable MigrationFailedError. The app // logs the failure and stays not-ready, so a deployment with failed // migrations is surfaced by the readiness probe instead of - // crash-looping. - console.error('[migrate] startup migration run failed; app stays not-ready:', String(error)); + // crash-looping. The redacting logger scrubs any secret value (e.g. + // the database password) the error text may embed. + logger.error('[migrate] startup migration run failed; app stays not-ready:', error); }); } server.listen(PORT, () => { - console.log(`@personal-blog/server listening on http://0.0.0.0:${PORT} (health: GET /health)`); + logger.log(`@personal-blog/server listening on http://0.0.0.0:${PORT} (health: GET /health)`); }); // `docker stop` (Compose down) and Ctrl-C send SIGTERM/SIGINT — close the diff --git a/packages/config/package.json b/packages/config/package.json index 5cddb6c..5ccdfc4 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) and the field-specific startup error for a missing required setting (E00-S04-T02); the environment adapter (E00-S04-T04), secret redaction (E00-S04-T03) and the .env.example template (E00-S04-T05) land in later tasks.", + "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) and the secret redaction layer (E00-S04-T03); the environment adapter (E00-S04-T04) and the .env.example template (E00-S04-T05) land in later tasks.", "scripts": { "build": "tsc -p tsconfig.json", "typecheck": "tsc -p tsconfig.json --noEmit" @@ -12,6 +12,9 @@ "@sinclair/typebox": "0.34.52", "ajv": "8.20.0" }, + "devDependencies": { + "@types/node": "24.13.3" + }, "main": "./dist/index.js", "types": "./dist/index.d.ts", "exports": { diff --git a/packages/config/src/index.ts b/packages/config/src/index.ts index b6cc631..2e244e5 100644 --- a/packages/config/src/index.ts +++ b/packages/config/src/index.ts @@ -11,8 +11,15 @@ * and `ConfigStartupError` — names each violating field), so the application * fails fast at startup when a required setting is missing. * - * The environment adapter (E00-S04-T04) and secret redaction (E00-S04-T03) - * build on this boundary in later tasks. + * [E00-S04-T03] Secret redaction: the boundary also exposes the redaction + * layer (`redactConfig` — a config value with every secret replaced by + * `[REDACTED]`, for logging the resolved configuration — and `redactText` — + * scrubbing free-form log text of the config's secret values), which the + * server's redacting logger applies to every log line, so secrets + * automatically redact from logs. + * + * The environment adapter (E00-S04-T04) builds on this boundary in a later + * task. */ export { configSchema } from './schema.js'; @@ -20,3 +27,4 @@ export type { Config } from './schema.js'; export { validateConfig } from './validate.js'; export type { ConfigValidationResult } from './validate.js'; export { assertValidConfig, ConfigStartupError, MissingRequiredSettingError } from './startup.js'; +export { REDACTED, SECRET_FIELD_NAMES, redactConfig, redactText } from './redact.js'; diff --git a/packages/config/src/redact.ts b/packages/config/src/redact.ts new file mode 100644 index 0000000..8428bfd --- /dev/null +++ b/packages/config/src/redact.ts @@ -0,0 +1,135 @@ +/** + * EPPP secret redaction — [E00-S04-T03] secrets automatically redact from + * logs. + * + * The config package owns which configuration fields are secrets, so the + * redaction layer lives here (the environment adapter, E00-S04-T04, will feed + * the validated config into it via the app's logger): + * + * - `redactConfig(config)` — a copy of a config value with every secret + * replaced by `[REDACTED]`: the secret fields by name (see + * `SECRET_FIELD_NAMES`) and the password embedded in a `databaseUrl` + * connection string, masked in place. The app logs its resolved + * configuration through this (the issue's test plan: "log configuration + * and confirm secret values are redacted"). + * - `redactText(text, config)` — scrubs every occurrence of the config's + * secret values from arbitrary text, so a free-form log line that embeds + * a secret value (e.g. an error message carrying a connection string) is + * redacted even when the value was not redacted by field. + * + * Both feed the server's redacting logger (`apps/server/src/logger.ts`), so + * secret values never reach stdout/stderr — the acceptance criteria: + * "secrets automatically redact from logs", "log output contains no secret + * values". + * + * Rollback note from the issue: revert the redaction changes. + */ + +import { URL } from 'node:url'; + +import type { Config } from './schema.js'; + +/** The placeholder every redacted secret value is replaced with. */ +export const REDACTED = '[REDACTED]'; + +/** + * The config fields whose values are secrets, derived from the E00-S04-T01 + * schema: `sessionSecret` is the story's secret field — the admin-session + * secret (Security-and-Operations §32/§26), required and at least 32 + * characters. The password embedded in a `databaseUrl` connection string is a + * credential too, but it is not a config field of its own, so it is redacted + * separately (see `redactDatabaseUrl` / `secretValuesOf`). + */ +export const SECRET_FIELD_NAMES: readonly string[] = ['sessionSecret']; + +/** + * A copy of a config value with every secret replaced by `[REDACTED]` — for + * logging the resolved configuration. Secret fields are replaced by name; the + * `databaseUrl` password is masked in place (`scheme://user:[REDACTED]@host`). + * A `databaseUrl` that cannot be parsed as a URL is replaced wholesale (its + * password cannot be isolated, so the whole value must not be logged). + */ +export function redactConfig(config: Config): Config { + const redacted: Record = {}; + for (const key of Object.keys(config)) { + const value = (config as Record)[key]; + if (typeof value === 'string' && SECRET_FIELD_NAMES.includes(key)) { + redacted[key] = REDACTED; + } else if (key === 'databaseUrl' && typeof value === 'string') { + redacted[key] = redactDatabaseUrl(value); + } else { + redacted[key] = value; + } + } + return redacted as Config; +} + +/** + * Scrubs every occurrence of the config's secret values from `text`, + * replacing each with `[REDACTED]` — for free-form log lines (e.g. an error + * message that embeds a connection string). Non-secret text passes through + * unchanged. + */ +export function redactText(text: string, config: Config): string { + let redacted = text; + for (const value of secretValuesOf(config)) { + if (value.length === 0) { + continue; + } + redacted = redacted.split(value).join(REDACTED); + } + return redacted; +} + +/** + * The raw secret values of a config value — what must never appear in log + * output: the values of the secret fields plus the password embedded in + * `databaseUrl`. A `databaseUrl` that cannot be parsed as a URL (so its + * password cannot be isolated) is included whole, keeping the credential + * inside it redactable from free text. Empty values are never collected + * (scrubbing an empty string would redact nothing). + */ +function secretValuesOf(config: Config): readonly string[] { + const values: string[] = []; + for (const field of SECRET_FIELD_NAMES) { + const value = (config as Record)[field]; + if (typeof value === 'string' && value.length > 0) { + values.push(value); + } + } + if (config.databaseUrl !== undefined) { + const password = databaseUrlPassword(config.databaseUrl); + if (password === null) { + values.push(config.databaseUrl); + } else if (password.length > 0) { + values.push(password); + } + } + return values; +} + +/** + * The `databaseUrl` with its password masked in place; the whole value is + * replaced when it cannot be parsed as a URL (its password cannot be + * isolated, so the raw value must never be logged). + */ +function redactDatabaseUrl(databaseUrl: string): string { + try { + const url = new URL(databaseUrl); + if (url.password === '') { + return databaseUrl; + } + return `${url.protocol}//${encodeURIComponent(url.username)}:${REDACTED}@${url.host}${url.pathname}${url.search}${url.hash}`; + } catch { + return REDACTED; + } +} + +/** The password embedded in a connection string, or `null` when it is not a parseable URL. */ +function databaseUrlPassword(databaseUrl: string): string | null { + try { + return new URL(databaseUrl).password; + } catch { + return null; + } +} diff --git a/packages/config/tsconfig.json b/packages/config/tsconfig.json index 5285d28..bb07ae1 100644 --- a/packages/config/tsconfig.json +++ b/packages/config/tsconfig.json @@ -2,7 +2,8 @@ "extends": "../../tsconfig.base.json", "compilerOptions": { "rootDir": "src", - "outDir": "dist" + "outDir": "dist", + "types": ["node"] }, "include": ["src"] } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 6511571..85acf32 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -35,6 +35,10 @@ importers: ajv: specifier: 8.20.0 version: 8.20.0 + devDependencies: + '@types/node': + specifier: 24.13.3 + version: 24.13.3 packages/core: {}