feat: secrets redact from logs via config redaction layer and server redacting logger (E00-S04-T03)

This commit is contained in:
implementer
2026-08-30 03:52:52 +00:00
parent ebb9d4f421
commit 6458013306
7 changed files with 222 additions and 11 deletions
+4 -1
View File
@@ -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": {
+10 -2
View File
@@ -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';
+135
View File
@@ -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<string, unknown> = {};
for (const key of Object.keys(config)) {
const value = (config as Record<string, unknown>)[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<string, unknown>)[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;
}
}
+2 -1
View File
@@ -2,7 +2,8 @@
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"rootDir": "src",
"outDir": "dist"
"outDir": "dist",
"types": ["node"]
},
"include": ["src"]
}