From 6e804d729c5ccd4394c54b950572b4ade26affbd Mon Sep 17 00:00:00 2001 From: Jakub Nowakowski Date: Fri, 4 Sep 2026 16:57:12 +0200 Subject: [PATCH 1/4] Mask inside logged errors instead of passing them through The masking engine has returned any Error untouched since 4.4.0, when the walk stopped writing the placeholder into the caller's objects (#180) by skipping errors altogether. That left the gap reported in #361 and earlier in #214: a secret in `error.message`, in an own property assigned to the error, or down the `cause` chain reached the JSON line, the pretty error block and every transport's `nativeError` in plaintext, while the same secret in a string argument was redacted. Errors are now replaced by a masked clone, like every other argument. The clone is a genuine Error re-pointed at the source's prototype, so `instanceof`, the `[object Error]` tag and the JSON renderer's IErrorObject detection keep working, and no subclass constructor runs (#227). A cross-realm error (node:vm, an iframe) counts as a real Error by its tag. Every own property is defined fresh on the clone from a guarded read, so read-only and getter-only properties cannot throw (#217, #234) and the caller's instance is never written to. Inside an error, `name`, `message` and `stack` are exempt from `mask.keys`, because `keys: ["name"]` is ordinary PII configuration and must not blank every error. `mask.regex` still applies to their text and `mask.paths` can censor them explicitly. The stack is not regex-masked as a whole, since a token or digit pattern would corrupt frame positions. Only the `: ` header of a V8-style stack is masked, with the same regexes as the message, so a header that was formatted before the message changed cannot keep the secret either. Firefox and Safari stacks have no header and stay untouched. Every other own property, `cause` included, is masked exactly like a plain object's. The full browser bundle grows by about 0.3KB gzip for the new branch, so its budget moves from 21.8KB to 22.2KB. --- scripts/check-bundle-size.mjs | 3 +- src/core/masking.ts | 137 +++++++++++- tests/22_BaseLogger_Internals.test.ts | 6 +- tests/26_advanced_masking.browser.test.ts | 33 +++ tests/26_advanced_masking.test.ts | 253 ++++++++++++++++++++++ tests/65_core_gaps.test.ts | 10 +- 6 files changed, 423 insertions(+), 19 deletions(-) diff --git a/scripts/check-bundle-size.mjs b/scripts/check-bundle-size.mjs index 0923df01..76fe805e 100644 --- a/scripts/check-bundle-size.mjs +++ b/scripts/check-bundle-size.mjs @@ -49,7 +49,8 @@ const PROBES = [ { name: "tslog (browser entry, Logger, json)", // 21_500 -> 21_800: ansiToCssConsoleFormat (errors rendered as %c CSS on the browser console path). - budgetGzipBytes: 21_800, + // 21_800 -> 22_200: MaskingEngine.maskError (logged Errors are cloned and masked instead of passed through). + budgetGzipBytes: 22_200, entry: ` import { Logger } from "${entryPath("src/index.browser.ts")}"; const log = new Logger({ type: "json" }); diff --git a/src/core/masking.ts b/src/core/masking.ts index ce3b20b3..81d539ba 100644 --- a/src/core/masking.ts +++ b/src/core/masking.ts @@ -96,12 +96,13 @@ interface MaskKeysCache { * post-construction mutations of `mask.keys` / `mask.placeholder` take effect) and the runtime's * {@link MaskingPredicates}, keeping the core free of runtime imports. * - * Behavior preserved from the v4 monolith: Error/Buffer pass-through, Date/URL cloning, the - * `$`-escape fix for the placeholder, numeric mask-key normalization, and getter-only robustness - * (a throwing getter yields `null` rather than aborting the mask). v5 improvements per contract: - * a zero-clone fast path, a memoizing `WeakMap` cycle/shared-reference guard (a repeat visit returns - * the same MASKED clone, never an unmasked copy), masking inside `Map`/`Set` contents, mask regexes - * always applied globally, `Set.has` key matching, and a single placeholder `$`-escape per invocation. + * Behavior preserved from the v4 monolith: Buffer pass-through, Date/URL cloning, the `$`-escape fix + * for the placeholder, numeric mask-key normalization, and getter-only robustness (a throwing getter + * yields `null` rather than aborting the mask). v5 improvements per contract: a zero-clone fast path, + * a memoizing `WeakMap` cycle/shared-reference guard (a repeat visit returns the same MASKED clone, + * never an unmasked copy), masking inside `Map`/`Set` contents and inside Errors (see {@link maskError}), + * mask regexes always applied globally, `Set.has` key matching, and a single placeholder `$`-escape + * per invocation. */ export class MaskingEngine { private maskKeysCache?: MaskKeysCache; @@ -390,8 +391,10 @@ export class MaskingEngine { } } - if (this.predicates.isError(source) || this.predicates.isBuffer(source)) { + if (this.predicates.isBuffer(source)) { return source as T; + } else if (this.predicates.isError(source)) { + return this.maskError(source, ctx) as T; } else if (source instanceof Map) { // Mask INSIDE the Map: a key matching `mask.keys` (string, or number/bigint — normalized the // same way getMaskKeys stringifies numeric mask keys) redacts its value like an object property @@ -541,16 +544,21 @@ export class MaskingEngine { } } else { if (typeof source === "string") { - let modifiedSource: string = source; - for (const regEx of ctx.regexes) { - modifiedSource = modifiedSource.replace(regEx, ctx.escapedPlaceholder); - } - return modifiedSource as unknown as T; + return this.maskString(source, ctx) as unknown as T; } return source; } } + /** Replace every match of every mask regex in a string. The regexes are already global, see toGlobalRegex. */ + private maskString(value: string, ctx: MaskContext): string { + let masked = value; + for (const regEx of ctx.regexes) { + masked = masked.replace(regEx, ctx.escapedPlaceholder); + } + return masked; + } + /** * Return a variant of `regEx` guaranteed to match globally. `String.replace` with a non-global regex * replaces only the FIRST occurrence — silently leaking every later secret in the same string — and a @@ -577,6 +585,111 @@ export class MaskingEngine { return null; } } + + /** + * Clone an Error and mask the clone, so a secret in the message, in a property assigned to the error or in + * the `cause` chain is redacted like in any other argument: in the JSON line, in the pretty error block and + * in the `nativeError` that transports receive. + * + * A real Error is cloned as `new Error()` with its prototype swapped for the source's. That keeps + * `instanceof`, the `[object Error]` tag and `isNativeError` working downstream without running the + * subclass constructor, which may need arguments. An error-like object (anything else the runtime + * predicate accepts) is cloned as a plain object with the same prototype, so it still prints and + * serializes as an object. Every property is read once, guarded, and defined fresh on the clone, so + * read-only or getter-only properties cannot throw and the caller's error is never written to. + * + * `mask.keys` does not apply to `name`, `message` and `stack`: `keys: ["name"]` is a normal PII setting + * and must not blank every error. `mask.regex` still masks their text and `mask.paths` can target them. + * All other own properties (`code`, `cause`, the `errors` of an AggregateError, ...) are masked like the + * properties of a plain object, which covers the whole `cause` chain. + */ + private maskError(source: Error, ctx: MaskContext): Error { + const prototype = Object.getPrototypeOf(source); + // An error from another realm (node:vm, an iframe) fails `instanceof Error` here but still has the Error tag. + const isErrorInstance = source instanceof Error || Object.prototype.toString.call(source) === "[object Error]"; + const clone: Error = isErrorInstance ? Object.setPrototypeOf(new Error(), prototype) : Object.create(prototype); + ctx.seen.set(source, clone); + if (ctx.inertClones != null && this.isPathInert(ctx)) { + ctx.inertClones.add(clone); + } + ctx.inProgress?.add(source); + try { + const caseInsensitive = this.settings.mask.caseInsensitive === true; + const hasPaths = ctx.paths.length > 0; + // Always redefine `stack` on the clone. `new Error()` captured tslog's own frames, and on Firefox `stack` + // is an accessor on the prototype that would keep reporting them. On V8 and WebKit it is already an own + // property, so the Set only adds it where it is missing. + for (const prop of new Set([...Object.getOwnPropertyNames(source), "stack"])) { + const builtIn = prop === "name" || prop === "message" || prop === "stack"; + const descriptor = Object.getOwnPropertyDescriptor(source, prop); + let masked: unknown; + let removed = false; + if (!builtIn && ctx.keySet.has(caseInsensitive ? prop.toLowerCase() : prop)) { + masked = this.settings.mask.censor === "hash" ? this.hashToken(safeRead(source, prop)) : this.settings.mask.placeholder; + } else { + if (hasPaths) { + ctx.segmentStack.push(prop); + } + try { + if (hasPaths && this.matchesPath(ctx)) { + removed = this.settings.mask.censor === "remove"; + masked = removed ? undefined : this.censorValue(safeRead(source, prop), ctx); + } else if (prop === "stack") { + const stack = safeRead(source, "stack"); + masked = typeof stack === "string" ? this.maskStackHeader(stack, safeRead(source, "message"), ctx) : stack; + } else { + masked = this.recurseProperty(source, prop, ctx); + } + } finally { + if (hasPaths) { + ctx.segmentStack.pop(); + } + } + } + // A property removed by `censor: "remove"` is left off the clone. `stack` still has to be set (see + // above), so it becomes `undefined`. + if (removed && prop !== "stack") { + continue; + } + // Always define a plain writable value. Copying a getter could hand back the unmasked value, and a + // frozen source must not produce a frozen clone. Only enumerability is copied, because it decides what + // `JSON.stringify(nativeError)` and the pretty message line (which joins own properties) show. + Object.defineProperty(clone, prop, { value: masked, enumerable: descriptor?.enumerable === true, writable: true, configurable: true }); + } + } finally { + ctx.inProgress?.delete(source); + } + return clone; + } + + /** + * Mask the header of a V8-style stack with `mask.regex` and leave the frames alone. Node, Bun, Deno, + * Chromium and Hermes start the stack with ": " and follow with " at ..." frame lines, + * so the header repeats whatever secret the message had. The frames are skipped because a token or digit + * pattern would also hit chunk hashes and `line:col` positions. Firefox and Safari stacks have no header, + * only frames, so they come back unchanged. + */ + private maskStackHeader(stack: string, message: unknown, ctx: MaskContext): string { + let headerEnd = stack.indexOf("\n at "); + if (headerEnd === -1) { + // No frame lines. Either a V8 stack without frames (`Error.stackTraceLimit = 0`), which is all header, + // or a Firefox/Safari stack, which is all frames. Only a header contains the message. + if (typeof message !== "string" || message.length === 0 || !stack.includes(message)) { + return stack; + } + headerEnd = stack.length; + } + return this.maskString(stack.slice(0, headerEnd), ctx) + stack.slice(headerEnd); + } +} + +/** Read `source[prop]` without throwing. A getter or Proxy trap that throws yields `undefined`. */ +function safeRead(source: object, prop: string): unknown { + try { + return (source as Record)[prop]; + } catch { + return undefined; + } } /** The longest compiled path's segment count — the depth horizon below which paths can no longer match. */ diff --git a/tests/22_BaseLogger_Internals.test.ts b/tests/22_BaseLogger_Internals.test.ts index 2999fe48..61081400 100644 --- a/tests/22_BaseLogger_Internals.test.ts +++ b/tests/22_BaseLogger_Internals.test.ts @@ -305,13 +305,15 @@ describe("BaseLogger internals", () => { expect(result.name).toBe("Error"); }); - test("recursion guard recognizes error instances", () => { + test("recursion guard clones error instances instead of passing them through", () => { const logger = new Logger({ type: "json" }); const engine = maskingEngineFor(logger); const error = new Error("boom"); const result = engine.recursiveCloneAndMaskValuesOfKeys(error, []); - expect(result).toBe(error); + expect(result).not.toBe(error); + expect(result).toBeInstanceOf(Error); + expect(result.message).toBe("boom"); }); test("recursive masking clones error prototypes when encountered", () => { diff --git a/tests/26_advanced_masking.browser.test.ts b/tests/26_advanced_masking.browser.test.ts index cd1095e7..a850bef0 100644 --- a/tests/26_advanced_masking.browser.test.ts +++ b/tests/26_advanced_masking.browser.test.ts @@ -146,4 +146,37 @@ test.describe("Advanced masking (browser)", () => { ); expect(result).toBe(""); }); + + test("mask.regex masks an error's message in the record and on the native handle", async ({ page }) => { + const result = await inPage<{ message: unknown; native: unknown; isError: boolean; original: unknown }>( + page, + { type: "hidden" }, + ` + const logger = new tslog.Logger({ ...settings, mask: { regex: [/SECRET_[0-9]+/] } }); + const err = new Error("connecting to https://example.org/?key=SECRET_123456"); + const logObj = logger.error(err); + return { message: logObj.message, native: logObj.nativeError.message, isError: logObj.nativeError instanceof Error, original: err.message }; + `, + ); + expect(result.message).toBe("connecting to https://example.org/?key=[***]"); + expect(result.native).toBe("connecting to https://example.org/?key=[***]"); + expect(result.isError).toBe(true); + expect(result.original).toBe("connecting to https://example.org/?key=SECRET_123456"); + }); + + test("mask.keys masks own properties assigned to an error", async ({ page }) => { + const result = await inPage<{ token: unknown; nested: unknown; original: unknown }>( + page, + { type: "hidden", mask: { keys: ["token"] } }, + ` + const logger = new tslog.Logger(settings); + const err = Object.assign(new Error("boom"), { token: "t-1", extensions: { token: "t-2" } }); + const logObj = logger.error(err); + return { token: logObj.nativeError.token, nested: logObj.nativeError.extensions.token, original: err.token }; + `, + ); + expect(result.token).toBe("[***]"); + expect(result.nested).toBe("[***]"); + expect(result.original).toBe("t-1"); + }); }); diff --git a/tests/26_advanced_masking.test.ts b/tests/26_advanced_masking.test.ts index 8dce57ac..8b381c1e 100644 --- a/tests/26_advanced_masking.test.ts +++ b/tests/26_advanced_masking.test.ts @@ -1,4 +1,9 @@ +import { runInNewContext } from "node:vm"; +import { MaskingEngine } from "../src/core/masking.js"; import { Logger } from "../src/index.js"; +import type { ILogObjMeta } from "../src/interfaces.js"; +import { renderJson } from "../src/render/json.js"; +import { getConsoleLogStripped, mockConsoleLog } from "./helper.js"; describe("Advanced masking", () => { test("masks keys in deeply nested structure (5+ levels)", () => { @@ -327,3 +332,251 @@ describe("Masking leak fixes (shared references, cycles, regex flags, Map/Set)", expect((logObj?.plain as Record)?.b).toBe("outside"); }); }); + +class HttpError extends Error { + status: number; + constructor(message: string, status: number) { + super(message); + this.name = "HttpError"; + this.status = status; + } +} + +/** A subclass that cannot be constructed blindly: `new error.constructor()` without arguments throws. */ +class StatusError extends Error { + constructor(status: number) { + if (typeof status !== "number") { + throw new TypeError("StatusError needs a numeric status"); + } + super(`status ${status} key=SECRET_777`); + this.name = "StatusError"; + } +} + +type AnyRecord = Record & ILogObjMeta; +type ErrorRecord = { + name?: string; + message?: string; + stack?: { fileName?: string; filePath?: string; fileLine?: string }[]; + nativeError?: Error & Record; + cause?: ErrorRecord; +}; + +describe("Masking inside errors", () => { + const SECRET = /SECRET_[0-9]+/; + + // Issue #361: a secret inside an Error is masked the same way as in a string argument. + test("mask.regex masks an error's message in the record, the JSON line and the native handle", () => { + const logger = new Logger({ type: "hidden", mask: { regex: [SECRET] } }); + const record = logger.error(new Error("connecting to https://example.org/?key=SECRET_123456")) as AnyRecord; + const logObj = record as ErrorRecord; + expect(logObj.message).toBe("connecting to https://example.org/?key=[***]"); + expect(logObj.nativeError?.message).toBe("connecting to https://example.org/?key=[***]"); + + const line = renderJson(record, logger.settings); + expect(line).toContain('"message":"connecting to https://example.org/?key=[***]"'); + expect(line).not.toContain("SECRET_123456"); + }); + + test("the pretty error block carries the masked message and own properties", () => { + mockConsoleLog(true); + const logger = new Logger({ type: "pretty", pretty: { style: false }, mask: { regex: [SECRET], keys: ["token"] } }); + const err = Object.assign(new Error("connecting to https://example.org/?key=SECRET_123456"), { token: "SECRET_999" }); + logger.error(err); + + const out = getConsoleLogStripped(); + expect(out).toContain("connecting to https://example.org/?key=[***]"); + expect(out).not.toContain("SECRET_123456"); + expect(out).not.toContain("SECRET_999"); + }); + + // Issue #214: properties assigned onto an error are masked too, since they show in pretty output and reach transports. + test("mask.keys masks own properties assigned to an error, nested included", () => { + const logger = new Logger({ type: "hidden", mask: { keys: ["token", "phoneNumber"] } }); + const err = Object.assign(new Error("boom"), { token: "t-1", extensions: { serviceName: "upstream", variables: { phoneNumber: "555" } } }); + const native = (logger.error(err) as ErrorRecord).nativeError as Record; + const extensions = native.extensions as Record>; + expect(native.token).toBe("[***]"); + expect(extensions.variables.phoneNumber).toBe("[***]"); + expect(extensions.serviceName).toBe("upstream"); + // The caller's error is untouched. + expect(err.token).toBe("t-1"); + expect(err.extensions.variables.phoneNumber).toBe("555"); + }); + + test("the cause chain is masked, for Error and string causes", () => { + const logger = new Logger({ type: "hidden", mask: { regex: [SECRET] } }); + const outer = new Error("outer", { cause: new Error("inner key=SECRET_1", { cause: "root key=SECRET_2" }) }); + const logObj = logger.error(outer) as ErrorRecord; + expect(logObj.message).toBe("outer"); + expect(logObj.cause?.message).toBe("inner key=[***]"); + expect(logObj.cause?.cause?.message).toBe("root key=[***]"); + }); + + test("mask.keys never touches name/message/stack, while mask.paths can censor them", () => { + const keyed = new Logger({ type: "hidden", mask: { keys: ["name", "message", "stack", "status"] } }); + const byKeys = keyed.error(new HttpError("Not Found", 404)) as ErrorRecord; + expect(byKeys.name).toBe("HttpError"); + expect(byKeys.message).toBe("Not Found"); + expect(byKeys.stack?.length).toBeGreaterThan(0); + // An own property of the same error is still masked by key. + expect(byKeys.nativeError?.status).toBe("[***]"); + + const pathed = new Logger({ type: "hidden", mask: { paths: ["message"] } }); + const byPath = pathed.error(new HttpError("Not Found", 404)) as ErrorRecord; + expect(byPath.message).toBe("[***]"); + expect(byPath.name).toBe("HttpError"); + }); + + test("the caller's error is never mutated and the clone keeps the subclass", () => { + const logger = new Logger({ type: "hidden", mask: { regex: [SECRET] } }); + const err = new HttpError("key=SECRET_1", 404); + const logObj = logger.error(err) as ErrorRecord; + expect(err.message).toBe("key=SECRET_1"); + expect(logObj.nativeError).not.toBe(err); + expect(logObj.nativeError).toBeInstanceOf(HttpError); + expect(logObj.nativeError?.name).toBe("HttpError"); + expect(logObj.nativeError?.status).toBe(404); + }); + + test("an Error subclass whose constructor requires arguments is cloned without running it", () => { + const logger = new Logger({ type: "hidden", mask: { regex: [SECRET] } }); + const logObj = logger.error(new StatusError(503)) as ErrorRecord; + expect(logObj.message).toBe("status 503 key=[***]"); + expect(logObj.nativeError).toBeInstanceOf(StatusError); + }); + + test("the message repeated in the stack header is masked too", () => { + const logger = new Logger({ type: "hidden", mask: { regex: [SECRET] } }); + // With a name other than `Error` the header line survives stack sanitizing, and the " at " in the message + // makes the parser read it as a frame. So the secret would land in the parsed `stack` array as well. + const logObj = logger.error(new TypeError("failed at https://example.org/?key=SECRET_1")) as ErrorRecord; + expect(logObj.message).toBe("failed at https://example.org/?key=[***]"); + expect(logObj.nativeError?.stack).not.toContain("SECRET_1"); + expect(JSON.stringify(logObj.stack)).not.toContain("SECRET_1"); + }); + + test("stack frames still parse after masking (frames are not regex-masked)", () => { + // A digit pattern would mangle every `line:col` if it ran over the stack string. + const logger = new Logger({ type: "hidden", mask: { regex: [/[0-9]{3,}/] } }); + const logObj = logger.error(new Error("key=123456")) as ErrorRecord; + expect(logObj.message).toBe("key=[***]"); + expect(logObj.stack?.[0]?.fileName).toBe("26_advanced_masking.test.ts"); + expect(logObj.stack?.[0]?.fileLine).toMatch(/^[0-9]+$/); + }); + + test("a masked message that also occurs in the frame paths leaves the frames alone", () => { + // Every frame of this file has "tests" in its path. Only the header may change, the paths must stay. + const logger = new Logger({ type: "hidden", mask: { regex: [/tests/] } }); + const logObj = logger.error(new Error("tests")) as ErrorRecord; + expect(logObj.message).toBe("[***]"); + expect(logObj.nativeError?.stack?.split("\n")[0]).toBe("Error: [***]"); + expect(logObj.stack?.[0]?.fileName).toBe("26_advanced_masking.test.ts"); + expect(logObj.stack?.[0]?.filePath).toMatch(/tests\/26_advanced_masking\.test\.ts$/); + }); + + test("a stack header formatted before the message changed is masked too", () => { + const logger = new Logger({ type: "hidden", mask: { regex: [SECRET] } }); + const err = new Error("key=SECRET_1"); + // V8 formats the header on the first read of `stack` and keeps that text afterwards. + expect(err.stack).toContain("SECRET_1"); + err.message = "sanitized"; + const logObj = logger.error(err) as ErrorRecord; + expect(logObj.message).toBe("sanitized"); + expect(logObj.nativeError?.stack?.split("\n")[0]).toBe("Error: key=[***]"); + }); + + test("a frameless V8 stack is masked as a whole", () => { + const logger = new Logger({ type: "hidden", mask: { regex: [SECRET] } }); + const err = new Error("key=SECRET_1"); + // This is what V8 produces under `Error.stackTraceLimit = 0`. Bun gives no stack at all there, so the + // string is set by hand. + err.stack = "Error: key=SECRET_1"; + const logObj = logger.error(err) as ErrorRecord; + expect(logObj.nativeError?.stack).toBe("Error: key=[***]"); + }); + + test("a frames-only stack (Firefox, Safari) is left untouched", () => { + const logger = new Logger({ type: "hidden", mask: { regex: [/[0-9]{3,}/] } }); + const err = new Error("key=123456"); + err.stack = "handler@https://example.org/app.3f9a8c1.js:120:4567\n@https://example.org/app.3f9a8c1.js:1:1"; + const logObj = logger.error(err) as ErrorRecord; + expect(logObj.message).toBe("key=[***]"); + expect(logObj.nativeError?.stack).toBe(err.stack); + }); + + test("a cross-realm error is cloned as a real Error", () => { + const logger = new Logger({ type: "hidden", mask: { regex: [SECRET] } }); + const err = runInNewContext("new Error('key=SECRET_1')") as Error; + const logObj = logger.error(err) as ErrorRecord; + expect(logObj.message).toBe("key=[***]"); + expect(Object.prototype.toString.call(logObj.nativeError)).toBe("[object Error]"); + }); + + test("an error that references itself through an own property resolves to one masked clone", () => { + const logger = new Logger({ type: "hidden", mask: { regex: [SECRET] } }); + const engine = new MaskingEngine(logger.settings, { isError: (value): value is Error => value instanceof Error, isBuffer: () => false }); + const err = new Error("key=SECRET_1") as Error & { self?: unknown }; + err.self = err; + const [out] = engine.mask([err]) as (Error & { self?: unknown })[]; + expect(out).not.toBe(err); + expect(out.message).toBe("key=[***]"); + expect(out.self).toBe(out); + }); + + // Issue #217: read-only own properties on an Error must not break the masking clone. + test("read-only and frozen own properties on an error neither throw nor escape masking", () => { + const logger = new Logger({ type: "hidden", mask: { keys: ["token"] } }); + const err = new Error("boom"); + Object.defineProperty(err, "token", { value: "t-1", writable: false, enumerable: true, configurable: false }); + Object.defineProperty(err, "kept", { value: "keep", writable: false, enumerable: true, configurable: false }); + Object.freeze(err); + const logObj = logger.error(err) as ErrorRecord; + expect(logObj.nativeError?.token).toBe("[***]"); + expect(logObj.nativeError?.kept).toBe("keep"); + expect(logObj.message).toBe("boom"); + }); + + // Issue #234: some SDK errors expose `message` through a getter only. + test("a getter-only message is read through the getter and masked on the clone", () => { + const logger = new Logger({ type: "hidden", mask: { regex: [SECRET] } }); + const err = new Error("placeholder"); + Object.defineProperty(err, "message", { get: () => "key=SECRET_1", enumerable: false, configurable: true }); + const logObj = logger.error(err) as ErrorRecord; + expect(logObj.message).toBe("key=[***]"); + expect(logObj.nativeError?.message).toBe("key=[***]"); + }); + + test("a throwing getter on an error's own property yields null, like on a plain object, instead of throwing", () => { + const logger = new Logger({ type: "hidden", mask: { regex: [SECRET] } }); + const err = new Error("key=SECRET_1"); + Object.defineProperty(err, "hostile", { + get() { + throw new Error("trap"); + }, + enumerable: true, + configurable: true, + }); + const logObj = logger.error(err) as ErrorRecord; + expect(logObj.message).toBe("key=[***]"); + expect(logObj.nativeError?.hostile).toBeNull(); + }); + + test("an error nested below the deepest mask.paths depth is still cloned and masked", () => { + // At `wrap.err` no configured path can match any more, so the engine may reuse the clone for the second + // reference instead of cloning again. + const logger = new Logger({ type: "hidden", mask: { paths: ["wrap.other"], regex: [SECRET] } }); + const err = new Error("key=SECRET_1"); + const logObj = logger.info({ wrap: { err, again: err } }) as Record>; + expect(logObj.wrap.err.message).toBe("key=[***]"); + expect(logObj.wrap.again).toBe(logObj.wrap.err); + expect(err.message).toBe("key=SECRET_1"); + }); + + test("the clone keeps message and stack non-enumerable, so JSON.stringify(nativeError) is unchanged", () => { + const logger = new Logger({ type: "hidden", mask: { regex: [SECRET] } }); + const logObj = logger.error(new Error("key=SECRET_1")) as ErrorRecord; + expect(JSON.stringify(logObj.nativeError)).toBe("{}"); + expect(Object.keys(logObj.nativeError ?? {})).toEqual([]); + }); +}); diff --git a/tests/65_core_gaps.test.ts b/tests/65_core_gaps.test.ts index a43c0b7e..a9e9dd2e 100644 --- a/tests/65_core_gaps.test.ts +++ b/tests/65_core_gaps.test.ts @@ -397,13 +397,15 @@ describe("masking: numeric mask keys", () => { }); }); -describe("masking: Buffer / Error / URL / Date pass-through", () => { - test("an Error value passes through untouched (its message is not masked)", () => { +describe("masking: Error cloning and Buffer / URL / Date pass-through", () => { + test("an Error value is replaced by a masked clone and the caller's instance is untouched", () => { const engine = maskEngine({ regex: [/secret/g] }); const err = new Error("secret message"); const [out] = engine.mask([err]); - expect(out).toBe(err); - expect((out as Error).message).toBe("secret message"); + expect(out).not.toBe(err); + expect(out).toBeInstanceOf(Error); + expect((out as Error).message).toBe("[***] message"); + expect(err.message).toBe("secret message"); }); test("a Buffer-like value (per predicate) passes through untouched", () => { From b93437508147ae76eb41631a73337a8c59569145 Mon Sep 17 00:00:00 2001 From: Jakub Nowakowski Date: Fri, 4 Sep 2026 16:57:19 +0200 Subject: [PATCH 2/4] Document masking inside errors The README's masking section described masking as leak-proof without saying that errors were skipped, and the Sentry recipe promised the caller's native Error instance under `nativeError`. Both now describe the masked clone: what `regex`, `keys` and `paths` reach inside an error, why `keys` skips `name`, `message` and `stack`, and that stack frames are never regex-masked. llms.txt gets the same clause in its `mask` bullet, and the CHANGELOG its 5.2.0 entries. --- CHANGELOG.md | 8 ++++++++ README.md | 9 +++++++-- llms.txt | 2 +- 3 files changed, 16 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 94ff071b..461ea48a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,14 @@ All notable changes to this project are documented here. This project adheres to [Semantic Versioning](https://semver.org/). +## [5.2.0] - Unreleased + +### Changed +- **Logged errors are cloned** — with `mask` configured, an `Error` argument is replaced by a masked clone like every other argument, and that clone is what transports receive as `nativeError`. It is a real `Error` with the source's prototype (no subclass constructor runs), so `instanceof`, JSON error detection and Sentry-style transports keep working, and the caller's instance is never modified. Without `mask`, errors pass through untouched as before. + +### Fixed +- **Masking inside errors** — a secret in an error's message, in a property assigned to the error or down the `cause` chain no longer reaches the JSON line, the pretty error block or `nativeError` in plaintext. `mask.regex` covers the message and the `: ` header of a V8 stack (frames are left alone, so a broad pattern cannot corrupt positions), `mask.keys`/`regex`/`paths` cover every other own property and the whole `cause` chain. `name`, `message` and `stack` are exempt from `mask.keys`, so `keys: ["name"]` does not blank every error, while `mask.paths` can still target them. (#214, #361) + ## [5.1.0] - 2026-07-17 ### Added diff --git a/README.md b/README.md index 5614ba24..29e73e30 100644 --- a/README.md +++ b/README.md @@ -496,7 +496,12 @@ const log = new Logger({ }); ``` -Masking is leak-proof by construction: `regex` patterns are always applied **globally** (every occurrence in a string is redacted, whether or not you wrote the `g` flag), shared references and circular structures resolve to the same *masked* clone (a secret can never escape through a second reference to the same object), and `mask.keys` / `regex` also apply **inside `Map` and `Set`** contents (`mask.paths` does not descend into them). +Masking is leak-proof by construction: + +- `regex` patterns are always applied **globally**: every occurrence in a string is redacted, whether or not you wrote the `g` flag. +- Shared references and circular structures resolve to the same *masked* clone, so a secret can never escape through a second reference to the same object. +- `keys` and `regex` also apply **inside `Map` and `Set`** contents (`paths` does not descend into them). +- **Errors are masked like any other object.** A logged `Error`, top-level or nested, is replaced by a masked clone and the caller's instance stays untouched. `regex` covers the `message` and the `: ` header of a V8-style stack, but never the frames, so a broad pattern cannot corrupt `line:col` positions. `keys`, `regex` and `paths` cover every other own property (`code`, `extensions`, ...) and the `cause` chain. `keys` skips `name`, `message` and `stack`, so `keys: ["name"]` does not blank every error, while `paths` can still target them (`paths: ["message"]`). The `censor` option controls *how* a **`paths`-matched** value is replaced (`keys`- and `regex`-matched values always use `placeholder`, with one exception below): @@ -700,7 +705,7 @@ Error trackers and log platforms plug in as transports — no vendor-specific lo [Sentry](https://sentry.io) has two ingestion paths: **issues** (error tracking) and **[Sentry Logs](https://docs.sentry.io/platforms/javascript/guides/node/logs/)** (structured logs, searchable next to your traces). A tslog transport covers each — run one or both. -**Errors → Sentry issues.** Forward `ERROR`/`FATAL` records while keeping your normal console/JSON output. The record a transport receives still carries the **native `Error` instance** (as `nativeError` on the serialized error), so Sentry gets the real exception — full stack and `cause` chain, proper issue grouping — not a stringified copy: +**Errors → Sentry issues.** Forward `ERROR`/`FATAL` records while keeping your normal console/JSON output. The record a transport receives still carries the **native `Error` instance** (as `nativeError` on the serialized error), so Sentry gets the real exception — full stack and `cause` chain, proper issue grouping — not a stringified copy. With `mask` configured, `nativeError` is the masked clone: still a real `Error` with the same stack and `cause` chain, so grouping works and secrets stay out of Sentry too: ```typescript import * as Sentry from "@sentry/node"; diff --git a/llms.txt b/llms.txt index 0127efbc..6fee7a80 100644 --- a/llms.txt +++ b/llms.txt @@ -22,7 +22,7 @@ import { Logger, createLogger, log } from "tslog"; // class + typed-custom-level ## Settings are GROUPED (no flat keys) - `type`, `name`, `minLevel: "INFO"` (name or 0..6), `prefix: ["[api]"]` (args prepended to every call, concatenated down the sub-logger chain; use `bindings` for JSON fields, `prefix` for message text), `strictConfig: true` (throw `TslogConfigError` on unknown/typo'd/v4-flat keys; without it they warn in dev with a did-you-mean). - `persistLevel: true` (browser-only) — persist runtime `setMinLevel()` changes in `localStorage` and restore them on reload; key name via `persistLevelKey`; no-op outside browsers. -- `mask: { keys: ["password","apiKey","token","prompt"], paths: ["user.password","*.token"], caseInsensitive, regex, censor, placeholder: "[***]" }` — redact secrets/PII/prompts by key, dotted path, or regex. Regexes always apply globally (no `g` flag needed); keys/regex also mask inside `Map`/`Set` contents; shared/circular references resolve to the same masked clone. +- `mask: { keys: ["password","apiKey","token","prompt"], paths: ["user.password","*.token"], caseInsensitive, regex, censor, placeholder: "[***]" }` — redact secrets/PII/prompts by key, dotted path, or regex. Regexes always apply globally (no `g` flag needed); keys/regex also mask inside `Map`/`Set` contents; shared/circular references resolve to the same masked clone. Errors are masked too: `regex` on `message` and on the V8 stack header (never on the frames), `keys`/`regex`/`paths` on every other own property and down the `cause` chain. `keys` skips `name`/`message`/`stack`, `paths` can target them. - `json: { messageKey, levelKey, timeKey, errorKey, time: "iso"|"epoch"|false|fn }` (`time` shapes the top-level timestamp; `_logMeta.date` stays UTC ISO), `pretty: { template, timeZone, style, levelMethod, passObjectsNatively, inspectOptions }`, `stack: { capture: "off"|"lazy"|"auto"|"full" }`, `meta: { property, attachContext }`. `pretty.template` reshapes the log line via `{{placeholders}}` — `{{logLevelName}}`, `{{name}}`, `{{filePathWithLine}}`, `{{dateIsoStr}}`, or date parts `{{yyyy}}.{{mm}}.{{dd}} {{hh}}:{{MM}}:{{ss}}:{{ms}}` — and `stack.capture: "auto"` (the pretty default; json defaults to `"off"`) captures frames only when the template renders a code position. `pretty.passObjectsNatively` hands non-Error args to the console by reference (collapsible objects in browser DevTools; pair with `levelMethod` for native warn/error stack groups) — default TRUE in real browsers, false elsewhere; set `false` for log-time snapshots (raw references show post-mutation state when expanded) or text-matchable output (DevTools filter/console-capture only match the rendered string); `pretty.inspectOptions.breakLength: Infinity` keeps inspected objects on one line for log aggregators. - Source-mapped error positions (Node/Bun/Deno, not browser): `_logMeta.path` and pretty error stacks resolve through a source map back to the original `.ts` file/line/column when one is discoverable, automatically outside production (`NODE_ENV !== "production"`); force with `TSLOG_SOURCE_MAPS=on`/`off`. Flat and indexed (`sections`) maps both work — incl. Turbopack/Next.js dev, TanStack Start (Vite), webpack, Rollup, esbuild, tsc output. - `clock: () => Date` — injectable clock (deterministic tests, offset stamping); inherited by sub-loggers; a throwing/invalid clock is ignored. From 7d4db27107193f4552318b45eb7ec854fa9af3b7 Mon Sep 17 00:00:00 2001 From: Eugene Terehov Date: Thu, 10 Sep 2026 23:20:24 +0300 Subject: [PATCH 3/4] Keep DOMException name and message when masking errors --- RECIPES.md | 2 +- src/core/masking.ts | 21 ++++++++++++++++++++- tests/26_advanced_masking.browser.test.ts | 13 +++++++++++++ tests/26_advanced_masking.test.ts | 15 +++++++++++++++ 4 files changed, 49 insertions(+), 2 deletions(-) diff --git a/RECIPES.md b/RECIPES.md index ab44309e..cf9291d0 100644 --- a/RECIPES.md +++ b/RECIPES.md @@ -180,7 +180,7 @@ const detach = log.attachTransport({ ## 7b. Send errors and logs to Sentry -Errors as Sentry issues: the record a transport receives still carries the native `Error` instance (as `nativeError` on the serialized error), so Sentry gets the real exception — full stack and `cause` chain — not a stringified copy. +Errors as Sentry issues: the record a transport receives still carries the native `Error` instance (as `nativeError` on the serialized error), so Sentry gets the real exception — full stack and `cause` chain — not a stringified copy. With `mask` configured, `nativeError` is the masked clone: still a real `Error` with the same stack and `cause` chain, so secrets stay out of Sentry too. ```ts import * as Sentry from "@sentry/node"; diff --git a/src/core/masking.ts b/src/core/masking.ts index 81d539ba..5c9a3b4d 100644 --- a/src/core/masking.ts +++ b/src/core/masking.ts @@ -619,7 +619,16 @@ export class MaskingEngine { // Always redefine `stack` on the clone. `new Error()` captured tslog's own frames, and on Firefox `stack` // is an accessor on the prototype that would keep reporting them. On V8 and WebKit it is already an own // property, so the Set only adds it where it is missing. - for (const prop of new Set([...Object.getOwnPropertyNames(source), "stack"])) { + const props = new Set([...Object.getOwnPropertyNames(source), "stack"]); + // DOMException (AbortError, TimeoutError, ...) serves `name` and `message` from prototype getters that read + // internal slots, and a class can do the same with #private fields. The clone has neither, so those getters + // throw on it. Copy such a property from the source like an own one. + for (const prop of ["name", "message"]) { + if (!props.has(prop) && throwsOnRead(clone, prop)) { + props.add(prop); + } + } + for (const prop of props) { const builtIn = prop === "name" || prop === "message" || prop === "stack"; const descriptor = Object.getOwnPropertyDescriptor(source, prop); let masked: unknown; @@ -692,6 +701,16 @@ function safeRead(source: object, prop: string): unknown { } } +/** Whether reading `target[prop]` throws, as a getter does when it needs internal state the target lacks. */ +function throwsOnRead(target: object, prop: string): boolean { + try { + Reflect.get(target, prop); + return false; + } catch { + return true; + } +} + /** The longest compiled path's segment count — the depth horizon below which paths can no longer match. */ function maxSegments(paths: CompiledPath[]): number { let max = 0; diff --git a/tests/26_advanced_masking.browser.test.ts b/tests/26_advanced_masking.browser.test.ts index a850bef0..a8b4086e 100644 --- a/tests/26_advanced_masking.browser.test.ts +++ b/tests/26_advanced_masking.browser.test.ts @@ -179,4 +179,17 @@ test.describe("Advanced masking (browser)", () => { expect(result.nested).toBe("[***]"); expect(result.original).toBe("t-1"); }); + + test("a DOMException keeps its name and masked message", async ({ page }) => { + const result = await inPage<{ name: unknown; message: unknown; nativeName: unknown; isDomException: boolean }>( + page, + { type: "hidden" }, + ` + const logger = new tslog.Logger({ ...settings, mask: { regex: [/SECRET_[0-9]+/] } }); + const logObj = logger.error(new DOMException("aborted key=SECRET_1", "AbortError")); + return { name: logObj.name, message: logObj.message, nativeName: logObj.nativeError.name, isDomException: logObj.nativeError instanceof DOMException }; + `, + ); + expect(result).toEqual({ name: "AbortError", message: "aborted key=[***]", nativeName: "AbortError", isDomException: true }); + }); }); diff --git a/tests/26_advanced_masking.test.ts b/tests/26_advanced_masking.test.ts index 8b381c1e..038b66b7 100644 --- a/tests/26_advanced_masking.test.ts +++ b/tests/26_advanced_masking.test.ts @@ -513,6 +513,21 @@ describe("Masking inside errors", () => { expect(Object.prototype.toString.call(logObj.nativeError)).toBe("[object Error]"); }); + test("a DOMException keeps its name and message, which it serves from getters the clone cannot answer", () => { + // `fetch` and AbortController reject with these. Their `name`/`message` getters read internal slots. + const logger = new Logger({ type: "hidden", mask: { regex: [SECRET] } }); + const logObj = logger.error(new DOMException("aborted key=SECRET_1", "AbortError")) as ErrorRecord; + expect(logObj.name).toBe("AbortError"); + expect(logObj.message).toBe("aborted key=[***]"); + expect(logObj.nativeError).toBeInstanceOf(DOMException); + expect(logObj.nativeError?.name).toBe("AbortError"); + expect(logObj.nativeError?.message).toBe("aborted key=[***]"); + + // The copied `message` goes through the same masking as an own one, so `mask.paths` reaches it. + const pathed = new Logger({ type: "hidden", mask: { paths: ["message"] } }); + expect((pathed.error(new DOMException("aborted", "AbortError")) as ErrorRecord).message).toBe("[***]"); + }); + test("an error that references itself through an own property resolves to one masked clone", () => { const logger = new Logger({ type: "hidden", mask: { regex: [SECRET] } }); const engine = new MaskingEngine(logger.settings, { isError: (value): value is Error => value instanceof Error, isBuffer: () => false }); From f2b01ec6fe0f0cb208064b58f5d3b34cb57335f0 Mon Sep 17 00:00:00 2001 From: Eugene Terehov Date: Thu, 10 Sep 2026 23:32:00 +0300 Subject: [PATCH 4/4] Fix masking a DOMException on Node 20 --- src/core/masking.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/core/masking.ts b/src/core/masking.ts index 5c9a3b4d..941ee6a3 100644 --- a/src/core/masking.ts +++ b/src/core/masking.ts @@ -607,7 +607,11 @@ export class MaskingEngine { const prototype = Object.getPrototypeOf(source); // An error from another realm (node:vm, an iframe) fails `instanceof Error` here but still has the Error tag. const isErrorInstance = source instanceof Error || Object.prototype.toString.call(source) === "[object Error]"; - const clone: Error = isErrorInstance ? Object.setPrototypeOf(new Error(), prototype) : Object.create(prototype); + // Settle the clone's own `stack` while its prototype is still Error.prototype. Node 20 formats a pending stack + // when `stack` is redefined, which reads `name` and `message`, and on a DOMException prototype those throw. + const clone: Error = isErrorInstance + ? Object.setPrototypeOf(Object.defineProperty(new Error(), "stack", { value: undefined, writable: true, configurable: true }), prototype) + : Object.create(prototype); ctx.seen.set(source, clone); if (ctx.inertClones != null && this.isPathInert(ctx)) { ctx.inertClones.add(clone);