Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<name>: <message>` 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
Expand Down
9 changes: 7 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<name>: <message>` 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):

Expand Down Expand Up @@ -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";
Expand Down
2 changes: 1 addition & 1 deletion RECIPES.md
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down
2 changes: 1 addition & 1 deletion llms.txt
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
3 changes: 2 additions & 1 deletion scripts/check-bundle-size.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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" });
Expand Down
160 changes: 148 additions & 12 deletions src/core/masking.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<LogObj> {
private maskKeysCache?: MaskKeysCache;
Expand Down Expand Up @@ -390,8 +391,10 @@ export class MaskingEngine<LogObj> {
}
}

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
Expand Down Expand Up @@ -541,16 +544,21 @@ export class MaskingEngine<LogObj> {
}
} 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
Expand All @@ -577,6 +585,134 @@ export class MaskingEngine<LogObj> {
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]";
// 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);
}
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.
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;
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 "<name>: <message>" 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<string, unknown>)[prop];
} catch {
return undefined;
}
}

/** 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. */
Expand Down
6 changes: 4 additions & 2 deletions tests/22_BaseLogger_Internals.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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", () => {
Expand Down
Loading
Loading