[E00-S04-T03] Secrets automatically redact from logs #402
@@ -197,6 +197,35 @@ jobs:
|
|||||||
- name: Run config startup error test suite
|
- name: Run config startup error test suite
|
||||||
run: node --test tests/config-startup-error.test.mjs
|
run: node --test tests/config-startup-error.test.mjs
|
||||||
|
|
||||||
|
# E00-S04-T03: the static assertions of tests/config-log-redaction.test.mjs
|
||||||
|
# gate every PR — the suite locks in automatic secret redaction from logs
|
||||||
|
# (packages/config's redactConfig/redactText + the server's redacting
|
||||||
|
# logger: every log line is scrubbed of the config's secret values) with
|
||||||
|
# mutation probes, and the deterministic probes execute the issue's test
|
||||||
|
# plan ("log configuration and confirm secret values are redacted"):
|
||||||
|
# booting the committed server logs its resolved configuration with the
|
||||||
|
# secret values replaced by [REDACTED], and no secret value appears in the
|
||||||
|
# log output. The job installs the frozen workspace and builds the config
|
||||||
|
# and database-postgres packages because the probes boot the committed
|
||||||
|
# server which imports them.
|
||||||
|
config-log-redaction:
|
||||||
|
name: Secret redaction from logs (E00-S04-T03)
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- name: Install Node.js 24
|
||||||
|
uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: '24'
|
||||||
|
- name: Enable pnpm (corepack, pinned to 11.23.0 via packageManager)
|
||||||
|
run: corepack enable
|
||||||
|
- name: Install dependencies (frozen lockfile)
|
||||||
|
run: pnpm install --frozen-lockfile
|
||||||
|
- name: Build the config and database-postgres packages (the probes boot the committed server which imports them)
|
||||||
|
run: pnpm --filter @personal-blog/config build && pnpm --filter @personal-blog/database-postgres build
|
||||||
|
- name: Run config log redaction test suite
|
||||||
|
run: node --test tests/config-log-redaction.test.mjs
|
||||||
|
|
||||||
# E00-S04-T01: the static assertions of tests/config-schema.test.mjs gate
|
# E00-S04-T01: the static assertions of tests/config-schema.test.mjs gate
|
||||||
# every PR — the suite locks in the TypeBox/Ajv configuration schema
|
# every PR — the suite locks in the TypeBox/Ajv configuration schema
|
||||||
# (packages/config, golden-tuple pins @sinclair/typebox@0.34.52 +
|
# (packages/config, golden-tuple pins @sinclair/typebox@0.34.52 +
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -15,9 +15,9 @@ The workspace is a pnpm monorepo with three package groups:
|
|||||||
|
|
||||||
| Group | Path | Purpose |
|
| Group | Path | Purpose |
|
||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
| `apps/` | `apps/server` (`@personal-blog/server`) | Public server application. Serves the application health endpoint (E00-S02-T03) gated on the startup migration run (E00-S03-T06), and fails fast at startup with a field-specific error when a required setting is missing (E00-S04-T02); the Fastify 5 application shell lands in a later story. |
|
| `apps/` | `apps/server` (`@personal-blog/server`) | Public server application. Serves the application health endpoint (E00-S02-T03) gated on the startup migration run (E00-S03-T06), fails fast at startup with a field-specific error when a required setting is missing (E00-S04-T02), and redacts secret values from all log output (E00-S04-T03); the Fastify 5 application shell lands in a later story. |
|
||||||
| `packages/` | `packages/core` (`@personal-blog/core`) | Application core (site identity, content primitives). Bootstrap placeholder. |
|
| `packages/` | `packages/core` (`@personal-blog/core`) | Application core (site identity, content primitives). Bootstrap placeholder. |
|
||||||
| `packages/` | `packages/config` (`@personal-blog/config`) | Configuration service. Owns the TypeBox/Ajv configuration schema for the validated config fields (E00-S04-T01) and the field-specific startup error for a missing required setting (E00-S04-T02); the environment adapter (E00-S04-T04) and secret redaction (E00-S04-T03) land in later tasks. |
|
| `packages/` | `packages/config` (`@personal-blog/config`) | Configuration service. Owns the TypeBox/Ajv configuration schema for the validated config fields (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. |
|
||||||
| `packages/` | `packages/database-postgres` (`@personal-blog/database-postgres`) | PostgreSQL database adapter package. Single owner of the `pg`/Kysely driver imports (E00-S03-T02); the migration ledger (`schema_migrations`, E00-S03-T03), the migration advisory lock (E00-S03-T04) and the migration runner with its failure diagnostic (E00-S03-T05) are implemented here. The server depends on this package to run the startup migrations behind its readiness gate (E00-S03-T06). |
|
| `packages/` | `packages/database-postgres` (`@personal-blog/database-postgres`) | PostgreSQL database adapter package. Single owner of the `pg`/Kysely driver imports (E00-S03-T02); the migration ledger (`schema_migrations`, E00-S03-T03), the migration advisory lock (E00-S03-T04) and the migration runner with its failure diagnostic (E00-S03-T05) are implemented here. The server depends on this package to run the startup migrations behind its readiness gate (E00-S03-T06). |
|
||||||
| `extensions/` | `extensions/example` (`@personal-blog/example-extension`) | Example extension exercising the `extensions/` group. Bootstrap placeholder. |
|
| `extensions/` | `extensions/example` (`@personal-blog/example-extension`) | Example extension exercising the `extensions/` group. Bootstrap placeholder. |
|
||||||
|
|
||||||
@@ -113,6 +113,12 @@ compiled application entrypoint. Three things to know:
|
|||||||
answers 200 `{"status":"ok"}` from the start. The real Fastify 5
|
answers 200 `{"status":"ok"}` from the start. The real Fastify 5
|
||||||
application shell — which turns this into the full serving API — lands in
|
application shell — which turns this into the full serving API — lands in
|
||||||
a later story; the `start` command shape stays the same once it does.
|
a later story; the `start` command shape stays the same once it does.
|
||||||
|
4. Since [E00-S04-T03], the server **logs its resolved configuration at
|
||||||
|
startup with secret values redacted**: the first log line is
|
||||||
|
`[config] resolved configuration: {"host":"0.0.0.0","port":3000, ...,
|
||||||
|
"sessionSecret":"[REDACTED]"}`, and every log line passes through the
|
||||||
|
redacting logger — the admin-session secret and the password embedded in a
|
||||||
|
`DATABASE_URL` connection string never appear in the log output.
|
||||||
|
|
||||||
To run the compiled output of any other workspace package directly:
|
To run the compiled output of any other workspace package directly:
|
||||||
|
|
||||||
|
|||||||
@@ -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 (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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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: {}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,646 @@
|
|||||||
|
/**
|
||||||
|
* Config log redaction test — locks in the [E00-S04-T03] guarantee that
|
||||||
|
* secrets automatically redact from logs: the app's log output contains no
|
||||||
|
* secret values.
|
||||||
|
*
|
||||||
|
* Acceptance criteria covered (each test fails without the committed state):
|
||||||
|
* - "secrets automatically redact from logs" → `packages/config` 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), and the committed server writes ALL of its log output through
|
||||||
|
* the redacting logger (`createLogger` in the server entrypoint, seeded
|
||||||
|
* with the validated config). Locked in statically (mutation probes prove
|
||||||
|
* non-vacuity: renaming the exports, dropping the split/join scrub,
|
||||||
|
* unmasking the databaseUrl password, or reintroducing a bare
|
||||||
|
* `console.log`/`console.error` all fail) and behaviorally by the
|
||||||
|
* deterministic probes.
|
||||||
|
* - "log output contains no secret values" → the deterministic probes
|
||||||
|
* execute the issue's test plan ("log configuration and confirm secret
|
||||||
|
* values are redacted") against the committed code: the compiled
|
||||||
|
* `@personal-blog/config` boundary redacts the admin-session secret and
|
||||||
|
* the password embedded in a `DATABASE_URL` connection string, and
|
||||||
|
* booting the committed server logs its resolved configuration with
|
||||||
|
* every secret value replaced by `[REDACTED]` — the booted server's
|
||||||
|
* stdout/stderr contain no secret value.
|
||||||
|
*
|
||||||
|
* Run: `node --test tests/config-log-redaction.test.mjs`
|
||||||
|
* (node:test — built into Node >= 18; no dependencies, lockfile untouched.
|
||||||
|
* The deterministic probes boot the committed server, which imports
|
||||||
|
* `@personal-blog/config` and `@personal-blog/database-postgres` — build those
|
||||||
|
* packages first, exactly as the CI job does.)
|
||||||
|
*/
|
||||||
|
|
||||||
|
import test from 'node:test';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { readFileSync, existsSync, writeFileSync, rmSync } from 'node:fs';
|
||||||
|
import { spawn, spawnSync } from 'node:child_process';
|
||||||
|
import { once } from 'node:events';
|
||||||
|
import { createServer as createNetServer } from 'node:net';
|
||||||
|
import path from 'node:path';
|
||||||
|
import { fileURLToPath } from 'node:url';
|
||||||
|
|
||||||
|
const REPO_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
||||||
|
|
||||||
|
const read = (relPath) => readFileSync(path.join(REPO_ROOT, relPath), 'utf8');
|
||||||
|
|
||||||
|
/** The committed redaction layer, package boundary and server entrypoint under test. */
|
||||||
|
const REDACT_SRC = 'packages/config/src/redact.ts';
|
||||||
|
const INDEX_SRC = 'packages/config/src/index.ts';
|
||||||
|
const SERVER_SRC = 'apps/server/src/index.ts';
|
||||||
|
|
||||||
|
/** The root test glob (root `scripts.test`, E00-S01-T12) that runs every suite. */
|
||||||
|
const ROOT_TEST_GLOB = 'tests/**/*.test.mjs';
|
||||||
|
|
||||||
|
/** The CI job that gates the log-redaction criterion on every PR. */
|
||||||
|
const CI_JOB = 'config-log-redaction';
|
||||||
|
|
||||||
|
/** A distinctive >= 32-char admin-session secret the probes must never leak. */
|
||||||
|
const SECRET = 'redact-me-0123456789abcdefghijklmnopqrstuv';
|
||||||
|
|
||||||
|
/** A connection string whose password the probes must never leak. */
|
||||||
|
const DATABASE_URL = 'postgres://redact-user:redact-password@db:5432/redact-db';
|
||||||
|
|
||||||
|
const delay = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Static assertions on the committed sources
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Asserts the config package exposes the redaction layer: `redactConfig`
|
||||||
|
* (a config value with every secret replaced by `[REDACTED]` — the secret
|
||||||
|
* fields by name and the `databaseUrl` password masked in place) and
|
||||||
|
* `redactText` (free-form log text scrubbed of the config's secret values).
|
||||||
|
* Fails fast on a deviation; the mutation probes below prove the assertions
|
||||||
|
* are non-vacuous.
|
||||||
|
*/
|
||||||
|
function assertRedactionSource(src) {
|
||||||
|
// The placeholder and the schema-derived secret field names.
|
||||||
|
assert.match(
|
||||||
|
src,
|
||||||
|
/export const REDACTED = '\[REDACTED\]'/,
|
||||||
|
'the redaction module must export the [REDACTED] placeholder',
|
||||||
|
);
|
||||||
|
assert.match(
|
||||||
|
src,
|
||||||
|
/export const SECRET_FIELD_NAMES/,
|
||||||
|
'the redaction module must export the secret field names (SECRET_FIELD_NAMES)',
|
||||||
|
);
|
||||||
|
assert.match(
|
||||||
|
src,
|
||||||
|
/SECRET_FIELD_NAMES: readonly string\[\] = \['sessionSecret'\]/,
|
||||||
|
"the secret field names must name the schema's secret field (sessionSecret, E00-S04-T01)",
|
||||||
|
);
|
||||||
|
|
||||||
|
// redactConfig — a config copy with every secret replaced by the placeholder.
|
||||||
|
assert.match(
|
||||||
|
src,
|
||||||
|
/export function redactConfig\(config: Config\): Config/,
|
||||||
|
'the redaction module must export redactConfig (a redacted copy of a config value, for logging the resolved configuration)',
|
||||||
|
);
|
||||||
|
assert.match(
|
||||||
|
src,
|
||||||
|
/SECRET_FIELD_NAMES\.includes\(key\)/,
|
||||||
|
'redactConfig must replace the secret fields by name (SECRET_FIELD_NAMES)',
|
||||||
|
);
|
||||||
|
assert.match(
|
||||||
|
src,
|
||||||
|
/redacted\[key\] = REDACTED/,
|
||||||
|
'redactConfig must replace secret field values with the [REDACTED] placeholder',
|
||||||
|
);
|
||||||
|
assert.match(
|
||||||
|
src,
|
||||||
|
/redactDatabaseUrl\(value\)/,
|
||||||
|
'redactConfig must mask the databaseUrl password (redactDatabaseUrl)',
|
||||||
|
);
|
||||||
|
assert.match(
|
||||||
|
src,
|
||||||
|
/:\$\{REDACTED\}@/,
|
||||||
|
'redactDatabaseUrl must mask the password in place (scheme://user:[REDACTED]@host)',
|
||||||
|
);
|
||||||
|
|
||||||
|
// redactText — free-form log text scrubbed of the config's secret values.
|
||||||
|
assert.match(
|
||||||
|
src,
|
||||||
|
/export function redactText\(text: string, config: Config\): string/,
|
||||||
|
'the redaction module must export redactText (scrub free-form log text of the config\'s secret values)',
|
||||||
|
);
|
||||||
|
assert.match(
|
||||||
|
src,
|
||||||
|
/\.split\(value\)\.join\(REDACTED\)/,
|
||||||
|
'redactText must replace every occurrence of a secret value with the [REDACTED] placeholder',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Asserts the package boundary re-exports the redaction layer.
|
||||||
|
*/
|
||||||
|
function assertBoundary(src) {
|
||||||
|
assert.match(
|
||||||
|
src,
|
||||||
|
/export \{ REDACTED, SECRET_FIELD_NAMES, redactConfig, redactText \} from '\.\/redact\.js'/,
|
||||||
|
'the package boundary must re-export the redaction layer (REDACTED, SECRET_FIELD_NAMES, redactConfig, redactText)',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Asserts the committed server logs through a redacting logger only: it
|
||||||
|
* imports the redaction entry points from `@personal-blog/config`, defines
|
||||||
|
* `createLogger(config)` which scrubs every joined log line with the
|
||||||
|
* validated config's secret values before writing it to stdout/stderr, and
|
||||||
|
* logs its resolved configuration at startup via `redactConfig` (the issue's
|
||||||
|
* test plan: "log configuration and confirm secret values are redacted"). A
|
||||||
|
* bare `console.log`/`console.error` would bypass the redaction and is
|
||||||
|
* rejected.
|
||||||
|
*/
|
||||||
|
function assertServerSource(src) {
|
||||||
|
// The redacting logger — every log line passes through the config
|
||||||
|
// package's scrubber before it reaches stdout/stderr.
|
||||||
|
assert.match(
|
||||||
|
src,
|
||||||
|
/import \{ assertValidConfig \} from '@personal-blog\/config'/,
|
||||||
|
'the server must import the startup validation entry point from the config package',
|
||||||
|
);
|
||||||
|
assert.match(
|
||||||
|
src,
|
||||||
|
/import \{ redactConfig, redactText, type Config \} from '@personal-blog\/config'/,
|
||||||
|
'the server must import the redaction entry points (redactConfig, redactText) and the Config type from the config package',
|
||||||
|
);
|
||||||
|
assert.match(
|
||||||
|
src,
|
||||||
|
/function createLogger\(config: Config\): ServerLogger/,
|
||||||
|
'the server must define the redacting logger (createLogger, seeded with the validated configuration)',
|
||||||
|
);
|
||||||
|
assert.match(
|
||||||
|
src,
|
||||||
|
/redactText\(args\.map\(serialize\)\.join\(' '\), config\)/,
|
||||||
|
'every log line must pass through redactText (the config package\'s scrubber) before it is written',
|
||||||
|
);
|
||||||
|
assert.match(
|
||||||
|
src,
|
||||||
|
/write\(process\.stdout, args\)/,
|
||||||
|
'log lines must be written to stdout',
|
||||||
|
);
|
||||||
|
assert.match(
|
||||||
|
src,
|
||||||
|
/write\(process\.stderr, args\)/,
|
||||||
|
'error lines must be written to stderr',
|
||||||
|
);
|
||||||
|
|
||||||
|
// The wiring — the server keeps its validated config, creates the logger
|
||||||
|
// with it and logs the resolved configuration redacted.
|
||||||
|
assert.match(
|
||||||
|
src,
|
||||||
|
/const config = assertValidConfig\(\{/,
|
||||||
|
'the server must keep its validated configuration (const config = assertValidConfig(...))',
|
||||||
|
);
|
||||||
|
assert.match(
|
||||||
|
src,
|
||||||
|
/const logger = createLogger\(config\)/,
|
||||||
|
'the server must create the redacting logger seeded with its validated configuration',
|
||||||
|
);
|
||||||
|
assert.match(
|
||||||
|
src,
|
||||||
|
/resolved configuration/,
|
||||||
|
'the server must log its resolved configuration at startup (the issue\'s test plan: "log configuration and confirm secret values are redacted")',
|
||||||
|
);
|
||||||
|
assert.match(
|
||||||
|
src,
|
||||||
|
/redactConfig\(config\)/,
|
||||||
|
'the configuration log must be redacted (redactConfig) so secret values never reach the log output',
|
||||||
|
);
|
||||||
|
assert.doesNotMatch(
|
||||||
|
src,
|
||||||
|
/console\.(log|error)\(/,
|
||||||
|
'the server must not write log output with bare console.log/console.error (they would bypass the redaction)',
|
||||||
|
);
|
||||||
|
// The startup validation runs before the logger is created, so a missing
|
||||||
|
// required setting still fails fast (E00-S04-T02) before any log output.
|
||||||
|
const validationIndex = src.indexOf('assertValidConfig({');
|
||||||
|
const loggerIndex = src.indexOf('createLogger(config)');
|
||||||
|
assert.ok(
|
||||||
|
validationIndex !== -1 && loggerIndex !== -1 && validationIndex < loggerIndex,
|
||||||
|
'the startup validation must run before the logger is created (a missing required setting is still a startup error)',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Criterion tests
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
test('the config package exposes the secret redaction layer (redactConfig + redactText)', () => {
|
||||||
|
assert.ok(existsSync(path.join(REPO_ROOT, REDACT_SRC)), `committed ${REDACT_SRC} must exist`);
|
||||||
|
assertRedactionSource(read(REDACT_SRC));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('the package boundary re-exports the redaction layer', () => {
|
||||||
|
assertBoundary(read(INDEX_SRC));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('the server logs through the redacting logger and logs its resolved configuration redacted', () => {
|
||||||
|
assertServerSource(read(SERVER_SRC));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('the config-log-redaction criterion is enforced in CI', () => {
|
||||||
|
// Picked up by the root test command (root `scripts.test` glob).
|
||||||
|
const scripts = JSON.parse(read('package.json')).scripts ?? {};
|
||||||
|
assert.equal(
|
||||||
|
scripts.test,
|
||||||
|
`node --test "${ROOT_TEST_GLOB}"`,
|
||||||
|
`root scripts.test must run the "${ROOT_TEST_GLOB}" glob so this suite runs with the rest`,
|
||||||
|
);
|
||||||
|
// And a dedicated CI job gates it on every PR.
|
||||||
|
const workflow = read('.gitea/workflows/ci.yml');
|
||||||
|
assert.ok(
|
||||||
|
workflow.includes(`node --test tests/config-log-redaction.test.mjs`),
|
||||||
|
`CI must run the config-log-redaction suite (job "${CI_JOB}") on every PR`,
|
||||||
|
);
|
||||||
|
assert.ok(
|
||||||
|
workflow.includes(
|
||||||
|
'pnpm --filter @personal-blog/config build && pnpm --filter @personal-blog/database-postgres build',
|
||||||
|
),
|
||||||
|
'the CI job must build the config and database-postgres packages (the probes boot the committed server which imports them)',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Mutation probes — the static assertions are non-vacuous
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
test('renaming the redactText export fails the redaction assertion (mutation probe)', () => {
|
||||||
|
const src = read(REDACT_SRC);
|
||||||
|
const renamed = src.replace('export function redactText(', 'export function redactTextX(');
|
||||||
|
assert.notEqual(renamed, src, 'the mutation must actually rename the redactText export');
|
||||||
|
assert.throws(() => assertRedactionSource(renamed), /must export redactText/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('changing the [REDACTED] placeholder fails the redaction assertion (mutation probe)', () => {
|
||||||
|
const src = read(REDACT_SRC);
|
||||||
|
const noPlaceholder = src.replace("export const REDACTED = '[REDACTED]';", "export const REDACTED = '***';");
|
||||||
|
assert.notEqual(noPlaceholder, src, 'the mutation must actually change the placeholder');
|
||||||
|
assert.throws(() => assertRedactionSource(noPlaceholder), /\[REDACTED\] placeholder/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('replacing every occurrence instead of scrubbing the whole value fails the redaction assertion (mutation probe)', () => {
|
||||||
|
const src = read(REDACT_SRC);
|
||||||
|
const partial = src.replace('.split(value).join(REDACTED)', '.replace(value, REDACTED)');
|
||||||
|
assert.notEqual(partial, src, 'the mutation must actually change the scrubbing');
|
||||||
|
assert.throws(() => assertRedactionSource(partial), /every occurrence/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('unmasking the databaseUrl password fails the redaction assertion (mutation probe)', () => {
|
||||||
|
const src = read(REDACT_SRC);
|
||||||
|
const unmasked = src.replace('redactDatabaseUrl(value)', 'value');
|
||||||
|
assert.notEqual(unmasked, src, 'the mutation must actually drop the databaseUrl password masking');
|
||||||
|
assert.throws(() => assertRedactionSource(unmasked), /must mask the databaseUrl password/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('dropping a redaction export from the package boundary fails the boundary assertion (mutation probe)', () => {
|
||||||
|
const src = read(INDEX_SRC);
|
||||||
|
const dropped = src.replace('REDACTED, SECRET_FIELD_NAMES, redactConfig, redactText', 'REDACTED, SECRET_FIELD_NAMES, redactConfig');
|
||||||
|
assert.notEqual(dropped, src, 'the mutation must actually drop the redactText export');
|
||||||
|
assert.throws(() => assertBoundary(dropped), /must re-export the redaction layer/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('renaming createLogger fails the logger assertion (mutation probe)', () => {
|
||||||
|
const src = read(SERVER_SRC);
|
||||||
|
const renamed = src.replace('function createLogger(', 'function createLoggerX(');
|
||||||
|
assert.notEqual(renamed, src, 'the mutation must actually rename the createLogger function');
|
||||||
|
assert.throws(() => assertServerSource(renamed), /must define the redacting logger/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('writing a log line without redacting it fails the logger assertion (mutation probe)', () => {
|
||||||
|
const src = read(SERVER_SRC);
|
||||||
|
const unredacted = src.replace("redactText(args.map(serialize).join(' '), config)", "args.map(serialize).join(' ')");
|
||||||
|
assert.notEqual(unredacted, src, 'the mutation must actually drop the redactText pass-through');
|
||||||
|
assert.throws(() => assertServerSource(unredacted), /must pass through redactText/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('reintroducing a bare console.log/console.error in the server fails the wiring assertion (mutation probe)', () => {
|
||||||
|
const src = read(SERVER_SRC);
|
||||||
|
const bareConsole = src.replaceAll('logger.log(', 'console.log(').replaceAll('logger.error(', 'console.error(');
|
||||||
|
assert.notEqual(bareConsole, src, 'the mutation must actually replace the logger calls with bare console calls');
|
||||||
|
assert.throws(() => assertServerSource(bareConsole), /console\.(log|error)/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('dropping the resolved-configuration log fails the wiring assertion (mutation probe)', () => {
|
||||||
|
const src = read(SERVER_SRC);
|
||||||
|
const noConfigLog = src.replace(
|
||||||
|
"logger.log('[config] resolved configuration:', JSON.stringify(redactConfig(config)));",
|
||||||
|
'',
|
||||||
|
);
|
||||||
|
assert.notEqual(noConfigLog, src, 'the mutation must actually drop the resolved-configuration log');
|
||||||
|
assert.throws(() => assertServerSource(noConfigLog), /resolved configuration/);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Deterministic behavioral probe — the compiled @personal-blog/config boundary
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* How the current Node executes TypeScript sources: `default` (>= 23.6, type
|
||||||
|
* stripping on by default), `strip-types-flag` (>= 22.6 via
|
||||||
|
* `--experimental-strip-types`) or `null` (cannot run .ts at all). The
|
||||||
|
* workspace pins engines.node to 24.x, where type stripping is stable.
|
||||||
|
*/
|
||||||
|
function tsExecMode() {
|
||||||
|
const [major, minor] = process.versions.node.split('.').map(Number);
|
||||||
|
if (major > 23 || (major === 23 && minor >= 6)) return 'default';
|
||||||
|
if (major === 22 && minor >= 6) return 'strip-types-flag';
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** True when this Node can execute the committed `.ts` server source (>= 22.6, type stripping). */
|
||||||
|
const TS_STRIPPING = tsExecMode() !== null;
|
||||||
|
|
||||||
|
/** The compiled config package boundary the probes import (built by the CI job first). */
|
||||||
|
const CONFIG_DIST = existsSync(path.join(REPO_ROOT, 'packages/config', 'dist', 'index.js'));
|
||||||
|
/** The compiled database-postgres package the booted server also imports. */
|
||||||
|
const DATABASE_POSTGRES_DIST = existsSync(
|
||||||
|
path.join(REPO_ROOT, 'packages/database-postgres', 'dist', 'index.js'),
|
||||||
|
);
|
||||||
|
|
||||||
|
/** Why the boot probes may be skipped on a clean clone without a build step. */
|
||||||
|
const BUILD_HINT =
|
||||||
|
'build the config and database-postgres packages first (pnpm --filter @personal-blog/config build && pnpm --filter @personal-blog/database-postgres build)';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The boundary probe source: exercises the compiled `@personal-blog/config`
|
||||||
|
* redaction boundary (`redactConfig` + `redactText`) exactly as the server's
|
||||||
|
* logger consumes it — the admin-session secret is replaced by `[REDACTED]`,
|
||||||
|
* the `databaseUrl` password is masked in place (and an unparseable
|
||||||
|
* `databaseUrl` is replaced wholesale), free-form text is scrubbed of the
|
||||||
|
* secret values, and non-secret text passes through unchanged. Written to a
|
||||||
|
* temp file inside `packages/config/` so `ajv`/`@sinclair/typebox` resolve
|
||||||
|
* through the package's own dependency links, then removed.
|
||||||
|
*/
|
||||||
|
const PROBE_SOURCE = `
|
||||||
|
import { REDACTED, redactConfig, redactText } from './dist/index.js';
|
||||||
|
|
||||||
|
const SECRET = '${SECRET}';
|
||||||
|
const URL = '${DATABASE_URL}';
|
||||||
|
|
||||||
|
const result = {
|
||||||
|
placeholder: REDACTED,
|
||||||
|
redactedSecret: redactConfig({ sessionSecret: SECRET }).sessionSecret,
|
||||||
|
maskedUrl: redactConfig({ sessionSecret: SECRET, databaseUrl: URL }).databaseUrl,
|
||||||
|
unparseableUrl: redactConfig({ sessionSecret: SECRET, databaseUrl: 'not a url' }).databaseUrl,
|
||||||
|
nonSecretKept: redactConfig({ sessionSecret: SECRET, host: '0.0.0.0', port: 3000 }),
|
||||||
|
scrubText: redactText('connecting with ' + SECRET + ' now', { sessionSecret: SECRET }),
|
||||||
|
scrubUrl: redactText('failed at ' + URL, { sessionSecret: SECRET, databaseUrl: URL }),
|
||||||
|
untouched: redactText('no secrets here', { sessionSecret: SECRET }),
|
||||||
|
};
|
||||||
|
|
||||||
|
console.log('CONFIG_REDACTION_PROBE_RESULT ' + JSON.stringify(result));
|
||||||
|
`;
|
||||||
|
|
||||||
|
test('the compiled boundary redacts secret values from config and text (deterministic probe)', { skip: !CONFIG_DIST ? BUILD_HINT : false }, () => {
|
||||||
|
const probeFile = path.join(REPO_ROOT, 'packages/config', `.config-redaction-probe-${process.pid}.mjs`);
|
||||||
|
try {
|
||||||
|
writeFileSync(probeFile, PROBE_SOURCE);
|
||||||
|
const run = spawnSync(process.execPath, [path.basename(probeFile)], {
|
||||||
|
cwd: path.join(REPO_ROOT, 'packages/config'),
|
||||||
|
encoding: 'utf8',
|
||||||
|
timeout: 60_000,
|
||||||
|
});
|
||||||
|
assert.equal(
|
||||||
|
run.status,
|
||||||
|
0,
|
||||||
|
`the probe must exit 0 (status ${run.status}):\n${(run.stderr || run.stdout || '').trim()}`,
|
||||||
|
);
|
||||||
|
const match = run.stdout.match(/CONFIG_REDACTION_PROBE_RESULT (\{.*\})/);
|
||||||
|
assert.ok(match, `the probe must print CONFIG_REDACTION_PROBE_RESULT:\n${run.stdout.trim()}`);
|
||||||
|
const result = JSON.parse(match[1]);
|
||||||
|
|
||||||
|
// The placeholder and the redacted admin-session secret.
|
||||||
|
assert.equal(result.placeholder, '[REDACTED]', 'REDACTED must be the [REDACTED] placeholder');
|
||||||
|
assert.equal(
|
||||||
|
result.redactedSecret,
|
||||||
|
'[REDACTED]',
|
||||||
|
'redactConfig must replace the admin-session secret with [REDACTED]',
|
||||||
|
);
|
||||||
|
|
||||||
|
// The databaseUrl password is masked in place; an unparseable databaseUrl
|
||||||
|
// is replaced wholesale (its password cannot be isolated).
|
||||||
|
assert.equal(
|
||||||
|
result.maskedUrl,
|
||||||
|
'postgres://redact-user:[REDACTED]@db:5432/redact-db',
|
||||||
|
`redactConfig must mask the databaseUrl password in place (got: ${JSON.stringify(result.maskedUrl)})`,
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
result.unparseableUrl,
|
||||||
|
'[REDACTED]',
|
||||||
|
'redactConfig must replace an unparseable databaseUrl wholesale (its password cannot be isolated)',
|
||||||
|
);
|
||||||
|
|
||||||
|
// Non-secret fields pass through unchanged.
|
||||||
|
assert.equal(result.nonSecretKept.host, '0.0.0.0', 'non-secret fields must pass through unchanged');
|
||||||
|
assert.equal(result.nonSecretKept.port, 3000, 'non-secret fields must pass through unchanged');
|
||||||
|
assert.equal(result.nonSecretKept.sessionSecret, '[REDACTED]', 'the secret field must still be redacted');
|
||||||
|
|
||||||
|
// Free-form text is scrubbed of the config's secret values.
|
||||||
|
assert.equal(
|
||||||
|
result.scrubText,
|
||||||
|
'connecting with [REDACTED] now',
|
||||||
|
`redactText must scrub the admin-session secret from free text (got: ${JSON.stringify(result.scrubText)})`,
|
||||||
|
);
|
||||||
|
assert.ok(
|
||||||
|
!result.scrubText.includes(SECRET),
|
||||||
|
'redactText output must not contain the admin-session secret value',
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
result.scrubUrl,
|
||||||
|
'failed at postgres://redact-user:[REDACTED]@db:5432/redact-db',
|
||||||
|
`redactText must scrub the database password from free text (got: ${JSON.stringify(result.scrubUrl)})`,
|
||||||
|
);
|
||||||
|
assert.ok(
|
||||||
|
!result.scrubUrl.includes('redact-password'),
|
||||||
|
'redactText output must not contain the database password',
|
||||||
|
);
|
||||||
|
assert.equal(result.untouched, 'no secrets here', 'redactText must leave non-secret text unchanged');
|
||||||
|
} finally {
|
||||||
|
rmSync(probeFile, { force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Server-boot probes — the issue's test plan executed against the real
|
||||||
|
// committed server: "log configuration and confirm secret values are
|
||||||
|
// redacted"
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/** Reserves an ephemeral TCP port, then releases it for the child to bind. */
|
||||||
|
function reservePort() {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const probe = createNetServer();
|
||||||
|
probe.once('error', reject);
|
||||||
|
probe.listen(0, '127.0.0.1', () => {
|
||||||
|
const address = probe.address();
|
||||||
|
const port = typeof address === 'object' && address !== null ? address.port : 0;
|
||||||
|
probe.close(() => resolve(port));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Boots the committed server source on `port` with the given env overrides.
|
||||||
|
* An inherited `DATABASE_URL` (the no-database path must be deterministic)
|
||||||
|
* and `EPPP_SESSION_SECRET` (the secret must be deterministic) are stripped
|
||||||
|
* unless explicitly provided. Returns `{ child, stdout, stderr }` with
|
||||||
|
* closures for the captured output.
|
||||||
|
*/
|
||||||
|
function bootServer(port, envOverrides = {}) {
|
||||||
|
const args =
|
||||||
|
tsExecMode() === 'strip-types-flag'
|
||||||
|
? ['--experimental-strip-types', SERVER_SRC]
|
||||||
|
: [SERVER_SRC];
|
||||||
|
const env = { ...process.env, PORT: String(port) };
|
||||||
|
delete env.DATABASE_URL;
|
||||||
|
delete env.EPPP_SESSION_SECRET;
|
||||||
|
Object.assign(env, envOverrides);
|
||||||
|
const child = spawn(process.execPath, args, {
|
||||||
|
cwd: REPO_ROOT,
|
||||||
|
env,
|
||||||
|
stdio: ['ignore', 'pipe', 'pipe'],
|
||||||
|
});
|
||||||
|
let stdout = '';
|
||||||
|
let stderr = '';
|
||||||
|
child.stdout.on('data', (chunk) => {
|
||||||
|
stdout += String(chunk);
|
||||||
|
});
|
||||||
|
child.stderr.on('data', (chunk) => {
|
||||||
|
stderr += String(chunk);
|
||||||
|
});
|
||||||
|
return { child, stdout: () => stdout, stderr: () => stderr };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Polls `GET /health` until the server answers with ANY status, the child
|
||||||
|
* exits, or the deadline passes. Returns the fetch Response.
|
||||||
|
*/
|
||||||
|
async function waitForAnswer(port, child, stderr, deadlineMs = 10_000) {
|
||||||
|
const deadline = Date.now() + deadlineMs;
|
||||||
|
let lastError = '';
|
||||||
|
while (Date.now() < deadline) {
|
||||||
|
if (child.exitCode !== null) {
|
||||||
|
throw new Error(
|
||||||
|
`the server exited before answering GET /health (code ${child.exitCode}): ${stderr().trim()}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
return await fetch(`http://127.0.0.1:${port}/health`, {
|
||||||
|
signal: AbortSignal.timeout(1_000),
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
lastError = err instanceof Error ? err.message : String(err);
|
||||||
|
await delay(100);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
throw new Error(
|
||||||
|
`GET /health did not answer within ${deadlineMs}ms (last error: ${lastError}; server stderr: ${stderr().trim()})`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Waits until the captured log output matches `pattern` (or the deadline passes). */
|
||||||
|
async function waitForLog(readOutput, pattern, deadlineMs = 5_000) {
|
||||||
|
const deadline = Date.now() + deadlineMs;
|
||||||
|
while (Date.now() < deadline) {
|
||||||
|
if (pattern.test(readOutput())) return true;
|
||||||
|
await delay(100);
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Kills a booted child (SIGTERM, then SIGKILL if needed) and waits for exit. */
|
||||||
|
async function stopChild(child) {
|
||||||
|
if (child.exitCode !== null || child.signalCode !== null) return;
|
||||||
|
child.kill('SIGTERM');
|
||||||
|
await Promise.race([once(child, 'exit'), delay(2_000)]);
|
||||||
|
if (child.exitCode === null && child.signalCode === null) child.kill('SIGKILL');
|
||||||
|
}
|
||||||
|
|
||||||
|
test('booting the server logs the resolved configuration with secret values redacted (server boot probe)', { skip: !TS_STRIPPING || !CONFIG_DIST || !DATABASE_POSTGRES_DIST ? BUILD_HINT : false }, async () => {
|
||||||
|
// The issue's test plan: "log configuration and confirm secret values are
|
||||||
|
// redacted". The committed server logs its resolved configuration at
|
||||||
|
// startup through the redacting logger — the admin-session secret must
|
||||||
|
// appear as [REDACTED], and the secret value must not appear in the log
|
||||||
|
// output at all.
|
||||||
|
const port = await reservePort();
|
||||||
|
const { child, stdout, stderr } = bootServer(port, { EPPP_SESSION_SECRET: SECRET }); // DATABASE_URL stripped
|
||||||
|
try {
|
||||||
|
const response = await waitForAnswer(port, child, stderr);
|
||||||
|
assert.equal(
|
||||||
|
response.status,
|
||||||
|
200,
|
||||||
|
`the server must boot to GET /health 200 (got ${response.status}); server output: ${stdout().trim()} ${stderr().trim()}`,
|
||||||
|
);
|
||||||
|
// The resolved configuration is logged, with the secret redacted.
|
||||||
|
assert.match(
|
||||||
|
stdout(),
|
||||||
|
/\[config\] resolved configuration:/,
|
||||||
|
`the server must log its resolved configuration at startup (got: ${stdout().trim()})`,
|
||||||
|
);
|
||||||
|
assert.match(
|
||||||
|
stdout(),
|
||||||
|
/"sessionSecret":"\[REDACTED\]"/,
|
||||||
|
`the resolved-configuration log must show the admin-session secret redacted (got: ${stdout().trim()})`,
|
||||||
|
);
|
||||||
|
// No secret value in the log output (the acceptance criterion).
|
||||||
|
assert.ok(
|
||||||
|
!(stdout() + stderr()).includes(SECRET),
|
||||||
|
`the log output must not contain the admin-session secret value (got: ${stdout().trim()} ${stderr().trim()})`,
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
await stopChild(child);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('the log output contains no database password when a DATABASE_URL is configured (server boot probe)', { skip: !TS_STRIPPING || !CONFIG_DIST || !DATABASE_POSTGRES_DIST ? BUILD_HINT : false }, async () => {
|
||||||
|
// With a DATABASE_URL whose password must never leak, the startup
|
||||||
|
// migration run cannot complete (nothing listens on the dead port), so the
|
||||||
|
// app stays not-ready — and the log output (the resolved-configuration log
|
||||||
|
// AND the migration-failure log) must contain the masked URL, never the
|
||||||
|
// password and never the raw connection string.
|
||||||
|
const port = await reservePort();
|
||||||
|
const deadPort = await reservePort(); // reserved then released: nothing listens
|
||||||
|
const databaseUrl = `postgres://redact-user:redact-password@127.0.0.1:${deadPort}/redact-db`;
|
||||||
|
const { child, stdout, stderr } = bootServer(port, {
|
||||||
|
EPPP_SESSION_SECRET: SECRET,
|
||||||
|
DATABASE_URL: databaseUrl,
|
||||||
|
});
|
||||||
|
try {
|
||||||
|
const response = await waitForAnswer(port, child, stderr);
|
||||||
|
assert.equal(
|
||||||
|
response.status,
|
||||||
|
503,
|
||||||
|
`the app must stay not-ready while the migration run cannot complete (got ${response.status}); server output: ${stdout().trim()} ${stderr().trim()}`,
|
||||||
|
);
|
||||||
|
// The resolved-configuration log masks the databaseUrl password in place.
|
||||||
|
assert.match(
|
||||||
|
stdout(),
|
||||||
|
new RegExp(`postgres://redact-user:${'\\[REDACTED\\]'}@127\\.0\\.0\\.1:`),
|
||||||
|
`the resolved-configuration log must show the databaseUrl password masked in place (got: ${stdout().trim()})`,
|
||||||
|
);
|
||||||
|
// Wait for the migration-failure log (also written through the redacting logger).
|
||||||
|
assert.ok(
|
||||||
|
await waitForLog(() => stdout() + stderr(), /startup migration run failed; app stays not-ready/),
|
||||||
|
`the app must log the failed startup migration run (got: ${stdout().trim()} ${stderr().trim()})`,
|
||||||
|
);
|
||||||
|
const output = stdout() + stderr();
|
||||||
|
assert.ok(
|
||||||
|
!output.includes('redact-password'),
|
||||||
|
`the log output must not contain the database password (got: ${output.trim()})`,
|
||||||
|
);
|
||||||
|
assert.ok(
|
||||||
|
!output.includes('postgres://redact-user:redact-password@'),
|
||||||
|
`the log output must not contain the raw connection string with its password (got: ${output.trim()})`,
|
||||||
|
);
|
||||||
|
assert.ok(
|
||||||
|
!output.includes(SECRET),
|
||||||
|
`the log output must not contain the admin-session secret value (got: ${output.trim()})`,
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
await stopChild(child);
|
||||||
|
}
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user