Files
HealthLog/src/lib/logging/event-builder.ts
T
Marc-André Bombeck e31c66e959 fix(security): scrub wide events at the builder, encrypt the replay cache
Three leaks of the same class - data leaving the process unprotected.

addMeta and addExternalCall wrote their values verbatim while setError
and setHttp scrubbed, so the builder's redaction contract held for two
entry points out of four. The finished event is serialised whole to
stdout and the log store, which made meta a trusted sink by accident: a
Telegram request timeout put the bot token there, and the AI provider
chain fed upstream error bodies through it. Both now scrub, at any depth.

Scrubbing cannot help with the second half. A gateway that echoes the
request it rejected echoes the prompt, and on that path the prompt
carries the health snapshot; no pattern separates that from any other
text. Upstream bodies are therefore classified rather than quoted - the
diagnostic question is which rejection occurred and whether it repeats,
which a classification answers without the content.

The idempotency replay cache stored response bodies unencrypted. The
create paths echo their own decrypted payload, so cycle notes, mood text
and allergy reactions sat in cleartext for 24 hours in a column that
lands in every backup; the existing guard missed it because it matches
secret shapes and health data is not secret-shaped. Bodies are encrypted
now, and when encryption is unavailable the response is not cached at
all rather than written in the clear.

chore(release): v1.30.18
2026-07-19 01:28:20 +02:00

222 lines
7.0 KiB
TypeScript

import { randomUUID } from "node:crypto";
import type { WideEvent, LogLevel, EventKind } from "./types";
import { LOG_LEVEL_PRIORITY } from "./types";
import { getDeployContext } from "./config";
import { redactOptional, redactSecrets } from "./redact";
/**
* Apply `redactSecrets` to every string a value contains, at any depth.
*
* The builder's contract is that nothing leaves through it unscrubbed. Strings
* nested inside an object or array are just as visible in the emitted JSON as a
* top-level one, so the walk has to reach them. Non-strings pass through
* untouched; the structure is rebuilt rather than mutated so a caller's object
* is never modified behind its back.
*
* The depth bound stops a cyclic or pathologically nested value from turning a
* log write into a stack overflow. Anything past it is dropped rather than
* emitted unscrubbed — an unreadable log line is recoverable, a leaked
* credential is not.
*/
function redactDeep<T>(value: T, depth = 0): T {
if (depth > 8) return "[redacted: too deep]" as unknown as T;
if (typeof value === "string") return redactSecrets(value) as unknown as T;
if (Array.isArray(value)) {
return value.map((v) => redactDeep(v, depth + 1)) as unknown as T;
}
if (value && typeof value === "object") {
const out: Record<string, unknown> = {};
for (const [k, v] of Object.entries(value as Record<string, unknown>)) {
out[k] = redactDeep(v, depth + 1);
}
return out as unknown as T;
}
return value;
}
/**
* Baut ein Wide Event Schritt fuer Schritt auf.
* Wird am Anfang eines Requests/Tasks erstellt und am Ende emittiert.
*/
export class WideEventBuilder {
private event: Partial<WideEvent>;
private startTime: number;
private highestLevel: LogLevel = "info";
constructor(kind: EventKind = "http") {
this.startTime = performance.now();
this.event = {
timestamp: new Date().toISOString(),
request_id: randomUUID(),
trace_id: randomUUID(),
kind,
service: "healthlog",
environment: process.env.NODE_ENV || "development",
deploy: getDeployContext(),
level: "info",
};
}
setRequestId(id: string): this {
this.event.request_id = id;
return this;
}
setTraceId(id: string): this {
this.event.trace_id = id;
return this;
}
setHttp(http: WideEvent["http"]): this {
if (http) {
// Fix-J (v1.4.25 W21): the Withings webhook ships its shared
// secret as a path segment, so `path` and `route` carry the
// secret unless we scrub on the way in. `redactSecrets` is the
// single egress-redaction surface (see redact.ts for the rules
// and the PATH_SECRET_PATHS registry).
this.event.http = {
...http,
path: redactSecrets(http.path),
route: redactSecrets(http.route),
};
} else {
this.event.http = http;
}
return this;
}
setAuth(auth: WideEvent["auth"]): this {
this.event.auth = auth;
return this;
}
setAction(action: WideEvent["action"]): this {
this.event.action = action;
return this;
}
setBackground(bg: WideEvent["background"]): this {
this.event.background = bg;
return this;
}
setError(err: unknown): this {
this.elevateLevel("error");
if (err instanceof Error) {
this.event.error = {
type: err.constructor.name,
message: redactSecrets(err.message),
stack: redactOptional(err.stack),
code: (err as { statusCode?: number }).statusCode,
};
} else {
this.event.error = {
type: "Unknown",
message: redactSecrets(String(err)),
};
}
return this;
}
addWarning(msg: string): this {
this.elevateLevel("warn");
if (!this.event.warnings) this.event.warnings = [];
this.event.warnings.push(msg);
return this;
}
addExternalCall(call: NonNullable<WideEvent["external_calls"]>[0]): this {
if (!this.event.external_calls) this.event.external_calls = [];
// An outbound failure carries whatever the remote said, and for several
// integrations that is the request URL — which for Telegram embeds the bot
// token. `setError` and `setHttp` scrub on the way in; this entry point did
// not, so a credential that is encrypted at rest reached stdout and the log
// store in plaintext. Scrub here too: the redaction contract is the
// builder's, not each caller's.
this.event.external_calls.push(redactDeep(call));
return this;
}
addDbQuery(durationMs: number): this {
if (!this.event.db)
this.event.db = { query_count: 0, query_duration_ms: 0 };
this.event.db.query_count++;
this.event.db.query_duration_ms += durationMs;
return this;
}
setRateLimit(info: WideEvent["rate_limit"]): this {
this.event.rate_limit = info;
return this;
}
addMeta(key: string, value: unknown): this {
if (!this.event.meta) this.event.meta = {};
// Meta is emitted verbatim: the finished event is JSON-stringified whole to
// stdout and the log store. It was the one builder entry point that did not
// scrub, which made it a trusted sink by accident rather than by design —
// and the AI provider chain feeds upstream error bodies through it, so a
// gateway that echoes the offending request put prompt content and
// non-standard credentials into the logs. Scrub every string, at any depth.
this.event.meta[key] = redactDeep(value);
return this;
}
/** Level nur hochstufen, nie runter */
elevateLevel(level: LogLevel): this {
if (LOG_LEVEL_PRIORITY[level] > LOG_LEVEL_PRIORITY[this.highestLevel]) {
this.highestLevel = level;
}
return this;
}
/** Request/Operation beenden, Duration berechnen */
finish(httpStatus?: number): this {
this.event.duration_ms = Math.round(performance.now() - this.startTime);
// Event-Loop-Lag des letzten Sampling-Fensters anheften, falls der
// Monitor laeuft. Bewusst ueber den Global-Slot statt Import gelesen:
// dieser Builder wird in die Edge-Runtime gebuendelt, wo perf_hooks
// nicht existiert — der Slot ist dort einfach leer.
const lag = (
globalThis as unknown as {
[key: symbol]: { loop_max_ms: number; loop_last_ms: number };
}
)[Symbol.for("healthlog.eventLoopLag")];
if (lag) this.event.runtime = lag;
if (httpStatus !== undefined && this.event.http) {
this.event.http.status = httpStatus;
}
if (httpStatus !== undefined) {
if (httpStatus >= 500) this.elevateLevel("error");
else if (httpStatus >= 400) this.elevateLevel("warn");
}
this.event.level = this.highestLevel;
return this;
}
toJSON(): WideEvent {
return structuredClone(this.event) as WideEvent;
}
getDurationMs(): number {
return Math.round(performance.now() - this.startTime);
}
getLevel(): LogLevel {
return this.highestLevel;
}
/** The HTTP method of the current request, if one was attached. */
getHttpMethod(): string | undefined {
return this.event.http?.method;
}
getRequestId(): string {
return this.event.request_id!;
}
getTraceId(): string {
return this.event.trace_id!;
}
}