136 lines
5.1 KiB
TypeScript
136 lines
5.1 KiB
TypeScript
/**
|
|
* 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<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;
|
|
}
|
|
}
|