UNPKG

@dudousxd/nestjs-telescope

Version:

Laravel Telescope-style observability console for NestJS — core: watchers, recorder, correlation, SQLite store, headless API.

201 lines 9.38 kB
// packages/core/src/redaction/redact.ts export const DEFAULT_REDACT_KEYS = [ 'authorization', 'cookie', 'set-cookie', 'password', 'passwd', 'secret', 'token', 'access_token', 'refresh_token', 'api-key', 'api_key', 'apikey', 'x-api-key', 'client_secret', 'private_key', ]; /** * Hard, on-by-default bounds that cap how much `redact()` clones from a single * input. These exist because `redact()` doubles as the synchronous detach that * snapshots watcher content into a plain, reference-free object (see the * performance milestone spec §A.1): a host's `req.user` can be a hydrated ORM * entity with tens-of-KB..MB of enumerable graph. Cloning it whole produced a * working set of `prune.after × ingest rate × bytes_per_entry` large enough to * drive a GC death spiral at container limits. These defaults are generous * enough that a NORMAL request/query/cache entry is cloned byte-identically; they * only bite on pathological mega-graphs, so they are the incident's by-design fix. */ const DEFAULT_MAX_DEPTH = 8; const DEFAULT_MAX_STRING_LENGTH = 8_192; const DEFAULT_MAX_ARRAY_LENGTH = 200; const DEFAULT_MAX_NODES = 5_000; const DEFAULT_MAX_CONTENT_BYTES = 16_384; /** Approximate per-node overhead charged against `maxContentBytes` (keys, * punctuation, numbers — a coarse stand-in for serialized size). */ const NODE_BYTE_OVERHEAD = 8; /** Marker substituted for a subtree pruned because it exceeded `maxDepth`. */ const DEPTH_MARKER = '[Truncated: depth]'; /** Suffix appended to a string clipped to `maxStringLength`. */ const STRING_TRUNCATION_SUFFIX = '…[truncated]'; /** Marker substituted for a subtree pruned because the node budget ran out. */ const SIZE_MARKER = '[Truncated: size]'; /** * Precompiles the immutable key/path Sets from {@link RedactOptions}. Call once * (config is immutable after boot) and reuse the result across every entry — * this is the optimization that keeps the hottest redaction path allocation-free. */ export function compileRedactSpec(options) { return { keySet: new Set([...DEFAULT_REDACT_KEYS, ...(options.keys ?? [])].map((key) => key.toLowerCase())), paths: new Set(options.paths ?? []), }; } const isPlainObject = (value) => typeof value === 'object' && value !== null && !Array.isArray(value); /** * Bounded, never-throwing, SYNCHRONOUS deep clone of `value` with sensitive * leaves masked. Same key/path masking semantics as {@link redact}, plus the * hard memory bounds in {@link RedactOptions} (all defaulted on). Returns the * clone AND whether truncation happened so the Recorder can surface a counter. * * Synchronicity is load-bearing: this is the detach that releases the host's * live object graph at `record()` time — never defer it (see spec §A.1). */ export function redactBounded(value, options) { // Ad-hoc callers compile the spec lazily here; the hot path (Recorder) passes // a prebuilt spec to redactBoundedWith() and skips this per-call allocation. return redactBoundedWith(value, options, compileRedactSpec(options)); } /** * Bounded redaction using an ALREADY-COMPILED {@link CompiledRedactSpec}, so the * per-entry hot path never rebuilds the key/path Sets. Identical behaviour to * {@link redactBounded}; only the `keys`/`paths` of `options` are ignored in * favour of the prebuilt `spec` (the remaining bound options are still read). */ export function redactBoundedWith(value, options, spec) { const mask = options.mask ?? '[REDACTED]'; const keySet = spec.keySet; const paths = spec.paths; const maxDepth = options.maxDepth ?? DEFAULT_MAX_DEPTH; const maxStringLength = options.maxStringLength ?? DEFAULT_MAX_STRING_LENGTH; const maxArrayLength = options.maxArrayLength ?? DEFAULT_MAX_ARRAY_LENGTH; const maxNodes = options.maxNodes ?? DEFAULT_MAX_NODES; const maxContentBytes = options.maxContentBytes ?? DEFAULT_MAX_CONTENT_BYTES; const seen = new WeakSet(); // Mutable walk state: node + byte budgets and a truncation flag. Kept in the // closure (not threaded through args) so the per-call accounting stays cheap. let nodesRemaining = maxNodes; let bytesRemaining = maxContentBytes; let truncated = false; const walk = (node, path, depth) => { // Every visited node (object, array, or leaf) costs one node unit and a // small byte overhead; strings additionally charge their length. Once either // budget is gone, prune the rest of the graph regardless of its shape. if (nodesRemaining <= 0 || bytesRemaining <= 0) { truncated = true; return SIZE_MARKER; } nodesRemaining -= 1; bytesRemaining -= NODE_BYTE_OVERHEAD; if (typeof node === 'string') { const kept = node.length > maxStringLength ? maxStringLength : node.length; bytesRemaining -= kept; if (node.length > maxStringLength) { truncated = true; return `${node.slice(0, maxStringLength)}${STRING_TRUNCATION_SUFFIX}`; } return node; } // Binary blobs (Buffer, TypedArrays, DataView, ArrayBuffer) are opaque // bytes, not a traversable graph — summarize them as a bounded marker // instead of walking them. This is load-bearing: a Buffer is `typeof // 'object'` and not an Array, so without this it falls into the plain-object // branch and `Object.entries()` EAGERLY materializes one [index, byte] pair // per byte BEFORE the node/byte budget is ever consulted. On a multi-MB body // (e.g. a raw file-upload chunk) that is seconds of synchronous CPU and // hundreds of MB allocated on the event loop — the budgets never get a // chance to bite. Charged as a single node above; O(1) here. if (ArrayBuffer.isView(node)) { truncated = true; const name = node.constructor?.name ?? 'Binary'; return `[${name}: ${node.byteLength} bytes]`; } if (node instanceof ArrayBuffer) { truncated = true; return `[ArrayBuffer: ${node.byteLength} bytes]`; } // `seen` tracks only the current ancestor path (added on the way down, // removed on the way up), so genuine cycles are caught while a non-cyclic // shared reference appearing in two sibling positions is NOT a false hit. if (Array.isArray(node)) { if (seen.has(node)) return '[Circular]'; if (depth >= maxDepth) { truncated = true; return DEPTH_MARKER; } seen.add(node); const limit = Math.min(node.length, maxArrayLength); const mapped = []; for (let index = 0; index < limit; index++) { // Stop iterating the moment a budget runs out: ONE trailing marker, not // one marker per remaining item (markers themselves cost bytes). if (nodesRemaining <= 0 || bytesRemaining <= 0) { truncated = true; mapped.push(SIZE_MARKER); break; } mapped.push(walk(node[index], path ? `${path}.${index}` : String(index), depth + 1)); } if (node.length > maxArrayLength) { truncated = true; mapped.push(`[Truncated: ${maxArrayLength} of ${node.length} items]`); } seen.delete(node); return mapped; } if (isPlainObject(node)) { if (seen.has(node)) return '[Circular]'; if (depth >= maxDepth) { truncated = true; return DEPTH_MARKER; } seen.add(node); const result = {}; for (const [key, child] of Object.entries(node)) { // Stop iterating the moment a budget runs out: ONE trailing marker, not // one marker per remaining field (keys + markers themselves cost bytes). if (nodesRemaining <= 0 || bytesRemaining <= 0) { truncated = true; result['…'] = SIZE_MARKER; break; } // Object keys are real serialized bytes — charge them too. bytesRemaining -= key.length; const childPath = path ? `${path}.${key}` : key; if (keySet.has(key.toLowerCase()) || paths.has(childPath)) { result[key] = mask; } else { result[key] = walk(child, childPath, depth + 1); } } seen.delete(node); return result; } return node; }; return { value: walk(value, '', 0), truncated }; } /** * Returns a deep clone of `value` with sensitive leaves replaced by the mask. * Never mutates the input. Memory-bounded by default (see {@link RedactOptions}); * delegates to {@link redactBounded} and discards the truncation flag, so every * existing caller keeps the original `(value) => clone` signature. */ export function redact(value, options) { return redactBounded(value, options).value; } //# sourceMappingURL=redact.js.map