Redaction
Keep secrets — passwords, tokens, auth headers, PII — out of your logs without sprinkling delete data.password across every call site.
The model
Section titled “The model”Three layers. The first is on by default; the other two are opt-in:
- Built-in key denylist (since 4.15.0) —
DEFAULT_REDACT_KEYScensors common secret key names (passwords, tokens, API/private keys, credential headers, session ids, connection strings, high-sensitivity financial PII) at any depth ofcontext,message, and anError’s own enumerable properties, with no configuration required. Same logger-wide choke point as the layers below, so every channel — including custom ones — inherits it. - Logger-wide floor — set once via
configure({ redact })orlog.setRedact(...). Applied once before fan-out, so every channel inherits it. - Per-channel additive — set on individual channels via the
redactfield. Channel paths extend the floor; a channel can never undo a logger-wide redaction — including the built-in denylist from layer 0.
Layer 0 — the default denylist (no configuration needed)
Section titled “Layer 0 — the default denylist (no configuration needed)”log.error("auth", "login", "failed", { headers: req.headers, body: req.body });// context.headers.authorization → "[REDACTED]"// context.body.password → "[REDACTED]"// context.body.email → untouchedKeys 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 untouched.
import { log, DEFAULT_REDACT_KEYS } from "@warlock.js/logger";
log.configure({ redact: { keys: ["internalRef"], // union with the built-in set defaultKeys: false, // opt out of the built-in set entirely },});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 censor function if you only need to mask a value rather than blank it. As with paths, a channel cannot turn the default denylist off — it can only add keys, or turn it back on if the logger-wide config disabled it.
Not reachable by any layer: secrets interpolated into a message string (`token=${t}`), Map/Set/Buffer contents, and getter-backed or non-enumerable properties.
Logger-wide floor
Section titled “Logger-wide floor”import { log, ConsoleLog, FileLog } from "@warlock.js/logger";
log.configure({ channels: [new ConsoleLog(), new FileLog({ chunk: "daily" })], redact: { paths: ["context.password", "context.*.token", "context.headers.authorization"], censor: "[REDACTED]", // optional — string or function. Default "[REDACTED]" },});Or at runtime:
log.setRedact({ paths: ["context.password"] });log.setRedact(undefined); // clear the floorRead the active config with log.getRedact().
Per-channel additive
Section titled “Per-channel additive”A channel can redact more paths than the floor — useful when a destination has a broader audience than the rest of your channels.
import { log, ConsoleLog, FileLog } from "@warlock.js/logger";
log.configure({ channels: [ new ConsoleLog({ showContext: true }), // floor only new FileLog({ chunk: "daily" }), // floor only new MyAlertingChannel({ // floor + own paths redact: { paths: ["context.user.email", "context.user.phone", "context.metadata.*"], censor: "***", }, }), ], redact: { paths: ["context.password", "context.*.token"], censor: "[REDACTED]", },});For log.info("auth", "login", "ok", { user: { email: "x@y.com" }, password: "hunter2" }):
| Channel | context.password | context.user.email |
|---|---|---|
ConsoleLog (floor only) | [REDACTED] | x@y.com |
FileLog (floor only) | [REDACTED] | x@y.com |
MyAlertingChannel (floor + own) | *** | *** |
When a channel provides its own censor, it overrides the logger censor for both sets of paths in that channel only — the floor’s censor still applies on the other channels.
Path syntax
Section titled “Path syntax”Paths are dotted glob patterns, evaluated against the full LoggingData object (prefix with context. or message. to scope).
| Pattern | Matches |
|---|---|
context.password | exactly data.context.password |
context.*.token | data.context.<any>.token (one segment in between) |
context.users.*.token | array elements (* matches indices) |
**.password | data.context.password, data.context.user.password, … any depth |
message.apiKey | when message is an object, data.message.apiKey |
Wildcards:
*— exactly one segment (any object key, any array index).**— zero or more segments, greedily; matches at any depth.
Censor variants
Section titled “Censor variants”// Literal string — every match becomes this value.{ censor: "[REDACTED]" }{ censor: "***" }
// Function — receives original value + dotted path, returns the replacement.{ censor: (value, path) => { if (typeof value !== "string") return "[REDACTED]"; return value.length > 4 ? `${value.slice(0, 2)}***${value.slice(-2)}` : "***"; },}Function censors are called for every match — keep them cheap. The path argument is the actual matched location, e.g. "context.users.0.token" for an array hit, so you can branch on it.
Immutability and edge cases
Section titled “Immutability and edge cases”- Your input is never mutated. Entries are cloned when paths/keys match or registered credentials activate the safety pass.
DateandErrorinstances are reconstructed soinstanceofchecks still pass downstream.- Circular references are tolerated — the cloner uses an internal
WeakMapto break cycles. - No-op fast path: the deep clone only happens when something actually matches — either a
pathsglob or a denylisted key (default or custom). With{ defaultKeys: false }and nopaths/keysor registered credentials, nothing runs at all.
What about the message field?
Section titled “What about the message field?”If message is a plain object, paths under message.* work as expected. Key/path redaction cannot find secrets interpolated into strings. Register known literal values with registerLogSecret() (below) to mask those strings, and keep structured sensitive fields in context.
Email addresses are the exception worth handling at the call site: they end up inside message strings and cache keys, where no key can reach them. Mask them before logging with maskEmail / maskEmailsInText (see Utilities); Warlock’s own mail and cache logs already do.
Free text from the client (a user agent, a search term) can carry line breaks
that forge a log line in console and file channels. Pass it through
escapeLogValue() before putting it in a message.
warlock check reports raw
emails, credentials and request bodies in log calls as WL9008.
Performance
Section titled “Performance”| Setup | Cost per log() call |
|---|---|
| Nothing configured | The default denylist still runs: a cheap presence scan for a denylisted key, with the clone + censor pass skipped when nothing matches |
{ defaultKeys: false }, no paths/keys or registered credentials | Zero — fast path, no clone, no scan |
| Logger-wide redact only | One deep clone + one path-walk, shared by every channel |
| 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 |
Cost grows linearly with paths.length. The only pattern that truly scans every key is ** — profile before relying on it at very high volume.
See also
Section titled “See also”- Configuration — wiring
redactthroughconfigure() - Types ·
RedactConfig— the full TypeScript type setRedact/getRedact— the runtime methods
Registered secret values
Section titled “Registered secret values”Since 5.33, register credentials that might accidentally reach a string message:
import { log, registerLogSecret, maskRegisteredLogSecrets } from "@warlock.js/logger";
registerLogSecret("provider-credential", ["vendorPrivateRef"]);log.info("sample", "request", "using provider-credential", { vendorPrivateRef: "another-sensitive-value",});// message: "using [REDACTED]", context.vendorPrivateRef: "[REDACTED]"
const safeText = maskRegisteredLogSecrets("using provider-credential");Registration applies to every Logger instance in this module and remains for the process lifetime, including after credential rotation/removal. Empty values are ignored; duplicate registration is harmless. Registered keys use the same normalization as the default denylist. Literal values use exact substring matching with longer matches first; regular-expression punctuation is treated literally.
Registered credentials add a final safety floor after configured censors. Neither defaultKeys: false, clearing redact configuration, nor a permissive censor can reveal a registered key’s value or a registered literal. The pass protects message/context strings, property names, error messages, stacks and causes, Maps/Sets, and channel failure diagnostics. It clones data without invoking getters or custom serializers and replaces opaque objects (including Buffers) and functions with [REDACTED] whenever any registration is active.
The registry retains literal values in memory; use it for credentials, not arbitrary large payloads. It cannot protect earlier logs, encoded/transformed versions of values, direct console calls, third-party loggers or unknown credentials. @warlock.js/core’s secret store registers encountered names and values automatically. Continue to avoid logging credentials.