[E00-S04-T03] Secrets automatically redact from logs #402
No Reviewers
Labels
Clear labels
agent/analyst-drafted
agent/analyst-drafted
needs/human-decision
needs/human-decision
needs/security-review
needs/security-review
tier/t0
tier/t1
tier/t2
tier/t3
kind
bug
kind
bug
kind
epic
kind
epic
kind
initiative
EPPP programme initiative
kind
story
kind
story
kind
task
EPPP engineering card/task decomposed from a story
kind
toil
kind
toil
loop
1
loop
1
loop
2
loop
2
loop
3
loop
3
risk
agent-full
risk
agent-full
risk
human-gated
risk
human-gated
risk
human-only
risk
human-only
size
l
size
l
size
m
size
m
size
s
size
s
status
blocked
status
blocked
status
done
Workflow: Done
status
in-progress
status
in-progress
status
proposed
status
proposed
status
ready
status
ready
status
review
status
review
stream
checkout
stream
checkout
stream
onboarding
stream
onboarding
stream
platform
stream
platform
trivial — implementer only, auto-merge
standard — implementer + reviewer + tester
complex — security if triggered, human merge
critical — full chain + security, human merge
No labels
Milestone
No items
No Milestone
Projects
Clear projects
No projects
No Assignees
Notifications
Due Date
No due date set.
Dependencies
No dependencies set.
Reference: Fabrika/PersonalBlog#402
Reference in New Issue
Block a user
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.
What changed
Implements [E00-S04-T03] Secrets automatically redact from logs (#184): every log line the app writes is scrubbed of the config's secret values before it reaches stdout/stderr, and the resolved configuration is logged at startup with the secret values replaced by
[REDACTED](the issue's test plan: "log configuration and confirm secret values are redacted").packages/config— the redaction layer (@personal-blog/config): newsrc/redact.tsowns which fields are secrets (the schema's secret fieldsessionSecret— Security-and-Operations §32/§26, plus the password embedded in adatabaseUrlconnection string) and exports:redactConfig(config): Config— a copy of a config value with every secret replaced by[REDACTED](secret fields by name; thedatabaseUrlpassword masked in place,postgres://user:[REDACTED]@host; an unparseabledatabaseUrlis replaced wholesale because its password cannot be isolated) — for logging the resolved configuration.redactText(text, config): string— scrubs every occurrence of the config's secret values from free-form log text (e.g. an error message carrying a connection string), so a secret value is redacted even when it was not redacted by field.REDACTED(the[REDACTED]placeholder) andSECRET_FIELD_NAMES(schema-derived, currentlysessionSecret). The package boundary re-exports the layer.packages/configgains@types/node(devDep, same 24.13.3 pin as the server) and"types": ["node"]becauseredact.tsuses WHATWGURL(node:url) for connection-string parsing.apps/server— the redacting logger: the entrypoint definescreateLogger(config)(inlined inapps/server/src/index.tsso the committed server source stays directly executable under Node type stripping, which cannot resolve relative.js→.tsimports), which serializes each argument and passes the joined line throughredactTextbefore writing to stdout/stderr. ALL server output goes through it (console.log/console.errorare gone — the suite rejects their reintroduction), and the server now logs its resolved configuration at startup:[config] resolved configuration: {"host":"0.0.0.0","port":…,"sessionSecret":"[REDACTED]",…}. The startup validation still runs before the logger is created, so the E00-S04-T02 field-specific startup error is unchanged..gitea/workflows/ci.yml: additiveconfig-log-redactionjob (Node 24, frozen install, builds config + database-postgres, runs the new suite) gating the criterion on every PR — matching the #396/#397 precedent.docs/development/non-container.md: the package table and the Run section document the redacted resolved-configuration log line.pnpm-lock.yaml:packages/configimporter gains the@types/nodedevDep (already resolved at 24.13.3 from the server importer — no new package entries);pnpm install --frozen-lockfilepasses.Explicitly out of scope per the brief, not touched: field-specific startup error (E00-S04-T02, merged), the
process.envaccess rule / environment adapter (E00-S04-T04 — the server still reads the env vars it needs directly, as before; the adapter that centralizes these reads lands with T04 and will feed the validated config into this same redaction layer),.env.exampleplaceholders (E00-S04-T05).Criterion → test table
tests/config-log-redaction.test.mjs— "the config package exposes the secret redaction layer (redactConfig + redactText)" (redact.ts exportsREDACTED, schema-derivedSECRET_FIELD_NAMES = ['sessionSecret'],redactConfig,redactText; thedatabaseUrlpassword is masked in place); "the package boundary re-exports the redaction layer"; "the server logs through the redacting logger and logs its resolved configuration redacted" (the server importsredactConfig/redactTextfrom@personal-blog/config, definescreateLogger(config), scrubs every line viaredactTextbeforeprocess.stdout/stderr.write, logs[config] resolved configuration:viaredactConfig(config), and contains no bareconsole.log/console.error); mutation probes — renamingredactText/createLogger, changing the[REDACTED]placeholder, replacing the whole-value split/join scrub with a partial replace, unmasking thedatabaseUrlpassword, dropping a boundary re-export, writing a line without theredactTextpass-through, reintroducing a bareconsole.log/console.error, and dropping the resolved-configuration log all fail; deterministic probes — the compiled@personal-blog/configboundary redacts the admin-session secret, masks the connection-string password, replaces an unparseable URL wholesale, scrubs free text, and leaves non-secret text unchanged; the server boot probe (the issue's test plan) boots the committed server and confirms stdout shows[config] resolved configuration:with"sessionSecret":"[REDACTED]"tests/config-log-redaction.test.mjs— server boot probes: booting the committed server with a distinctive ≥ 32-charEPPP_SESSION_SECRET(and noDATABASE_URL) boots toGET /health200 while no secret value appears in stdout/stderr; booting withDATABASE_URL=postgres://redact-user:redact-password@…(dead port → migration fails, app stays not-ready) logs the masked URL (postgres://redact-user:[REDACTED]@127.0.0.1:…), still logs the failure via the redacting logger, and neither the database password nor the raw connection string nor the admin-session secret appears anywhere in stdout/stderr; the compiled-boundary probe additionally asserts scrubbed output contains no secret valueconfig-log-redactionjobtests/config-log-redaction.test.mjs— "the config-log-redaction criterion is enforced in CI" (root test glob covers the suite;.gitea/workflows/ci.ymlrunsnode --test tests/config-log-redaction.test.mjsand builds@personal-blog/config+@personal-blog/database-postgresfirst);.gitea/workflows/ci.yml—config-log-redactionjob (additive, matching the security-reviewed #396/#397 precedent)Test plan executed
node --test tests/config-log-redaction.test.mjs→ 16 tests, 16 pass / 0 fail / 0 skip. The deterministic probes ran for real: the compiled boundary replaces the admin-session secret with[REDACTED], maskspostgres://redact-user:redact-password@db:5432/redact-db→postgres://redact-user:[REDACTED]@db:5432/redact-db, replaces an unparseable URL wholesale, and scrubs free text (connecting with <secret> now→connecting with [REDACTED] now); booting the committed server with the distinctive secret logs[config] resolved configuration:with"sessionSecret":"[REDACTED]"and no secret value in stdout/stderr; booting with a dead-portDATABASE_URLlogs the masked URL, stays not-ready (GET /health503), and neither the password nor the raw connection string appears in the log output.config-startup-error(17),config-schema(12),health-endpoint(7),secrets-not-embedded(static assertions incl. "the committed files copied into the image contain no default credential values" — the new files underapps/server//packages/config/contain no credential literals),architecture-import(10),no-core-extension-imports(2),workspace-layout(8),workspace-config(6),strict-tsconfig(5),typescript-pin(3),database-postgres-imports(2),app-readiness(18 pass + 1 docker-gated skip) — all green.node --test "tests/**/*.test.mjs"): 243 tests — 219 pass / 9 fail / 15 skip; the 9 failures are the pre-existing Node-22 environment artifacts identical to the base-commit baseline documented in #397/#396 (this sandbox has Node 22 — the workspace engines gate requires Node ≥ 24):tests/frozen-install.test.mjs×5 andtests/root-commands.test.mjs×3 fail onERR_PNPM_UNSUPPORTED_ENGINE,tests/node-engine.test.mjs×1 asserts the runtime is Node 24.x. CI runs Node 24 where these pass.pnpm install --frozen-lockfilepasses against the regeneratedpnpm-lock.yaml(packages/config importer gains@types/node@24.13.3— already resolved from the server importer, so no new package entries).packages/config(with the new redaction module),packages/database-postgresandapps/servercompile withtsc --noEmitexit 0 (strict base config, NodeNext, verbatimModuleSyntax, TypeScript 6.0.3). The Dockerfile needs no change: it already copiespackages/configandapps/server(source + compiled dist), and the inlined logger lives in the existing entrypoint.Risks / notes
sessionSecretas the secret field (schema-derived, Security-and-Operations §32/§26) and the password embedded in aDATABASE_URLconnection string (a credential; masked in place inredactConfigand scrubbed from free text byredactText). An unparseabledatabaseUrlis replaced wholesale (its password cannot be isolated) — never logged raw. Non-secret fields pass through unchanged so operators still see the effectivehost/port.[config] resolved configuration: …) — existing suites that boot the server assert with.match, not exact stdout, and are unaffected (verified). The migration-failure error is still logged with the same message vialogger.error(anErrorserializes to its message, as before). The startup-validation path (E00-S04-T02) is unchanged:assertValidConfigstill runs before the logger is created, so a missing required setting still fails fast withmissing required setting: sessionSecret(boot probes in the sibling suite still pass).apps/server/src/index.ts(rather than a separatelogger.ts) because the committed server source must stay directly executable under Node type stripping (node apps/server/src/index.ts), which cannot resolve a relative./logger.jsimport tologger.ts; a separate module would break the existing boot probes (health-endpoint, app-readiness, config-startup-error). The suite still rejects bareconsole.log/console.errorwith a mutation probe.config-log-redactionjob, no existing job removed/modified) and matches the security-reviewed #396/#397 precedent; per the review-checklist pipeline tripwire a human decision on the new merge gate may be requested.packages/config/src/redact.ts(+ boundary re-exports), the server'screateLogger/redactText/redactConfigwiring and the resolved-configuration log line, the@types/nodedevDep +"types": ["node"]inpackages/config, theconfig-log-redactionCI job and the test suite, and the docs note (no data migration involved).Refs #184
25248461e0toae900589c3