feat: secrets redact from logs via config redaction layer and server redacting logger (E00-S04-T03)
This commit is contained in:
@@ -3,7 +3,7 @@
|
|||||||
"version": "0.0.0",
|
"version": "0.0.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"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": {
|
"scripts": {
|
||||||
"build": "pnpm --filter @personal-blog/config build && pnpm --filter @personal-blog/database-postgres build && tsc -p tsconfig.json",
|
"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",
|
"typecheck": "pnpm --filter @personal-blog/config build && pnpm --filter @personal-blog/database-postgres build && tsc -p tsconfig.json --noEmit",
|
||||||
|
|||||||
@@ -28,12 +28,23 @@
|
|||||||
* these reads is E00-S04-T04 and lands later); `assertValidConfig` throws
|
* these reads is E00-S04-T04 and lands later); `assertValidConfig` throws
|
||||||
* `MissingRequiredSettingError` naming the missing field.
|
* `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
|
* The Fastify 5 application shell (and the real HTTP API) lands in a later
|
||||||
* story; this bootstrap keeps the application health-checkable until then.
|
* story; this bootstrap keeps the application health-checkable until then.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { createServer, type IncomingMessage, type ServerResponse } from 'node:http';
|
import { createServer, type IncomingMessage, type ServerResponse } from 'node:http';
|
||||||
import { assertValidConfig } from '@personal-blog/config';
|
import { assertValidConfig } from '@personal-blog/config';
|
||||||
|
import { redactConfig, redactText, type Config } from '@personal-blog/config';
|
||||||
import { Pool } from '@personal-blog/database-postgres';
|
import { Pool } from '@personal-blog/database-postgres';
|
||||||
import { MigrationLedger, MigrationRunner } from '@personal-blog/database-postgres';
|
import { MigrationLedger, MigrationRunner } from '@personal-blog/database-postgres';
|
||||||
import type { Migration } 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.
|
// configuration before anything else, so a missing required setting (e.g.
|
||||||
// EPPP_SESSION_SECRET) crashes the process at startup with an error naming
|
// EPPP_SESSION_SECRET) crashes the process at startup with an error naming
|
||||||
// the missing field — never boots with an invalid configuration.
|
// the missing field — never boots with an invalid configuration.
|
||||||
assertValidConfig({
|
const config = assertValidConfig({
|
||||||
host: '0.0.0.0',
|
host: '0.0.0.0',
|
||||||
port: PORT,
|
port: PORT,
|
||||||
databaseUrl: process.env.DATABASE_URL,
|
databaseUrl: process.env.DATABASE_URL,
|
||||||
sessionSecret: process.env.EPPP_SESSION_SECRET,
|
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. */
|
/** Health payload — reported once the startup migration run completes. */
|
||||||
const HEALTH_PAYLOAD = JSON.stringify({ status: 'ok' });
|
const HEALTH_PAYLOAD = JSON.stringify({ status: 'ok' });
|
||||||
|
|
||||||
@@ -98,6 +116,47 @@ function sendJson(res: ServerResponse, statusCode: number, body: string): void {
|
|||||||
res.end(body);
|
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
|
* 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
|
* 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
|
// No DATABASE_URL configured (e.g. local non-container dev): there are no
|
||||||
// migrations to run, so the app reports ready from the start.
|
// migrations to run, so the app reports ready from the start.
|
||||||
migrationsComplete = true;
|
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 {
|
} else {
|
||||||
// E00-S03-T06: run the startup migrations; readiness follows completion.
|
// E00-S03-T06: run the startup migrations; readiness follows completion.
|
||||||
const pool = new Pool({ connectionString: databaseUrl });
|
const pool = new Pool({ connectionString: databaseUrl });
|
||||||
@@ -133,7 +192,7 @@ if (databaseUrl === undefined) {
|
|||||||
.run()
|
.run()
|
||||||
.then((result) => {
|
.then((result) => {
|
||||||
migrationsComplete = true;
|
migrationsComplete = true;
|
||||||
console.log(
|
logger.log(
|
||||||
`[migrate] startup migration run complete (applied ${result.applied.length}, skipped ${result.skipped.length}); reporting ready`,
|
`[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
|
// runner already throws a serializable MigrationFailedError. The app
|
||||||
// logs the failure and stays not-ready, so a deployment with failed
|
// logs the failure and stays not-ready, so a deployment with failed
|
||||||
// migrations is surfaced by the readiness probe instead of
|
// migrations is surfaced by the readiness probe instead of
|
||||||
// crash-looping.
|
// crash-looping. The redacting logger scrubs any secret value (e.g.
|
||||||
console.error('[migrate] startup migration run failed; app stays not-ready:', String(error));
|
// the database password) the error text may embed.
|
||||||
|
logger.error('[migrate] startup migration run failed; app stays not-ready:', error);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
server.listen(PORT, () => {
|
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
|
// `docker stop` (Compose down) and Ctrl-C send SIGTERM/SIGINT — close the
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
"version": "0.0.0",
|
"version": "0.0.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"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": {
|
"scripts": {
|
||||||
"build": "tsc -p tsconfig.json",
|
"build": "tsc -p tsconfig.json",
|
||||||
"typecheck": "tsc -p tsconfig.json --noEmit"
|
"typecheck": "tsc -p tsconfig.json --noEmit"
|
||||||
@@ -12,6 +12,9 @@
|
|||||||
"@sinclair/typebox": "0.34.52",
|
"@sinclair/typebox": "0.34.52",
|
||||||
"ajv": "8.20.0"
|
"ajv": "8.20.0"
|
||||||
},
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/node": "24.13.3"
|
||||||
|
},
|
||||||
"main": "./dist/index.js",
|
"main": "./dist/index.js",
|
||||||
"types": "./dist/index.d.ts",
|
"types": "./dist/index.d.ts",
|
||||||
"exports": {
|
"exports": {
|
||||||
|
|||||||
@@ -11,8 +11,15 @@
|
|||||||
* and `ConfigStartupError` — names each violating field), so the application
|
* and `ConfigStartupError` — names each violating field), so the application
|
||||||
* fails fast at startup when a required setting is missing.
|
* fails fast at startup when a required setting is missing.
|
||||||
*
|
*
|
||||||
* The environment adapter (E00-S04-T04) and secret redaction (E00-S04-T03)
|
* [E00-S04-T03] Secret redaction: the boundary also exposes the redaction
|
||||||
* build on this boundary in later tasks.
|
* 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';
|
export { configSchema } from './schema.js';
|
||||||
@@ -20,3 +27,4 @@ export type { Config } from './schema.js';
|
|||||||
export { validateConfig } from './validate.js';
|
export { validateConfig } from './validate.js';
|
||||||
export type { ConfigValidationResult } from './validate.js';
|
export type { ConfigValidationResult } from './validate.js';
|
||||||
export { assertValidConfig, ConfigStartupError, MissingRequiredSettingError } from './startup.js';
|
export { assertValidConfig, ConfigStartupError, MissingRequiredSettingError } from './startup.js';
|
||||||
|
export { REDACTED, SECRET_FIELD_NAMES, redactConfig, redactText } from './redact.js';
|
||||||
|
|||||||
@@ -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,7 +2,8 @@
|
|||||||
"extends": "../../tsconfig.base.json",
|
"extends": "../../tsconfig.base.json",
|
||||||
"compilerOptions": {
|
"compilerOptions": {
|
||||||
"rootDir": "src",
|
"rootDir": "src",
|
||||||
"outDir": "dist"
|
"outDir": "dist",
|
||||||
|
"types": ["node"]
|
||||||
},
|
},
|
||||||
"include": ["src"]
|
"include": ["src"]
|
||||||
}
|
}
|
||||||
|
|||||||
Generated
+4
@@ -35,6 +35,10 @@ importers:
|
|||||||
ajv:
|
ajv:
|
||||||
specifier: 8.20.0
|
specifier: 8.20.0
|
||||||
version: 8.20.0
|
version: 8.20.0
|
||||||
|
devDependencies:
|
||||||
|
'@types/node':
|
||||||
|
specifier: 24.13.3
|
||||||
|
version: 24.13.3
|
||||||
|
|
||||||
packages/core: {}
|
packages/core: {}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user