feat: secrets redact from logs via config redaction layer and server redacting logger (E00-S04-T03)
This commit is contained in:
@@ -28,12 +28,23 @@
|
||||
* these reads is E00-S04-T04 and lands later); `assertValidConfig` throws
|
||||
* `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
|
||||
* story; this bootstrap keeps the application health-checkable until then.
|
||||
*/
|
||||
|
||||
import { createServer, type IncomingMessage, type ServerResponse } from 'node:http';
|
||||
import { assertValidConfig } from '@personal-blog/config';
|
||||
import { redactConfig, redactText, type Config } from '@personal-blog/config';
|
||||
import { Pool } from '@personal-blog/database-postgres';
|
||||
import { MigrationLedger, MigrationRunner } 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.
|
||||
// EPPP_SESSION_SECRET) crashes the process at startup with an error naming
|
||||
// the missing field — never boots with an invalid configuration.
|
||||
assertValidConfig({
|
||||
const config = assertValidConfig({
|
||||
host: '0.0.0.0',
|
||||
port: PORT,
|
||||
databaseUrl: process.env.DATABASE_URL,
|
||||
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. */
|
||||
const HEALTH_PAYLOAD = JSON.stringify({ status: 'ok' });
|
||||
|
||||
@@ -98,6 +116,47 @@ function sendJson(res: ServerResponse, statusCode: number, body: string): void {
|
||||
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
|
||||
* 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
|
||||
// migrations to run, so the app reports ready from the start.
|
||||
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 {
|
||||
// E00-S03-T06: run the startup migrations; readiness follows completion.
|
||||
const pool = new Pool({ connectionString: databaseUrl });
|
||||
@@ -133,7 +192,7 @@ if (databaseUrl === undefined) {
|
||||
.run()
|
||||
.then((result) => {
|
||||
migrationsComplete = true;
|
||||
console.log(
|
||||
logger.log(
|
||||
`[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
|
||||
// logs the failure and stays not-ready, so a deployment with failed
|
||||
// migrations is surfaced by the readiness probe instead of
|
||||
// crash-looping.
|
||||
console.error('[migrate] startup migration run failed; app stays not-ready:', String(error));
|
||||
// crash-looping. The redacting logger scrubs any secret value (e.g.
|
||||
// the database password) the error text may embed.
|
||||
logger.error('[migrate] startup migration run failed; app stays not-ready:', error);
|
||||
});
|
||||
}
|
||||
|
||||
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
|
||||
|
||||
Reference in New Issue
Block a user