@warlock.js/logger 4.15.0 → 5.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,36 +1,101 @@
1
+ import { DEFAULT_REDACT_KEY_SET, normalizeRedactKey } from "./default-keys.mjs";
2
+
1
3
  //#region ../logger/src/redact/redact.ts
4
+ const DEFAULT_CENSOR = "[REDACTED]";
5
+ /**
6
+ * Values we copy by reference rather than walking. Expanding these with
7
+ * `Object.keys` would be destructive, not protective — a `Buffer` becomes a
8
+ * multi-thousand-key index map, a `Map`/`Set`/`RegExp` becomes `{}`. Their
9
+ * contents are consequently *not* reachable by redaction; see the residual
10
+ * gaps documented on `applyRedact`.
11
+ */
12
+ function isOpaque(value) {
13
+ return ArrayBuffer.isView(value) || value instanceof ArrayBuffer || value instanceof Map || value instanceof Set || value instanceof WeakMap || value instanceof WeakSet || value instanceof Promise || value instanceof RegExp;
14
+ }
2
15
  /**
3
16
  * Deep-clone a value with structural fidelity for log entries — handles plain
4
- * objects, arrays, `Date`, `Error`, and primitives. Anything else is copied
5
- * by reference (we only redact paths through plain objects/arrays anyway,
6
- * and rebuilding e.g. a `Buffer` would change semantics).
17
+ * objects, arrays, `Date`, `Error`, and primitives. Anything else (buffers,
18
+ * maps, sets, promises, regexes) is copied by reference: we only redact
19
+ * through walkable structures anyway, and rebuilding e.g. a `Buffer` would
20
+ * change semantics.
7
21
  *
8
22
  * Purpose-built rather than reaching for `structuredClone`: `Error` instances
9
23
  * lose their `message`/`stack` under `structuredClone` in some Node versions,
10
24
  * and the logger pipeline carries them often.
25
+ *
26
+ * When `redaction` is supplied, keys matching its denylist are censored during
27
+ * the same pass — one traversal, not two.
11
28
  */
12
- function cloneEntry(value, seen = /* @__PURE__ */ new WeakMap()) {
29
+ function cloneEntry(value, seen = /* @__PURE__ */ new WeakMap(), redaction, trail = []) {
13
30
  if (value === null || typeof value !== "object") return value;
14
- if (seen.has(value)) return seen.get(value);
31
+ const asObject = value;
32
+ if (seen.has(asObject)) return seen.get(asObject);
15
33
  if (value instanceof Date) return new Date(value.getTime());
16
- if (value instanceof Error) {
17
- const copy = new value.constructor(value.message);
18
- copy.stack = value.stack;
19
- copy.name = value.name;
20
- return copy;
21
- }
34
+ if (isOpaque(asObject)) return value;
35
+ if (value instanceof Error) return cloneError(value, seen, redaction, trail);
22
36
  if (Array.isArray(value)) {
23
37
  const arr = [];
24
- seen.set(value, arr);
25
- for (const item of value) arr.push(cloneEntry(item, seen));
38
+ seen.set(asObject, arr);
39
+ value.forEach((item, index) => {
40
+ arr.push(cloneEntry(item, seen, redaction, [...trail, String(index)]));
41
+ });
26
42
  return arr;
27
43
  }
28
44
  const out = {};
29
- seen.set(value, out);
30
- for (const key of Object.keys(value)) out[key] = cloneEntry(value[key], seen);
45
+ seen.set(asObject, out);
46
+ copyOwnKeys(value, out, seen, redaction, trail);
31
47
  return out;
32
48
  }
33
49
  /**
50
+ * Copy every own enumerable key from `source` onto `target`, censoring the
51
+ * ones the key denylist matches and recursing into the rest.
52
+ */
53
+ function copyOwnKeys(source, target, seen, redaction, trail) {
54
+ for (const key of Object.keys(source)) {
55
+ const childTrail = [...trail, key];
56
+ if (redaction && redaction.keys.has(normalizeRedactKey(key)) && !redaction.alreadyCensored.has(childTrail.join("."))) {
57
+ target[key] = applyCensor(source[key], redaction.censor, childTrail);
58
+ continue;
59
+ }
60
+ target[key] = cloneEntry(source[key], seen, redaction, childTrail);
61
+ }
62
+ }
63
+ /**
64
+ * Clone an `Error`, preserving its own enumerable properties.
65
+ *
66
+ * Those extra properties matter for redaction: HTTP clients (axios, got)
67
+ * attach `.config`/`.request`/`.response` to the errors they throw, and those
68
+ * routinely carry the *outgoing* `Authorization` header of the failed request.
69
+ * Copying them through the walk is what lets the key denylist reach in and
70
+ * censor them — dropping them instead (the pre-4.15.0 behavior) hid the
71
+ * secret only when redaction happened to be configured, and took `.code` and
72
+ * friends with it.
73
+ *
74
+ * `name`/`message`/`stack` are non-enumerable on `Error`, so they are carried
75
+ * over explicitly.
76
+ */
77
+ function cloneError(value, seen, redaction, trail) {
78
+ let copy;
79
+ try {
80
+ copy = new value.constructor(value.message);
81
+ } catch {
82
+ copy = new Error(value.message);
83
+ }
84
+ for (const [key, source] of [
85
+ ["message", value.message],
86
+ ["name", value.name],
87
+ ["stack", value.stack]
88
+ ]) Object.defineProperty(copy, key, {
89
+ value: source,
90
+ writable: true,
91
+ enumerable: false,
92
+ configurable: true
93
+ });
94
+ seen.set(value, copy);
95
+ copyOwnKeys(value, copy, seen, redaction, trail);
96
+ return copy;
97
+ }
98
+ /**
34
99
  * Apply a single censor decision to a value. String censors are returned
35
100
  * verbatim; function censors receive the original value plus the dotted
36
101
  * path so callers can implement value-aware redaction (mask all but the
@@ -45,6 +110,9 @@ function applyCensor(value, censor, path) {
45
110
  * replacing matched leaves via `censor`. Operates in place — the caller
46
111
  * is responsible for cloning before calling.
47
112
  *
113
+ * Every censored leaf's dotted path is recorded in `censored` so a following
114
+ * key-denylist pass can leave it alone.
115
+ *
48
116
  * Wildcards:
49
117
  * - `*` matches exactly one segment (any key on a plain object, any index
50
118
  * on an array — stringified for the path that's passed to a function
@@ -52,58 +120,148 @@ function applyCensor(value, censor, path) {
52
120
  * - `**` matches zero or more segments greedily; the rest of the pattern
53
121
  * is then attempted at the current level and at every descendant.
54
122
  */
55
- function redactAtPath(target, segments, censor, pathTrail) {
123
+ function redactAtPath(target, segments, censor, pathTrail, censored) {
56
124
  if (target === null || typeof target !== "object") return;
57
125
  if (segments.length === 0) return;
58
126
  const [head, ...rest] = segments;
59
127
  if (head === "**") {
60
- if (rest.length > 0) redactAtPath(target, rest, censor, pathTrail);
128
+ if (rest.length > 0) redactAtPath(target, rest, censor, pathTrail, censored);
61
129
  const keys = Array.isArray(target) ? target.map((_, index) => String(index)) : Object.keys(target);
62
- for (const key of keys) redactAtPath(target[key], segments, censor, [...pathTrail, key]);
130
+ for (const key of keys) redactAtPath(target[key], segments, censor, [...pathTrail, key], censored);
63
131
  return;
64
132
  }
65
133
  const keysToVisit = head === "*" ? Array.isArray(target) ? target.map((_, index) => String(index)) : Object.keys(target) : Array.isArray(target) ? /^\d+$/.test(head) && Number(head) < target.length ? [head] : [] : Object.prototype.hasOwnProperty.call(target, head) ? [head] : [];
66
- for (const key of keysToVisit) if (rest.length === 0) target[key] = applyCensor(target[key], censor, [...pathTrail, key]);
67
- else redactAtPath(target[key], rest, censor, [...pathTrail, key]);
134
+ for (const key of keysToVisit) if (rest.length === 0) {
135
+ const leafTrail = [...pathTrail, key];
136
+ target[key] = applyCensor(target[key], censor, leafTrail);
137
+ censored.add(leafTrail.join("."));
138
+ } else redactAtPath(target[key], rest, censor, [...pathTrail, key], censored);
68
139
  }
69
140
  /**
70
- * Produce a new `LoggingData` with every path in `config.paths` replaced
71
- * by `config.censor`. The original entry is never mutated — channels and
72
- * other call sites can hold references to the input safely.
141
+ * Cheap pre-scan: does this graph contain any denylisted key at all?
142
+ *
143
+ * Lets the default-on key pass stay allocation-free for the overwhelming
144
+ * majority of entries, which carry no secrets — we only pay for a clone when
145
+ * there is actually something to censor. Also keeps `applyRedact`'s
146
+ * "returns the input by reference when nothing changed" contract intact.
147
+ */
148
+ function hasDenylistedKey(value, keys, seen = /* @__PURE__ */ new WeakSet()) {
149
+ if (value === null || typeof value !== "object") return false;
150
+ const asObject = value;
151
+ if (seen.has(asObject)) return false;
152
+ seen.add(asObject);
153
+ if (value instanceof Date || isOpaque(asObject)) return false;
154
+ if (Array.isArray(value)) return value.some((item) => hasDenylistedKey(item, keys, seen));
155
+ for (const key of Object.keys(value)) {
156
+ if (keys.has(normalizeRedactKey(key))) return true;
157
+ if (hasDenylistedKey(value[key], keys, seen)) return true;
158
+ }
159
+ return false;
160
+ }
161
+ /**
162
+ * Cache of resolved key sets, keyed by the config object they came from.
163
+ * Logger-wide and channel configs are long-lived references, so this makes
164
+ * the per-entry cost a single map lookup. Merged configs (rebuilt per entry
165
+ * by `mergeRedact`) fall out of the `WeakMap` on their own.
166
+ */
167
+ const keySetCache = /* @__PURE__ */ new WeakMap();
168
+ /**
169
+ * Resolve the effective key denylist for a config: the built-in set (unless
170
+ * `defaultKeys: false`) plus any `keys` the application added.
171
+ *
172
+ * Returns `undefined` only when there is nothing to match — i.e. defaults are
173
+ * explicitly off and no custom keys were supplied.
174
+ */
175
+ function resolveRedactKeys(config) {
176
+ const useDefaults = config?.defaultKeys !== false;
177
+ const extra = config?.keys;
178
+ if (!extra || extra.length === 0) return useDefaults ? DEFAULT_REDACT_KEY_SET : void 0;
179
+ const cached = config && keySetCache.get(config);
180
+ if (cached) return cached;
181
+ const resolved = new Set(useDefaults ? DEFAULT_REDACT_KEY_SET : []);
182
+ for (const key of extra) resolved.add(normalizeRedactKey(key));
183
+ if (config) keySetCache.set(config, resolved);
184
+ return resolved;
185
+ }
186
+ /**
187
+ * Produce a new `LoggingData` with sensitive data censored:
188
+ *
189
+ * 1. every path in `config.paths` (opt-in globs), then
190
+ * 2. every key matching the denylist — the built-in
191
+ * {@link DEFAULT_REDACT_KEYS} plus `config.keys`, at any depth of
192
+ * `context`, `message`, and an `Error`'s own enumerable properties.
193
+ *
194
+ * Step 2 runs **with no config at all**: passing `undefined` still censors
195
+ * `password`, `authorization`, `token`, `apiKey` and friends. Pass
196
+ * `{ defaultKeys: false }` to opt out.
197
+ *
198
+ * Paths run first so a function censor sees the original value rather than a
199
+ * mask; leaves the path pass already censored are skipped by the key pass.
200
+ *
201
+ * The original entry is never mutated — channels and other call sites can
202
+ * hold references to the input safely. Returns the input **by reference**
203
+ * when nothing matched, so the fast path stays allocation-free.
204
+ *
205
+ * ## Residual gaps (by design, documented rather than silently absent)
73
206
  *
74
- * No-op (returns the input by reference) when `config` is `undefined` or
75
- * its `paths` array is empty, so the fast path stays fast.
207
+ * - **Secrets interpolated into a `message` string** (`` `token=${t}` ``)
208
+ * cannot be reached neither a path nor a key names a substring.
209
+ * - **`Map`/`Set`/`Buffer` contents** are not traversed (see {@link isOpaque}).
210
+ * - **Non-enumerable / getter-backed properties** are not walked, so an HTTP
211
+ * client that exposes request config behind a getter still slips through.
212
+ * Enumerable ones (axios's `.config`, `.response`) *are* covered.
76
213
  */
77
214
  function applyRedact(data, config) {
78
- if (!config || config.paths.length === 0) return data;
79
- const censor = config.censor ?? "[REDACTED]";
80
- const cloned = cloneEntry(data);
81
- for (const pattern of config.paths) {
82
- const segments = pattern.split(".").filter((segment) => segment.length > 0);
83
- if (segments.length === 0) continue;
84
- redactAtPath(cloned, segments, censor, []);
215
+ const paths = config?.paths ?? [];
216
+ const keys = resolveRedactKeys(config);
217
+ if (paths.length === 0 && !keys) return data;
218
+ const censor = config?.censor ?? DEFAULT_CENSOR;
219
+ let result = data;
220
+ const censored = /* @__PURE__ */ new Set();
221
+ if (paths.length > 0) {
222
+ result = cloneEntry(data);
223
+ for (const pattern of paths) {
224
+ const segments = pattern.split(".").filter((segment) => segment.length > 0);
225
+ if (segments.length === 0) continue;
226
+ redactAtPath(result, segments, censor, [], censored);
227
+ }
85
228
  }
86
- return cloned;
229
+ if (keys && hasDenylistedKey(result, keys)) result = cloneEntry(result, /* @__PURE__ */ new WeakMap(), {
230
+ keys,
231
+ censor,
232
+ alreadyCensored: censored
233
+ });
234
+ return result;
87
235
  }
88
236
  /**
89
237
  * Combine two redact configs into one effective config. Used to merge a
90
238
  * channel's additive paths on top of the logger-wide floor.
91
239
  *
92
- * - `paths` are concatenated; duplicates are kept (the matcher tolerates
93
- * them, and de-duping cross-config would mask a developer typo).
240
+ * - `paths` and `keys` are concatenated; duplicates are kept (the matcher
241
+ * tolerates them, and de-duping cross-config would mask a developer typo).
94
242
  * - `censor` from the channel wins; falls back to the logger's; falls back
95
243
  * to the default `"[REDACTED]"`.
244
+ * - `defaultKeys` follows the additive-only contract: a channel can turn the
245
+ * built-in denylist back *on* (`true`) but can never turn off one the
246
+ * logger-wide floor left enabled. When the channel is silent, the logger's
247
+ * choice is inherited — opting out logger-wide is not quietly undone by
248
+ * any channel that happens to set a `redact` option.
96
249
  */
97
250
  function mergeRedact(base, extra) {
98
251
  if (!base && !extra) return void 0;
99
- if (!base) return extra;
252
+ if (!base) return extra.defaultKeys === false ? {
253
+ ...extra,
254
+ defaultKeys: true
255
+ } : extra;
100
256
  if (!extra) return base;
101
257
  return {
102
- paths: [...base.paths, ...extra.paths],
258
+ paths: [...base.paths ?? [], ...extra.paths ?? []],
259
+ keys: [...base.keys ?? [], ...extra.keys ?? []],
260
+ defaultKeys: extra.defaultKeys === true ? true : base.defaultKeys,
103
261
  censor: extra.censor ?? base.censor
104
262
  };
105
263
  }
106
264
 
107
265
  //#endregion
108
- export { applyRedact, mergeRedact };
266
+ export { applyRedact, mergeRedact, resolveRedactKeys };
109
267
  //# sourceMappingURL=redact.mjs.map
@@ -1 +1 @@
1
- {"version":3,"file":"redact.mjs","names":[],"sources":["../../../../../../../logger/src/redact/redact.ts"],"sourcesContent":["import type { LoggingData, RedactCensor, RedactConfig } from \"../types\";\n\n/**\n * Deep-clone a value with structural fidelity for log entries — handles plain\n * objects, arrays, `Date`, `Error`, and primitives. Anything else is copied\n * by reference (we only redact paths through plain objects/arrays anyway,\n * and rebuilding e.g. a `Buffer` would change semantics).\n *\n * Purpose-built rather than reaching for `structuredClone`: `Error` instances\n * lose their `message`/`stack` under `structuredClone` in some Node versions,\n * and the logger pipeline carries them often.\n */\nfunction cloneEntry<T>(value: T, seen = new WeakMap<object, any>()): T {\n if (value === null || typeof value !== \"object\") {\n return value;\n }\n\n if (seen.has(value as unknown as object)) {\n return seen.get(value as unknown as object);\n }\n\n if (value instanceof Date) {\n return new Date(value.getTime()) as unknown as T;\n }\n\n if (value instanceof Error) {\n const copy = new (value.constructor as ErrorConstructor)(value.message);\n copy.stack = value.stack;\n copy.name = value.name;\n return copy as unknown as T;\n }\n\n if (Array.isArray(value)) {\n const arr: any[] = [];\n seen.set(value as unknown as object, arr);\n for (const item of value) {\n arr.push(cloneEntry(item, seen));\n }\n return arr as unknown as T;\n }\n\n const out: Record<string, any> = {};\n seen.set(value as unknown as object, out);\n for (const key of Object.keys(value as Record<string, any>)) {\n out[key] = cloneEntry((value as Record<string, any>)[key], seen);\n }\n return out as unknown as T;\n}\n\n/**\n * Apply a single censor decision to a value. String censors are returned\n * verbatim; function censors receive the original value plus the dotted\n * path so callers can implement value-aware redaction (mask all but the\n * last 4 chars, hash, etc.).\n */\nfunction applyCensor(value: any, censor: RedactCensor, path: string[]): any {\n if (typeof censor === \"function\") {\n return censor(value, path.join(\".\"));\n }\n return censor;\n}\n\n/**\n * Walk `target` following the remaining `segments` of a path pattern,\n * replacing matched leaves via `censor`. Operates in place — the caller\n * is responsible for cloning before calling.\n *\n * Wildcards:\n * - `*` matches exactly one segment (any key on a plain object, any index\n * on an array — stringified for the path that's passed to a function\n * censor).\n * - `**` matches zero or more segments greedily; the rest of the pattern\n * is then attempted at the current level and at every descendant.\n */\nfunction redactAtPath(\n target: any,\n segments: string[],\n censor: RedactCensor,\n pathTrail: string[],\n): void {\n if (target === null || typeof target !== \"object\") {\n return;\n }\n\n if (segments.length === 0) {\n return;\n }\n\n const [head, ...rest] = segments;\n\n if (head === \"**\") {\n // Try matching `rest` at the current level (the zero-segment match\n // case), then recurse into every child carrying the `**` forward so\n // it keeps matching at deeper levels too.\n if (rest.length > 0) {\n redactAtPath(target, rest, censor, pathTrail);\n }\n const keys = Array.isArray(target)\n ? target.map((_, index) => String(index))\n : Object.keys(target);\n for (const key of keys) {\n redactAtPath(target[key], segments, censor, [...pathTrail, key]);\n }\n return;\n }\n\n const keysToVisit =\n head === \"*\"\n ? Array.isArray(target)\n ? target.map((_, index) => String(index))\n : Object.keys(target)\n : Array.isArray(target)\n ? // Numeric segment can index into an array.\n /^\\d+$/.test(head) && Number(head) < target.length\n ? [head]\n : []\n : Object.prototype.hasOwnProperty.call(target, head)\n ? [head]\n : [];\n\n for (const key of keysToVisit) {\n if (rest.length === 0) {\n target[key] = applyCensor(target[key], censor, [...pathTrail, key]);\n } else {\n redactAtPath(target[key], rest, censor, [...pathTrail, key]);\n }\n }\n}\n\n/**\n * Produce a new `LoggingData` with every path in `config.paths` replaced\n * by `config.censor`. The original entry is never mutated — channels and\n * other call sites can hold references to the input safely.\n *\n * No-op (returns the input by reference) when `config` is `undefined` or\n * its `paths` array is empty, so the fast path stays fast.\n */\nexport function applyRedact(\n data: LoggingData,\n config: RedactConfig | undefined,\n): LoggingData {\n if (!config || config.paths.length === 0) {\n return data;\n }\n\n const censor = config.censor ?? \"[REDACTED]\";\n const cloned = cloneEntry(data);\n\n for (const pattern of config.paths) {\n const segments = pattern.split(\".\").filter((segment) => segment.length > 0);\n if (segments.length === 0) continue;\n redactAtPath(cloned, segments, censor, []);\n }\n\n return cloned;\n}\n\n/**\n * Combine two redact configs into one effective config. Used to merge a\n * channel's additive paths on top of the logger-wide floor.\n *\n * - `paths` are concatenated; duplicates are kept (the matcher tolerates\n * them, and de-duping cross-config would mask a developer typo).\n * - `censor` from the channel wins; falls back to the logger's; falls back\n * to the default `\"[REDACTED]\"`.\n */\nexport function mergeRedact(\n base: RedactConfig | undefined,\n extra: RedactConfig | undefined,\n): RedactConfig | undefined {\n if (!base && !extra) return undefined;\n if (!base) return extra;\n if (!extra) return base;\n\n return {\n paths: [...base.paths, ...extra.paths],\n censor: extra.censor ?? base.censor,\n };\n}\n"],"mappings":"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"}
1
+ {"version":3,"file":"redact.mjs","names":[],"sources":["../../../../../../../logger/src/redact/redact.ts"],"sourcesContent":["import type { LoggingData, RedactCensor, RedactConfig } from \"../types\";\nimport {\n DEFAULT_REDACT_KEY_SET,\n normalizeRedactKey,\n} from \"./default-keys\";\n\nconst DEFAULT_CENSOR = \"[REDACTED]\";\n\n/**\n * Per-key redaction settings threaded through the clone walk. Absent when the\n * walk is a plain structural clone (path-only redaction).\n */\ntype KeyRedaction = {\n /** Normalized key names to censor wherever they appear. */\n keys: ReadonlySet<string>;\n censor: RedactCensor;\n /**\n * Dotted paths the path-glob pass already censored. Skipped here so a\n * function censor is never invoked twice on the same leaf (the second call\n * would receive the already-masked value, not the original).\n */\n alreadyCensored: ReadonlySet<string>;\n};\n\n/**\n * Values we copy by reference rather than walking. Expanding these with\n * `Object.keys` would be destructive, not protective — a `Buffer` becomes a\n * multi-thousand-key index map, a `Map`/`Set`/`RegExp` becomes `{}`. Their\n * contents are consequently *not* reachable by redaction; see the residual\n * gaps documented on `applyRedact`.\n */\nfunction isOpaque(value: object): boolean {\n return (\n ArrayBuffer.isView(value) ||\n value instanceof ArrayBuffer ||\n value instanceof Map ||\n value instanceof Set ||\n value instanceof WeakMap ||\n value instanceof WeakSet ||\n value instanceof Promise ||\n value instanceof RegExp\n );\n}\n\n/**\n * Deep-clone a value with structural fidelity for log entries — handles plain\n * objects, arrays, `Date`, `Error`, and primitives. Anything else (buffers,\n * maps, sets, promises, regexes) is copied by reference: we only redact\n * through walkable structures anyway, and rebuilding e.g. a `Buffer` would\n * change semantics.\n *\n * Purpose-built rather than reaching for `structuredClone`: `Error` instances\n * lose their `message`/`stack` under `structuredClone` in some Node versions,\n * and the logger pipeline carries them often.\n *\n * When `redaction` is supplied, keys matching its denylist are censored during\n * the same pass — one traversal, not two.\n */\nfunction cloneEntry<T>(\n value: T,\n seen = new WeakMap<object, any>(),\n redaction?: KeyRedaction,\n trail: string[] = [],\n): T {\n if (value === null || typeof value !== \"object\") {\n return value;\n }\n\n const asObject = value as unknown as object;\n\n if (seen.has(asObject)) {\n return seen.get(asObject);\n }\n\n if (value instanceof Date) {\n return new Date(value.getTime()) as unknown as T;\n }\n\n if (isOpaque(asObject)) {\n return value;\n }\n\n if (value instanceof Error) {\n return cloneError(value, seen, redaction, trail) as unknown as T;\n }\n\n if (Array.isArray(value)) {\n const arr: any[] = [];\n seen.set(asObject, arr);\n value.forEach((item, index) => {\n // Array indices are positions, not names — never key-matched, but the\n // trail still carries them so function censors see a usable path.\n arr.push(cloneEntry(item, seen, redaction, [...trail, String(index)]));\n });\n return arr as unknown as T;\n }\n\n const out: Record<string, any> = {};\n seen.set(asObject, out);\n copyOwnKeys(value as Record<string, any>, out, seen, redaction, trail);\n return out as unknown as T;\n}\n\n/**\n * Copy every own enumerable key from `source` onto `target`, censoring the\n * ones the key denylist matches and recursing into the rest.\n */\nfunction copyOwnKeys(\n source: Record<string, any>,\n target: Record<string, any>,\n seen: WeakMap<object, any>,\n redaction: KeyRedaction | undefined,\n trail: string[],\n): void {\n for (const key of Object.keys(source)) {\n const childTrail = [...trail, key];\n\n if (\n redaction &&\n redaction.keys.has(normalizeRedactKey(key)) &&\n !redaction.alreadyCensored.has(childTrail.join(\".\"))\n ) {\n target[key] = applyCensor(source[key], redaction.censor, childTrail);\n continue;\n }\n\n target[key] = cloneEntry(source[key], seen, redaction, childTrail);\n }\n}\n\n/**\n * Clone an `Error`, preserving its own enumerable properties.\n *\n * Those extra properties matter for redaction: HTTP clients (axios, got)\n * attach `.config`/`.request`/`.response` to the errors they throw, and those\n * routinely carry the *outgoing* `Authorization` header of the failed request.\n * Copying them through the walk is what lets the key denylist reach in and\n * censor them — dropping them instead (the pre-4.15.0 behavior) hid the\n * secret only when redaction happened to be configured, and took `.code` and\n * friends with it.\n *\n * `name`/`message`/`stack` are non-enumerable on `Error`, so they are carried\n * over explicitly.\n */\nfunction cloneError(\n value: Error,\n seen: WeakMap<object, any>,\n redaction: KeyRedaction | undefined,\n trail: string[],\n): Error {\n let copy: Error;\n\n try {\n copy = new (value.constructor as ErrorConstructor)(value.message);\n } catch {\n // A subclass whose constructor demands a different signature must not\n // take the whole log call down with it.\n copy = new Error(value.message);\n }\n\n // Assigned rather than trusted to the constructor: a subclass that derives\n // its message from a structured argument (`new HttpError({ detail })`)\n // produces an empty message when replayed with a plain string. Defined\n // non-enumerably to match native `Error`, so the clone serializes with the\n // same shape as the original.\n for (const [key, source] of [\n [\"message\", value.message],\n [\"name\", value.name],\n [\"stack\", value.stack],\n ] as const) {\n Object.defineProperty(copy, key, {\n value: source,\n writable: true,\n enumerable: false,\n configurable: true,\n });\n }\n\n seen.set(value, copy);\n copyOwnKeys(value as unknown as Record<string, any>, copy as any, seen, redaction, trail);\n\n return copy;\n}\n\n/**\n * Apply a single censor decision to a value. String censors are returned\n * verbatim; function censors receive the original value plus the dotted\n * path so callers can implement value-aware redaction (mask all but the\n * last 4 chars, hash, etc.).\n */\nfunction applyCensor(value: any, censor: RedactCensor, path: string[]): any {\n if (typeof censor === \"function\") {\n return censor(value, path.join(\".\"));\n }\n return censor;\n}\n\n/**\n * Walk `target` following the remaining `segments` of a path pattern,\n * replacing matched leaves via `censor`. Operates in place — the caller\n * is responsible for cloning before calling.\n *\n * Every censored leaf's dotted path is recorded in `censored` so a following\n * key-denylist pass can leave it alone.\n *\n * Wildcards:\n * - `*` matches exactly one segment (any key on a plain object, any index\n * on an array — stringified for the path that's passed to a function\n * censor).\n * - `**` matches zero or more segments greedily; the rest of the pattern\n * is then attempted at the current level and at every descendant.\n */\nfunction redactAtPath(\n target: any,\n segments: string[],\n censor: RedactCensor,\n pathTrail: string[],\n censored: Set<string>,\n): void {\n if (target === null || typeof target !== \"object\") {\n return;\n }\n\n if (segments.length === 0) {\n return;\n }\n\n const [head, ...rest] = segments;\n\n if (head === \"**\") {\n // Try matching `rest` at the current level (the zero-segment match\n // case), then recurse into every child carrying the `**` forward so\n // it keeps matching at deeper levels too.\n if (rest.length > 0) {\n redactAtPath(target, rest, censor, pathTrail, censored);\n }\n const keys = Array.isArray(target)\n ? target.map((_, index) => String(index))\n : Object.keys(target);\n for (const key of keys) {\n redactAtPath(target[key], segments, censor, [...pathTrail, key], censored);\n }\n return;\n }\n\n const keysToVisit =\n head === \"*\"\n ? Array.isArray(target)\n ? target.map((_, index) => String(index))\n : Object.keys(target)\n : Array.isArray(target)\n ? // Numeric segment can index into an array.\n /^\\d+$/.test(head) && Number(head) < target.length\n ? [head]\n : []\n : Object.prototype.hasOwnProperty.call(target, head)\n ? [head]\n : [];\n\n for (const key of keysToVisit) {\n if (rest.length === 0) {\n const leafTrail = [...pathTrail, key];\n target[key] = applyCensor(target[key], censor, leafTrail);\n censored.add(leafTrail.join(\".\"));\n } else {\n redactAtPath(target[key], rest, censor, [...pathTrail, key], censored);\n }\n }\n}\n\n/**\n * Cheap pre-scan: does this graph contain any denylisted key at all?\n *\n * Lets the default-on key pass stay allocation-free for the overwhelming\n * majority of entries, which carry no secrets — we only pay for a clone when\n * there is actually something to censor. Also keeps `applyRedact`'s\n * \"returns the input by reference when nothing changed\" contract intact.\n */\nfunction hasDenylistedKey(\n value: any,\n keys: ReadonlySet<string>,\n seen = new WeakSet<object>(),\n): boolean {\n if (value === null || typeof value !== \"object\") {\n return false;\n }\n\n const asObject = value as object;\n\n if (seen.has(asObject)) return false;\n seen.add(asObject);\n\n if (value instanceof Date || isOpaque(asObject)) {\n return false;\n }\n\n if (Array.isArray(value)) {\n return value.some((item) => hasDenylistedKey(item, keys, seen));\n }\n\n for (const key of Object.keys(value)) {\n if (keys.has(normalizeRedactKey(key))) return true;\n if (hasDenylistedKey(value[key], keys, seen)) return true;\n }\n\n return false;\n}\n\n/**\n * Cache of resolved key sets, keyed by the config object they came from.\n * Logger-wide and channel configs are long-lived references, so this makes\n * the per-entry cost a single map lookup. Merged configs (rebuilt per entry\n * by `mergeRedact`) fall out of the `WeakMap` on their own.\n */\nconst keySetCache = new WeakMap<RedactConfig, ReadonlySet<string>>();\n\n/**\n * Resolve the effective key denylist for a config: the built-in set (unless\n * `defaultKeys: false`) plus any `keys` the application added.\n *\n * Returns `undefined` only when there is nothing to match — i.e. defaults are\n * explicitly off and no custom keys were supplied.\n */\nexport function resolveRedactKeys(\n config: RedactConfig | undefined,\n): ReadonlySet<string> | undefined {\n const useDefaults = config?.defaultKeys !== false;\n const extra = config?.keys;\n\n if (!extra || extra.length === 0) {\n return useDefaults ? DEFAULT_REDACT_KEY_SET : undefined;\n }\n\n const cached = config && keySetCache.get(config);\n if (cached) return cached;\n\n const resolved = new Set<string>(useDefaults ? DEFAULT_REDACT_KEY_SET : []);\n for (const key of extra) {\n resolved.add(normalizeRedactKey(key));\n }\n\n if (config) keySetCache.set(config, resolved);\n\n return resolved;\n}\n\n/**\n * Produce a new `LoggingData` with sensitive data censored:\n *\n * 1. every path in `config.paths` (opt-in globs), then\n * 2. every key matching the denylist — the built-in\n * {@link DEFAULT_REDACT_KEYS} plus `config.keys`, at any depth of\n * `context`, `message`, and an `Error`'s own enumerable properties.\n *\n * Step 2 runs **with no config at all**: passing `undefined` still censors\n * `password`, `authorization`, `token`, `apiKey` and friends. Pass\n * `{ defaultKeys: false }` to opt out.\n *\n * Paths run first so a function censor sees the original value rather than a\n * mask; leaves the path pass already censored are skipped by the key pass.\n *\n * The original entry is never mutated — channels and other call sites can\n * hold references to the input safely. Returns the input **by reference**\n * when nothing matched, so the fast path stays allocation-free.\n *\n * ## Residual gaps (by design, documented rather than silently absent)\n *\n * - **Secrets interpolated into a `message` string** (`` `token=${t}` ``)\n * cannot be reached — neither a path nor a key names a substring.\n * - **`Map`/`Set`/`Buffer` contents** are not traversed (see {@link isOpaque}).\n * - **Non-enumerable / getter-backed properties** are not walked, so an HTTP\n * client that exposes request config behind a getter still slips through.\n * Enumerable ones (axios's `.config`, `.response`) *are* covered.\n */\nexport function applyRedact(\n data: LoggingData,\n config: RedactConfig | undefined,\n): LoggingData {\n const paths = config?.paths ?? [];\n const keys = resolveRedactKeys(config);\n\n if (paths.length === 0 && !keys) {\n return data;\n }\n\n const censor = config?.censor ?? DEFAULT_CENSOR;\n\n let result = data;\n const censored = new Set<string>();\n\n if (paths.length > 0) {\n result = cloneEntry(data);\n\n for (const pattern of paths) {\n const segments = pattern.split(\".\").filter((segment) => segment.length > 0);\n if (segments.length === 0) continue;\n redactAtPath(result, segments, censor, [], censored);\n }\n }\n\n if (keys && hasDenylistedKey(result, keys)) {\n result = cloneEntry(result, new WeakMap(), {\n keys,\n censor,\n alreadyCensored: censored,\n });\n }\n\n return result;\n}\n\n/**\n * Combine two redact configs into one effective config. Used to merge a\n * channel's additive paths on top of the logger-wide floor.\n *\n * - `paths` and `keys` are concatenated; duplicates are kept (the matcher\n * tolerates them, and de-duping cross-config would mask a developer typo).\n * - `censor` from the channel wins; falls back to the logger's; falls back\n * to the default `\"[REDACTED]\"`.\n * - `defaultKeys` follows the additive-only contract: a channel can turn the\n * built-in denylist back *on* (`true`) but can never turn off one the\n * logger-wide floor left enabled. When the channel is silent, the logger's\n * choice is inherited — opting out logger-wide is not quietly undone by\n * any channel that happens to set a `redact` option.\n */\nexport function mergeRedact(\n base: RedactConfig | undefined,\n extra: RedactConfig | undefined,\n): RedactConfig | undefined {\n if (!base && !extra) return undefined;\n\n if (!base) {\n // An absent logger-wide config still means \"built-in denylist on\" — it is\n // the floor, not the absence of one. A channel may not switch it off, so\n // its `defaultKeys: false` is overridden here rather than inherited.\n return extra!.defaultKeys === false\n ? { ...extra!, defaultKeys: true }\n : extra;\n }\n\n if (!extra) return base;\n\n return {\n paths: [...(base.paths ?? []), ...(extra.paths ?? [])],\n keys: [...(base.keys ?? []), ...(extra.keys ?? [])],\n defaultKeys: extra.defaultKeys === true ? true : base.defaultKeys,\n censor: extra.censor ?? base.censor,\n };\n}\n"],"mappings":"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"}
package/esm/types.d.mts CHANGED
@@ -28,24 +28,36 @@ type RedactCensor = string | ((value: any, path: string) => any);
28
28
  /**
29
29
  * Strip sensitive fields from log entries before they reach a channel.
30
30
  *
31
- * Paths are dotted glob patterns evaluated against the `LoggingData` itself
32
- * use `context.password`, `message.token`, etc. Wildcards:
31
+ * Two independent matchers, both applied at the same choke point (so every
32
+ * channel inherits them):
33
33
  *
34
- * - `*` — matches a single segment (any one key)
35
- * - `**` matches zero or more segments (any depth, any key)
34
+ * 1. **Key denylist** (`keys` + the built-in `DEFAULT_REDACT_KEYS`) — matches
35
+ * by *key name* at any depth, case- and separator-insensitively. **On by
36
+ * default**, with no configuration: `password`, `authorization`, `apiKey`,
37
+ * `token`, `cookie`, … are censored out of the box.
38
+ * 2. **Path globs** (`paths`) — opt-in, dotted patterns evaluated against the
39
+ * `LoggingData` itself (`context.password`, `message.token`). Wildcards:
40
+ * - `*` — matches a single segment (any one key)
41
+ * - `**` — matches zero or more segments (any depth, any key)
36
42
  *
37
43
  * Configurable in two places:
38
44
  *
39
45
  * 1. **Logger-wide** via `Logger.configure({ redact })` — applied once before
40
46
  * fan-out. This is the security floor; no channel can undo it.
41
- * 2. **Per channel** via the channel's options. Channel paths are *additive*:
42
- * they extend (never replace) the logger-wide list, so a channel can only
43
- * redact more, never less.
47
+ * 2. **Per channel** via the channel's options. Channel paths/keys are
48
+ * *additive*: they extend (never replace) the logger-wide list, so a
49
+ * channel can only redact more, never less.
50
+ *
51
+ * @example
52
+ * // Nothing to configure for the common secrets — this is the default:
53
+ * log.info("auth", "login", "ok", { password: "hunter2" });
54
+ * // channel sees { password: "[REDACTED]" }
44
55
  *
45
56
  * @example
46
57
  * logger.configure({
47
58
  * redact: {
48
- * paths: ["context.password", "context.*.token", "context.headers.authorization"],
59
+ * keys: ["internalRef"], // extends the built-in denylist
60
+ * paths: ["context.*.token", "context.headers.authorization"],
49
61
  * censor: "[REDACTED]",
50
62
  * },
51
63
  * });
@@ -55,10 +67,34 @@ type RedactConfig = {
55
67
  * Glob path patterns to redact. Paths are evaluated against the full
56
68
  * `LoggingData` object — so prefix with `context.` or `message.` to scope
57
69
  * to either field.
70
+ *
71
+ * Optional: omit it to rely on key-based redaction alone.
72
+ */
73
+ paths?: string[];
74
+ /**
75
+ * Extra key names to censor anywhere they appear, in addition to the
76
+ * built-in {@link DEFAULT_REDACT_KEYS}. Matched case- and
77
+ * separator-insensitively on the normalized key, so `"internal_ref"`,
78
+ * `"internalRef"` and `"INTERNAL-REF"` are one entry.
79
+ */
80
+ keys?: string[];
81
+ /**
82
+ * Set to `false` to drop the built-in secret-key denylist and rely solely
83
+ * on `paths`/`keys`.
84
+ *
85
+ * This is an escape hatch, not a tuning knob — turning it off restores the
86
+ * pre-4.15.0 behavior where a `password` in `context` reaches every sink in
87
+ * cleartext. Prefer narrowing with a function `censor` over disabling.
88
+ *
89
+ * A *channel* may set this to `true` to re-enable defaults the logger-wide
90
+ * config turned off (channels can only redact more, never less); a channel
91
+ * setting it to `false` cannot disable a logger-wide default.
92
+ *
93
+ * @default true
58
94
  */
59
- paths: string[];
95
+ defaultKeys?: boolean;
60
96
  /**
61
- * Replacement applied at each matched path.
97
+ * Replacement applied at each matched path or key.
62
98
  *
63
99
  * @default "[REDACTED]"
64
100
  */
@@ -88,8 +124,9 @@ type BasicLogConfigurations = {
88
124
  context?: (data: LoggingData) => Promise<Record<string, any>>;
89
125
  /**
90
126
  * Channel-specific redaction. Additive on top of the logger-wide config —
91
- * the channel's paths extend (never replace) the logger floor. The
92
- * `censor` here, when omitted, falls back to the logger-wide censor.
127
+ * the channel's paths/keys extend (never replace) the logger floor. The
128
+ * `censor` here, when omitted, falls back to the logger-wide censor. The
129
+ * built-in key denylist applies to every channel whether or not this is set.
93
130
  */
94
131
  redact?: RedactConfig;
95
132
  };
@@ -1 +1 @@
1
- {"version":3,"file":"types.d.mts","names":[],"sources":["../../../../../../logger/src/types.ts"],"mappings":";;AASA;;;;AAAoB;AAUpB;;;KAVY,QAAA;AAUc;AAQ1B;;;;AAAqB;AAOrB;;AAf0B,KAAd,cAAA;AAAA,KAQA,SAAA;AASkB;AA2B9B;;;;AA3B8B,KAFlB,YAAA,cAEN,KAAA,OAAY,IAAY;;;;AAuCP;AAGvB;;;;;;;;;;;;;;;;;;;;;KAfY,YAAA;EAoCuB;;;;;EA9BjC,KAAA;EAuCU;;;;;EAjCV,MAAA,GAAS,YAAY;AAAA;AAAA,KAGX,sBAAA;EAkCV;;;;;EA5BA,MAAA,GAAS,QAAA;EAgCA;AAAA;AAGX;EA/BE,UAAA;IACE,IAAA;IACA,IAAA;EAAA;EAwDe;;;EAnDjB,MAAA,IAAU,IAAA,EAAM,WAAA;EAiChB;;;EA7BA,OAAA,IAAW,IAAA,EAAM,WAAA,KAAgB,OAAA,CAAQ,MAAA;EAuCrC;;;;;EAjCJ,MAAA,GAAS,YAAA;AAAA;AAAA,KAGC,UAAA;EACV,OAAA;EACA,KAAA,EAAO,QAAA;EACP,IAAA;EACA,MAAA;EACA,MAAA;EACA,KAAA;EACA,OAAA,GAAU,MAAM;EAChB,SAAA;AAAA;AAAA,UAGe,WAAA;EAwCC;AAAA;AAGlB;EAvCE,IAAA;;;AAuC+C;EAlC/C,WAAA;;;;EAKA,QAAA;;;;EAKA,GAAA,CAAI,IAAA,EAAM,WAAA,UAAqB,OAAA;;;;;;;EAQ/B,KAAA,YAAiB,OAAA;;;;EAKjB,SAAA;AAAA;AAAA,KAGU,WAAA;EACV,IAAA,EAAM,QAAA;EACN,MAAA;EACA,MAAA;EACA,OAAA;EACA,OAAA,GAAU,MAAM;AAAA;AAAA,KAGN,kBAAA,GAAqB,IAAI,CAAC,WAAA"}
1
+ {"version":3,"file":"types.d.mts","names":[],"sources":["../../../../../../logger/src/types.ts"],"mappings":";;AASA;;;;AAAoB;AAUpB;;;KAVY,QAAA;AAUc;AAQ1B;;;;AAAqB;AAOrB;;AAf0B,KAAd,cAAA;AAAA,KAQA,SAAA;AASkB;AAuC9B;;;;AAvC8B,KAFlB,YAAA,cAEN,KAAA,OAAY,IAAY;;;;;;AA2EP;AAGvB;;;;;;;;;;;;;;;;;;;;;;;;;;;AA4BuB;AAGvB;;;KAtEY,YAAA;EAuEV;;;;;;;EA/DA,KAAA;EAqEU;;;AACD;AAGX;;EAlEE,IAAA;EAqFU;;;;;;;;;;;;;;EAtEV,WAAA;EAmFS;AAAA;AAGX;;;EAhFE,MAAA,GAAS,YAAY;AAAA;AAAA,KAGX,sBAAA;EA+EV;;;;;EAzEA,MAAA,GAAS,QAAA;EA4EO;AAGlB;;EA3EE,UAAA;IACE,IAAA;IACA,IAAA;EAAA;;;;EAKF,MAAA,IAAU,IAAA,EAAM,WAAA;;;;EAIhB,OAAA,IAAW,IAAA,EAAM,WAAA,KAAgB,OAAA,CAAQ,MAAA;;;;;;;EAOzC,MAAA,GAAS,YAAA;AAAA;AAAA,KAGC,UAAA;EACV,OAAA;EACA,KAAA,EAAO,QAAA;EACP,IAAA;EACA,MAAA;EACA,MAAA;EACA,KAAA;EACA,OAAA,GAAU,MAAM;EAChB,SAAA;AAAA;AAAA,UAGe,WAAA;;;;EAIf,IAAA;;;;EAKA,WAAA;;;;EAKA,QAAA;;;;EAKA,GAAA,CAAI,IAAA,EAAM,WAAA,UAAqB,OAAA;;;;;;;EAQ/B,KAAA,YAAiB,OAAA;;;;EAKjB,SAAA;AAAA;AAAA,KAGU,WAAA;EACV,IAAA,EAAM,QAAA;EACN,MAAA;EACA,MAAA;EACA,OAAA;EACA,OAAA,GAAU,MAAM;AAAA;AAAA,KAGN,kBAAA,GAAqB,IAAI,CAAC,WAAA"}
package/llms-full.txt CHANGED
@@ -822,14 +822,48 @@ If two channels share a `name`, only one is reachable this way — the search re
822
822
 
823
823
  ---
824
824
  name: redact-sensitive-log-fields
825
- description: 'Strip secrets from log output — two-layer additive redaction via log.configure({redact: {paths}}) (logger floor) + per-channel redact (more paths on top). Dotted glob paths (*, **). Triggers: `redact`, `paths`, `censor`, `log.setRedact`, `applyRedact`; "redact passwords in logs", "strip tokens from log output", "hide authorization headers", "scrub PII before logging"; typical import `import { log } from "@warlock.js/logger"`. Skip: filtering — `@warlock.js/logger/filter-log-entries/SKILL.md`; custom sinks — `@warlock.js/logger/write-custom-log-channel/SKILL.md`; competing libs `pino.redact`, `fast-redact`.'
825
+ description: 'Strip secrets from log output — a built-in secret-key denylist on by default (DEFAULT_REDACT_KEYS), plus two-layer additive redaction via log.configure({redact: {paths, keys}}) (logger floor) + per-channel redact (more on top). Dotted glob paths (*, **). Triggers: `redact`, `paths`, `keys`, `defaultKeys`, `censor`, `log.setRedact`, `applyRedact`; "redact passwords in logs", "strip tokens from log output", "hide authorization headers", "scrub PII before logging", "turn off default redaction"; typical import `import { log } from "@warlock.js/logger"`. Skip: filtering — `@warlock.js/logger/filter-log-entries/SKILL.md`; custom sinks — `@warlock.js/logger/write-custom-log-channel/SKILL.md`; competing libs `pino.redact`, `fast-redact`.'
826
826
  ---
827
827
 
828
828
  # Redaction — keeping secrets out of logs
829
829
 
830
- Two layers, both opt-in. Configured at the logger and/or per channel.
830
+ Three layers: a **built-in key denylist that is on by default**, plus opt-in path globs configured at the logger and/or per channel.
831
831
 
832
- ## The model in one line
832
+ ## Layer 0 the default denylist (since 4.15.0, no configuration needed)
833
+
834
+ Common secret **key names** are censored at any depth of `context`, `message`, and an `Error`'s own enumerable properties — before any of your config runs:
835
+
836
+ ```ts
837
+ log.error("auth", "login", "failed", { headers: req.headers, body: req.body });
838
+ // context.headers.authorization → "[REDACTED]"
839
+ // context.body.password → "[REDACTED]"
840
+ // context.body.email → untouched
841
+ ```
842
+
843
+ Keys are matched **exactly**, on a normalized form (lower-cased, separators stripped) — so one entry covers `apiKey` / `api_key` / `API-KEY` / `x-api-key`. It is not substring matching: `tokenCount` and `passwordUpdatedAt` survive. Read the exact set from `DEFAULT_REDACT_KEYS`.
844
+
845
+ ```ts
846
+ import { DEFAULT_REDACT_KEYS } from "@warlock.js/logger";
847
+
848
+ log.configure({
849
+ redact: {
850
+ keys: ["internalRef"], // union with the built-in set
851
+ defaultKeys: false, // opt out of the built-in set entirely
852
+ },
853
+ });
854
+ ```
855
+
856
+ `defaultKeys: false` is an escape hatch, not a tuning knob — it restores the pre-4.15.0 behavior where a `password` in `context` reaches every sink in cleartext. Prefer adding a function `censor` if you only need to keep a prefix.
857
+
858
+ **A channel cannot turn the default set off** (it can only add keys, or turn it back *on* if the logger-wide config disabled it) — same additive-only contract as paths, below. And `log.setRedact(undefined)` clears *your* paths, not the default denylist.
859
+
860
+ Not reachable by any layer: secrets interpolated into a `message` string (`` `token=${t}` ``), `Map`/`Set`/`Buffer` contents, and getter-backed or non-enumerable properties.
861
+
862
+ ## Layers 1 & 2 — path globs, opt-in
863
+
864
+ For anything the denylist can't name by key — a secret under an app-specific key, or a value you want partially masked rather than blanked.
865
+
866
+ The model in one line:
833
867
 
834
868
  > Logger-wide redaction is the security floor. Per-channel redaction adds more paths. **No channel can ever undo a logger-wide redaction.**
835
869
 
@@ -853,7 +887,7 @@ log.configure({
853
887
 
854
888
  // runtime equivalent:
855
889
  log.setRedact({ paths: ["context.password"] });
856
- log.setRedact(undefined); // clear
890
+ log.setRedact(undefined); // clear your paths (the default denylist stays on)
857
891
  ```
858
892
 
859
893
  Every channel sees the redacted entry. Cheap: applied **once** before fan-out; channels share the redacted clone unless they add their own paths.
@@ -936,7 +970,7 @@ If `message` is a plain object, paths under `message.*` work as expected. If `me
936
970
 
937
971
  ## Performance notes
938
972
 
939
- - **No redact configured** → zero overhead (no clone, no walk).
973
+ - **No redact configured** → the default denylist still runs (since 4.15.0): a cheap presence scan for a denylisted key on every `log()` call, with the deep clone + censor pass skipped entirely when nothing matches. Only `{ defaultKeys: false }` (no `paths`, no extra `keys`) gets back to zero work.
940
974
  - **Logger-wide redact only** → one deep clone + one path-walk per `log()` call, shared by every channel.
941
975
  - **Channel adds paths** → that channel re-clones from the original input and runs the merged pass once. Other channels still share the cheaper logger-wide clone.
942
976
  - Each path is matched independently; cost grows linearly with `paths.length`.
package/llms.txt CHANGED
@@ -13,7 +13,7 @@
13
13
  - [logger-basics](@warlock.js/logger/logger-basics/SKILL.md): (no description)
14
14
  - [overview](@warlock.js/logger/overview/SKILL.md): (no description)
15
15
  - [pick-log-channel](@warlock.js/logger/pick-log-channel/SKILL.md): (no description)
16
- - [redact-sensitive-log-fields](@warlock.js/logger/redact-sensitive-log-fields/SKILL.md): Strip secrets from log output — two-layer additive redaction via log.configure({redact: {paths}}) (logger floor) + per-channel redact (more paths on top). Dotted glob paths (*, **). Triggers: `redact`, `paths`, `censor`, `log.setRedact`, `applyRedact`; "redact passwords in logs", "strip tokens from log output", "hide authorization headers", "scrub PII before logging"; typical import `import { log } from "@warlock.js/logger"`. Skip: filtering — `@warlock.js/logger/filter-log-entries/SKILL.md`; custom sinks — `@warlock.js/logger/write-custom-log-channel/SKILL.md`; competing libs `pino.redact`, `fast-redact`.
16
+ - [redact-sensitive-log-fields](@warlock.js/logger/redact-sensitive-log-fields/SKILL.md): Strip secrets from log output — a built-in secret-key denylist on by default (DEFAULT_REDACT_KEYS), plus two-layer additive redaction via log.configure({redact: {paths, keys}}) (logger floor) + per-channel redact (more on top). Dotted glob paths (*, **). Triggers: `redact`, `paths`, `keys`, `defaultKeys`, `censor`, `log.setRedact`, `applyRedact`; "redact passwords in logs", "strip tokens from log output", "hide authorization headers", "scrub PII before logging", "turn off default redaction"; typical import `import { log } from "@warlock.js/logger"`. Skip: filtering — `@warlock.js/logger/filter-log-entries/SKILL.md`; custom sinks — `@warlock.js/logger/write-custom-log-channel/SKILL.md`; competing libs `pino.redact`, `fast-redact`.
17
17
  - [ship-logs-to-sentry](@warlock.js/logger/ship-logs-to-sentry/SKILL.md): (no description)
18
18
  - [test-logging-code](@warlock.js/logger/test-logging-code/SKILL.md): Test code that touches the logger — silence globally via log.setChannels([]) in setupFiles, assert specific log lines via a capturing LogChannel subclass (prefer it over vi.spyOn — it asserts on delivered entries, not just method calls, and isolates the shared singleton cleanly). Triggers: `log.setChannels`, `LogChannel`, `LoggingData`, `Logger`, `log.channels`; "silence logger in vitest", "assert a log line was emitted", "capture log output in tests", "test code that logs"; typical import `import { log, Logger, LogChannel, type LoggingData } from "@warlock.js/logger"`. Skip: custom sinks — `@warlock.js/logger/write-custom-log-channel/SKILL.md`; filtering — `@warlock.js/logger/filter-log-entries/SKILL.md`; competing `vi.spyOn(console)`, `jest.spyOn`.
19
19
  - [use-log-helpers](@warlock.js/logger/use-log-helpers/SKILL.md): Two DX shortcuts on every Logger — log.assert(condition, module, action, message, context?) logs an error when condition is falsy (free on the happy path), log.timer(module, action) returns an end-function emitting an info entry with measured duration. Triggers: `log.assert`, `log.timer`, `durationMs`; "assert an invariant via logger", "measure how long an operation took", "time a request", "log operation duration"; typical import `import { log } from "@warlock.js/logger"`. Skip: basics — `@warlock.js/logger/logger-basics/SKILL.md`; filtering — `@warlock.js/logger/filter-log-entries/SKILL.md`; competing `console.assert`, `console.time`, `console.timeEnd`, `perf_hooks.performance.now`.
package/package.json CHANGED
@@ -3,8 +3,8 @@
3
3
  "description": "A powerful logging system for messages and errors in nodejs.",
4
4
  "dependencies": {
5
5
  "@mongez/copper": "^2.1.2",
6
- "@mongez/reinforcements": "^3.3.0",
7
- "@warlock.js/fs": "4.15.0",
6
+ "@mongez/reinforcements": "^4.0.1",
7
+ "@warlock.js/fs": "5.0.0",
8
8
  "dayjs": "^1.11.9",
9
9
  "safe-stable-stringify": "^2.5.0"
10
10
  },
@@ -29,7 +29,7 @@
29
29
  },
30
30
  "author": "hassanzohdy",
31
31
  "license": "MIT",
32
- "version": "4.15.0",
32
+ "version": "5.0.0",
33
33
  "main": "./cjs/index.cjs",
34
34
  "module": "./esm/index.mjs",
35
35
  "types": "./esm/index.d.mts",