Sanitize realtime log metadata

This commit is contained in:
jjbrunton committed 2026-08-08 19:46:51 +01:00
1 parent 492efe0f8a
commit 3d015c4e80
3 files changed
+130 -2

No files matched your search

+93
View File
@@ -0,0 +1,93 @@
export type JsonPrimitive = string | number | boolean | null;
export type JsonSafeValue =
| JsonPrimitive
| JsonSafeValue[]
| { [key: string]: JsonSafeValue };
const REDACTED = '[Redacted]';
const CIRCULAR = '[Circular]';
const MAX_DEPTH = 6;
const MAX_COLLECTION_ENTRIES = 100;
function isSensitiveKey(key: string): boolean {
return /^(?:authorization|cookie|set-cookie|password|passwd|secret|token|access[_-]?token|refresh[_-]?token|api[_-]?key)$/i.test(key);
}
function constructorLabel(value: object): string {
const constructorName = value.constructor?.name;
return constructorName ? `[${constructorName}]` : '[Object]';
}
/**
* Convert arbitrary diagnostic metadata into a bounded, JSON-safe value.
* Errors intentionally expose only useful diagnostics; request/response and
* socket internals can be circular and may contain credentials.
*/
export function toJsonSafe(
value: unknown,
depth = 0,
ancestors = new WeakSet<object>(),
): JsonSafeValue | undefined {
if (value === undefined) return undefined;
if (value === null || typeof value === 'string' || typeof value === 'boolean') return value;
if (typeof value === 'number') return Number.isFinite(value) ? value : String(value);
if (typeof value === 'bigint') return value.toString();
if (typeof value === 'symbol') return value.description ? `[Symbol: ${value.description}]` : '[Symbol]';
if (typeof value === 'function') return `[Function: ${value.name || 'anonymous'}]`;
if (value instanceof Date) {
return Number.isNaN(value.getTime()) ? '[Invalid Date]' : value.toISOString();
}
if (value instanceof Error) {
const result: { [key: string]: JsonSafeValue } = {
name: value.name,
message: value.message,
};
if (value.stack) result.stack = value.stack;
const code = Reflect.get(value, 'code');
if (typeof code === 'string' || typeof code === 'number') result.code = code;
if (value.cause !== undefined && depth < MAX_DEPTH) {
const cause = toJsonSafe(value.cause, depth + 1, ancestors);
if (cause !== undefined) result.cause = cause;
}
return result;
}
if (depth >= MAX_DEPTH) return constructorLabel(value);
if (ancestors.has(value)) return CIRCULAR;
ancestors.add(value);
try {
if (Array.isArray(value)) {
const items = value
.slice(0, MAX_COLLECTION_ENTRIES)
.map((item) => toJsonSafe(item, depth + 1, ancestors) ?? null);
if (value.length > MAX_COLLECTION_ENTRIES) items.push(`[Truncated ${value.length - MAX_COLLECTION_ENTRIES} items]`);
return items;
}
const prototype = Object.getPrototypeOf(value);
if (prototype !== Object.prototype && prototype !== null) {
return constructorLabel(value);
}
const result: { [key: string]: JsonSafeValue } = {};
const keys = Object.keys(value).slice(0, MAX_COLLECTION_ENTRIES);
for (const key of keys) {
if (isSensitiveKey(key)) {
result[key] = REDACTED;
continue;
}
try {
const child = toJsonSafe(Reflect.get(value, key), depth + 1, ancestors);
if (child !== undefined) result[key] = child;
} catch {
result[key] = '[Unreadable]';
}
}
const omitted = Object.keys(value).length - keys.length;
if (omitted > 0) result.__truncated__ = `${omitted} properties omitted`;
return result;
} finally {
ancestors.delete(value);
}
}
+4 -2
View File
@@ -6,6 +6,7 @@ import {
RealtimeEventHub,
} from './events/index.js';
import { createExpressSseHandler } from './events/express-sse-adapter.js';
import { toJsonSafe } from '../utils/json-safe.js';
const MAX_LOGS = 500;
const INITIAL_LOGS_ON_CONNECT = 50;
@@ -28,14 +29,15 @@ export class LogBroadcaster {
}
push(level: LogEntry['level'], message: string, data?: unknown): void {
const safeData = toJsonSafe(data);
const realtimeEvent = this.eventHub.closed
? undefined
: this.eventHub.publish('log.created', { level, message, data });
: this.eventHub.publish('log.created', { level, message, data: safeData });
const entry: LogEntry = {
timestamp: realtimeEvent ? Date.parse(realtimeEvent.occurredAt) : Date.now(),
level,
message,
data,
data: safeData,
};
this.entries.push(entry);
if (this.entries.length > MAX_LOGS) {