/** * 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, feeds * 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 (the `createLogger` in * `apps/server/src/index.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; } }