From 5d99391638a13bb7ea3a8b98f3ac71e07b9b72cb Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 1 Aug 2026 10:48:46 +0000 Subject: [PATCH 1/3] feat(redact): let redaction policy be computed, not just declared MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Redaction is the only stage that runs before the console write — enrich and drains both run after it, so they can never scrub what already reached stdout. That made it the one place a consumer can act in time, and its policy language was entirely declarative: `replacement` could only be a constant. So any policy needing logic had nowhere to run. The escape hatch was `silent: true` plus a custom drain, which throws away the console sink to gain a transform — not a trade you can make on a platform that ingests stdout. `replacement` now also accepts a function, called with the matched value and its path, so a replacement can be derived from what it replaces — a stable fingerprint keeps requests correlatable without exposing the credential. `transform` covers what per-value replacement cannot: policies conditional on a sibling field, tenant-scoped, or allowlist-shaped. Both run where redaction already ran, so ordering and the documented contract are unchanged. Two deliberate choices: - `transform` runs before the declarative stages, not after, so it sees raw values and `paths` / `builtins` / `patterns` still apply to what it leaves behind. A hook that misses a field is not the last line of defence. - A replacement that throws falls back to `[REDACTED]` rather than emitting the raw value. Degrading to over-redaction is the only safe direction for a stage whose job is to not leak. Function policy cannot cross the build-time config bridges, which serialize to JSON. Rather than drop it silently — the failure mode of #408 and #441 — the Nitro modules warn, and the docs point to declaring it at runtime. Closes #463 Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01Uyng7c1TAGK6eFSBMBE33e --- .changeset/programmable-redaction.md | 24 ++ apps/docs/content/2.learn/6.redaction.md | 66 ++++- .../content/7.reference/1.configuration.md | 2 +- packages/evlog/src/index.ts | 2 + packages/evlog/src/nitro-v3/module.ts | 6 + packages/evlog/src/nitro/module.ts | 6 + packages/evlog/src/redact.ts | 131 ++++++++-- packages/evlog/src/types.ts | 79 +++++- .../test/core/redact-integration.test.ts | 73 ++++++ packages/evlog/test/core/redact.test.ts | 230 +++++++++++++++++- 10 files changed, 593 insertions(+), 26 deletions(-) create mode 100644 .changeset/programmable-redaction.md diff --git a/.changeset/programmable-redaction.md b/.changeset/programmable-redaction.md new file mode 100644 index 00000000..4823cf26 --- /dev/null +++ b/.changeset/programmable-redaction.md @@ -0,0 +1,24 @@ +--- +"evlog": minor +--- + +Make emit-time redaction programmable + +`RedactConfig.replacement` now accepts a function, so a replacement can be derived from the value it replaces instead of being a constant — a stable fingerprint keeps requests correlatable without exposing the credential: + +```ts +initLogger({ + redact: { + patterns: [/\/public\/claim\/([A-Za-z0-9._-]{12,})/g], + replacement: (_match, ctx) => `/public/claim/[tok:${fingerprint(ctx.groups[0])}]`, + }, +}) +``` + +`RedactConfig.transform` covers policies that cannot be expressed declaratively — conditional on a sibling field, tenant-scoped, or allowlist-shaped. It runs before the declarative stages, so it sees raw values and `paths` / `builtins` / `patterns` still apply to whatever it leaves behind. + +Both run where redaction already runs: after the event is built, before the console write and before any drain. Failures are caught and reported like drain failures — a function that throws falls back to `[REDACTED]` rather than emitting the raw value, and a throwing `transform` does not stop the event from being logged. + +Function-valued policy cannot survive the build-time config bridges, which serialize to JSON; the Nitro modules now warn instead of dropping it silently. + +Closes #463 diff --git a/apps/docs/content/2.learn/6.redaction.md b/apps/docs/content/2.learn/6.redaction.md index bf9eb743..0c66ac07 100644 --- a/apps/docs/content/2.learn/6.redaction.md +++ b/apps/docs/content/2.learn/6.redaction.md @@ -130,6 +130,56 @@ evlog: { } ``` +### Computed Replacements + +When the replacement has to be **derived** from the value it replaces, pass a function instead of a string. It runs at the same point as the rest of redaction — before the console write, before any drain. + +The common case is keeping requests correlatable without exposing the credential that identifies them: + +```typescript +initLogger({ + redact: { + patterns: [/\/public\/claim\/([A-Za-z0-9._-]{12,})/g], + replacement: (_match, ctx) => `/public/claim/[tok:${fingerprint(ctx.groups[0])}]`, + }, +}) +// /public/claim/eyJhbGciOi... → /public/claim/[tok:9f3a1c] +``` + +The function receives the matched value and a context object: + +| Field | Type | Description | +|-------|------|-------------| +| `path` | `string` | Dot-notation path from the event root (`user.email`, `items.0.token`) | +| `key` | `string` | Leaf key of the field (`email`) | +| `groups` | `string[]` | Capture groups of the matching `patterns` entry. Only set for `patterns` | + +For `paths`, the matched value is the **whole field value** — any type, since path redaction replaces entire subtrees. For `patterns`, it is the matched substring. + +If the function throws or returns a non-string, redaction falls back to `[REDACTED]` and logs the failure. A broken policy degrades to over-redaction, never to leaking the value it was meant to scrub. + +### Conditional Policies + +Some policies cannot be expressed as a list of paths — redact a field only for certain tenants, only when a sibling field has a given value, or keep an allowlist rather than a denylist. Use `transform`: + +```typescript +initLogger({ + redact: { + transform: (event) => { + if (event.tenant === 'regulated') delete event.query + }, + }, +}) +``` + +`transform` runs **before** `paths`, `builtins`, and `patterns`, so it sees raw values and the declarative rules still apply to whatever it leaves behind — a hook that misses a field is not your last line of defence. Mutate the event in place; it is already a private clone, so the object you logged is never touched. + +It must be synchronous, since it runs on the emit path before the console write. Errors are caught and reported like drain failures: the declarative stages still run and the event is still logged. + +::callout{icon="i-lucide-triangle-alert" color="warning"} +Function-valued `replacement` and `transform` cannot be declared in `nuxt.config.ts` or a Nitro module's options — that config is serialized to JSON at build time, which drops functions. Declare them at runtime with `initLogger()` from a server plugin, or with `createEvlog()`. The modules emit a build-time warning if you do it anyway. +:: + ### Disable Built-ins If you only want custom redaction: @@ -152,7 +202,8 @@ evlog: { | `paths` | `string[]` | `undefined` | Dot-notation paths with globs (`password`, `**.password`, `*_token`, `user.*`) | | `patterns` | `RegExp[]` | `undefined` | Custom regex on string values. Uses flat `replacement` string | | `builtins` | `false \| string[]` | All enabled | `false` disables built-ins. Array selects specific ones | -| `replacement` | `string` | `'[REDACTED]'` | Replacement for paths and custom patterns. Built-ins use smart masking instead | +| `replacement` | `string \| (matched, ctx) => string` | `'[REDACTED]'` | Replacement for paths and custom patterns. Built-ins use smart masking instead. A function computes it from the matched value | +| `transform` | `(event) => void` | `undefined` | Escape hatch for policies that are conditional, tenant-scoped, or allowlist-shaped. Runs before the declarative stages | Available built-in names: `creditCard`, `email`, `ipv4`, `phone`, `jwt`, `bearer`, `iban`. @@ -160,11 +211,14 @@ Available built-in names: `creditCard`, `email`, `ipv4`, `phone`, `jwt`, `bearer Redaction runs inside the emit pipeline, after the wide event is fully built but before any output: -1. **Path redaction** — exact paths and globs replaced with `[REDACTED]` -2. **Smart masking** — built-in patterns scan all string values recursively with partial masking -3. **Pattern redaction** — custom regex patterns scan all string values with flat replacement -4. **Console output** — masked event printed to stdout -5. **Drain** — masked event sent to external services +1. **Transform** — your `transform` hook, if any, sees the raw event first +2. **Path redaction** — exact paths and globs replaced with `[REDACTED]` +3. **Smart masking** — built-in patterns scan all string values recursively with partial masking +4. **Pattern redaction** — custom regex patterns scan all string values with flat replacement +5. **Console output** — masked event printed to stdout +6. **Drain** — masked event sent to external services + +Redaction is the only stage that runs before the console write. `enrich` and drains run after it, so they cannot scrub what has already reached stdout — anything that needs to happen before output belongs in `transform` or a function-valued `replacement`. ::callout{icon="i-lucide-zap" color="info"} Redaction runs **after** the HTTP response is sent, so it adds zero latency to your API responses. diff --git a/apps/docs/content/7.reference/1.configuration.md b/apps/docs/content/7.reference/1.configuration.md index 64818e69..d7b5b61e 100644 --- a/apps/docs/content/7.reference/1.configuration.md +++ b/apps/docs/content/7.reference/1.configuration.md @@ -51,7 +51,7 @@ initLogger({ | `redact` | `boolean \| RedactConfig` | `true` in production | Enabled by default in production. `false` to disable. Object for fine-grained control. See [Auto-Redaction](/learn/redaction) | | `drain` | `(ctx: DrainContext) => void` | `undefined` | Drain callback for sending events to external services | -`RedactConfig` fields (when `redact` is an object): `paths` (dot-notation with globs), `patterns` (regex on string values), `builtins`, `replacement`. Full table in [Auto-Redaction](/learn/redaction#configuration-reference). +`RedactConfig` fields (when `redact` is an object): `paths` (dot-notation with globs), `patterns` (regex on string values), `builtins`, `replacement` (string, or a function computing it from the matched value), `transform` (hook for conditional policies). Full table in [Auto-Redaction](/learn/redaction#configuration-reference). ### `minLevel` vs sampling diff --git a/packages/evlog/src/index.ts b/packages/evlog/src/index.ts index fa227326..6023566d 100644 --- a/packages/evlog/src/index.ts +++ b/packages/evlog/src/index.ts @@ -93,6 +93,8 @@ export type { LogLevel, ParsedError, RedactConfig, + RedactReplacement, + RedactReplacementContext, RegisteredAuditCatalogs, RegisteredErrorCatalogs, RequestLogger, diff --git a/packages/evlog/src/nitro-v3/module.ts b/packages/evlog/src/nitro-v3/module.ts index 0d8c62c0..69ac234c 100644 --- a/packages/evlog/src/nitro-v3/module.ts +++ b/packages/evlog/src/nitro-v3/module.ts @@ -3,6 +3,7 @@ import { fileURLToPath } from 'node:url' import type { Nitro } from 'nitro/types' import type { NitroModuleOptions } from '../nitro' import { prependNitroErrorHandler } from '../nitro' +import { FUNCTION_REDACT_POLICY_WARNING, hasFunctionRedactPolicy } from '../redact' export type { NitroModuleOptions } @@ -38,6 +39,11 @@ export default function evlog(options?: NitroModuleOptions) { ) nitro.options.errorHandler = Array.isArray(handlers) ? handlers : [handlers] + // JSON.stringify below drops function-valued redact policy silently. + if (hasFunctionRedactPolicy(options?.redact)) { + console.warn(FUNCTION_REDACT_POLICY_WARNING) + } + // Inject config into runtimeConfig — works in production where the // plugin is bundled through Nitro's builder and the virtual // runtime-config module resolves correctly. diff --git a/packages/evlog/src/nitro/module.ts b/packages/evlog/src/nitro/module.ts index 957bc90b..4715c680 100644 --- a/packages/evlog/src/nitro/module.ts +++ b/packages/evlog/src/nitro/module.ts @@ -3,6 +3,7 @@ import { fileURLToPath } from 'node:url' import type { Nitro } from 'nitropack' import type { NitroModuleOptions } from '../nitro' import { prependNitroErrorHandler } from '../nitro' +import { FUNCTION_REDACT_POLICY_WARNING, hasFunctionRedactPolicy } from '../redact' export type { NitroModuleOptions } @@ -35,6 +36,11 @@ export default function evlog(options?: NitroModuleOptions) { nitro.options.noExternals = true + // JSON.stringify below drops function-valued redact policy silently. + if (hasFunctionRedactPolicy(options?.redact)) { + console.warn(FUNCTION_REDACT_POLICY_WARNING) + } + // Inject config into runtimeConfig — works in production where the // plugin is bundled through Nitro's builder and the virtual // runtime-config module resolves correctly. diff --git a/packages/evlog/src/redact.ts b/packages/evlog/src/redact.ts index ff87465d..d683495b 100644 --- a/packages/evlog/src/redact.ts +++ b/packages/evlog/src/redact.ts @@ -1,4 +1,4 @@ -import type { RedactConfig } from './types' +import type { RedactConfig, RedactReplacement, RedactReplacementContext, WideEvent } from './types' import { globToRegExp } from './utils' const DEFAULT_REPLACEMENT = '[REDACTED]' @@ -98,13 +98,42 @@ export function matchesRedactPath(fullPath: string, leafKey: string, matchers: R return false } +/** + * Resolve a {@link RedactReplacement} for one matched value. + * + * A throwing or non-string-returning function falls back to the default + * replacement: a broken policy must degrade to over-redaction, never to + * emitting the raw value it was meant to scrub. + */ +function resolveReplacement( + replacement: RedactReplacement, + matched: unknown, + ctx: RedactReplacementContext, +): string { + if (typeof replacement === 'string') return replacement + + try { + const result = replacement(matched, ctx) + if (typeof result !== 'string') { + console.error( + `[evlog] redact replacement returned ${typeof result} for "${ctx.path}", expected string — using ${DEFAULT_REPLACEMENT}`, + ) + return DEFAULT_REPLACEMENT + } + return result + } catch (err) { + console.error(`[evlog] redact replacement failed for "${ctx.path}":`, err) + return DEFAULT_REPLACEMENT + } +} + /** * Redact fields matching path globs recursively. Mutates `obj` in place (use on a clone). */ export function redactPathsInTree( obj: unknown, matchers: RedactPathMatchers, - replacement: string, + replacement: RedactReplacement, prefix = '', ): void { if (obj === null || obj === undefined) return @@ -123,7 +152,7 @@ export function redactPathsInTree( for (const key in record) { const fullPath = prefix ? `${prefix}.${key}` : key if (matchesRedactPath(fullPath, key, matchers)) { - record[key] = replacement + record[key] = resolveReplacement(replacement, record[key], { path: fullPath, key }) } else { redactPathsInTree(record[key], matchers, replacement, fullPath) } @@ -140,7 +169,7 @@ export function redactPathsInTree( export function redactValueByPaths( value: unknown, matchers: RedactPathMatchers, - replacement: string, + replacement: RedactReplacement, pointerPath = '', ): unknown { const segments = pointerPath.split('/').filter(Boolean) @@ -148,7 +177,9 @@ export function redactValueByPaths( const leafKey = segments.at(-1) ?? '' if (value === null || typeof value !== 'object') { - if (dotPath && matchesRedactPath(dotPath, leafKey, matchers)) return replacement + if (dotPath && matchesRedactPath(dotPath, leafKey, matchers)) { + return resolveReplacement(replacement, value, { path: dotPath, key: leafKey }) + } return value } @@ -163,7 +194,7 @@ export function redactValueByPaths( const childPointer = pointerPath ? `${pointerPath}/${k}` : `/${k}` const childDot = dotPath ? `${dotPath}.${k}` : k out[k] = matchesRedactPath(childDot, k, matchers) - ? replacement + ? resolveReplacement(replacement, v, { path: childDot, key: k }) : redactValueByPaths(v, matchers, replacement, childPointer) } return out @@ -317,7 +348,8 @@ function cloneForRedaction(event: Record): Record, config: RedactConfig const clone = cloneForRedaction(event) const replacement = config.replacement ?? DEFAULT_REPLACEMENT + // Runs first so the hook sees raw values; declarative stages then still apply + // to whatever it leaves behind, so a hook that misses a field is not the last + // line of defence. Failures are reported and swallowed like drain failures. + if (config.transform) { + try { + config.transform(clone as WideEvent) + } catch (err) { + console.error('[evlog] redact transform failed:', err) + } + } + // Configs resolved via resolveRedactConfig carry precompiled matchers; compile lazily for ad-hoc configs. const pathMatchers = config._pathMatchers ?? compileRedactPathMatchers(config.paths) if (pathMatchers) { @@ -347,15 +390,17 @@ export function redactEvent(event: Record, config: RedactConfig return clone } -function redactPatterns(obj: unknown, patterns: RegExp[], replacement: string): void { +function redactPatterns(obj: unknown, patterns: RegExp[], replacement: RedactReplacement, prefix = ''): void { if (obj === null || obj === undefined) return if (Array.isArray(obj)) { for (let i = 0; i < obj.length; i++) { + const key = String(i) + const fullPath = prefix ? `${prefix}.${key}` : key if (typeof obj[i] === 'string') { - obj[i] = applyPatterns(obj[i] as string, patterns, replacement) + obj[i] = applyPatterns(obj[i] as string, patterns, replacement, fullPath, key) } else if (typeof obj[i] === 'object') { - redactPatterns(obj[i], patterns, replacement) + redactPatterns(obj[i], patterns, replacement, fullPath) } } return @@ -365,23 +410,49 @@ function redactPatterns(obj: unknown, patterns: RegExp[], replacement: string): const record = obj as Record for (const key in record) { const val = record[key] + const fullPath = prefix ? `${prefix}.${key}` : key if (typeof val === 'string') { - record[key] = applyPatterns(val, patterns, replacement) + record[key] = applyPatterns(val, patterns, replacement, fullPath, key) } else if (typeof val === 'object') { - redactPatterns(val, patterns, replacement) + redactPatterns(val, patterns, replacement, fullPath) } } } } -function applyPatterns(value: string, patterns: RegExp[], replacement: string): string { +// eslint-disable-next-line max-params +function applyPatterns( + value: string, + patterns: RegExp[], + replacement: RedactReplacement, + path: string, + key: string, +): string { let result = value for (const pattern of patterns) { - result = result.replace(pattern, replacement) + if (typeof replacement === 'string') { + result = result.replace(pattern, replacement) + continue + } + result = result.replace(pattern, (...args) => { + const match = args[0] as string + return resolveReplacement(replacement, match, { path, key, groups: captureGroups(args) }) + }) } return result } +/** + * Extract the capture groups from a `String.prototype.replace` callback's args. + * Trailing args are `offset, string` — plus a named-groups object when the + * pattern declares any. + */ +function captureGroups(args: unknown[]): Array { + const last = args.at(-1) + const trailing = typeof last === 'object' && last !== null ? 3 : 2 + return args.slice(1, Math.max(1, args.length - trailing)) as Array +} + function applyMaskersToTree(obj: unknown, maskers: Masker[]): void { if (obj === null || obj === undefined) return @@ -417,6 +488,27 @@ function applyMaskers(value: string, maskers: Masker[]): string { return result } +/** + * Whether a `redact` option carries function-valued policy (`replacement` or + * `transform`). + * + * Build-time config bridges (`__EVLOG_CONFIG__`, `process.env.__EVLOG_CONFIG`) + * go through `JSON.stringify`, which drops functions without a word. Modules + * that serialize user config call this first so a policy declared in + * `nuxt.config.ts` fails loudly instead of silently not redacting — the same + * class of defect as #408 and #441. + */ +export function hasFunctionRedactPolicy(redact: unknown): boolean { + if (!redact || typeof redact !== 'object') return false + const config = redact as Record + return typeof config.replacement === 'function' || typeof config.transform === 'function' +} + +/** Message shared by every config bridge that serializes `redact` through JSON. */ +export const FUNCTION_REDACT_POLICY_WARNING + = '[evlog] redact.replacement / redact.transform is a function and cannot be serialized into the build config. ' + + 'Declare it at runtime instead — initLogger({ redact: { ... } }) from a server plugin — or use a string replacement.' + /** * Normalize a redact config that may have been deserialized from JSON * (e.g. via `process.env.__EVLOG_CONFIG`). Converts pattern strings @@ -432,8 +524,15 @@ export function normalizeRedactConfig(raw: boolean | Record | u config.paths = raw.paths as string[] } - if (typeof raw.replacement === 'string') { - config.replacement = raw.replacement + // Function-valued policy survives only when the config object reached us live + // (e.g. Nitro's in-process runtimeConfig). Through the JSON bridges it is gone + // before this point — `hasFunctionRedactPolicy` warns at that boundary instead. + if (typeof raw.replacement === 'string' || typeof raw.replacement === 'function') { + config.replacement = raw.replacement as RedactReplacement + } + + if (typeof raw.transform === 'function') { + config.transform = raw.transform as (event: WideEvent) => void } if (raw.builtins === false) { diff --git a/packages/evlog/src/types.ts b/packages/evlog/src/types.ts index 185eb957..de18b98a 100644 --- a/packages/evlog/src/types.ts +++ b/packages/evlog/src/types.ts @@ -91,6 +91,40 @@ export interface IngestPayload { [key: string]: unknown } +/** + * Context describing *what* is being redacted and *where*, passed to a + * function-valued {@link RedactConfig.replacement}. + */ +export interface RedactReplacementContext { + /** Dot-notation path of the field from the event root (e.g. `user.email`, `items.0.token`). */ + path: string + /** Leaf key of the field (e.g. `email`). Empty for the event root. */ + key: string + /** + * Capture groups of the matching `patterns` entry, in order. + * Only set when the replacement was triggered by `patterns`; `undefined` for `paths`. + */ + groups?: Array +} + +/** + * Replacement used for `paths` and `patterns` redaction. + * + * A string is used verbatim. A function is called once per redacted value and + * must return the replacement synchronously — use it when the replacement has + * to be *derived* from the value (e.g. a stable fingerprint that keeps requests + * correlatable without exposing the credential). + * + * The function receives the matched value: the whole field value for `paths` + * (any type, since path redaction replaces entire subtrees), the matched + * substring for `patterns`. If it throws or returns a non-string, redaction + * falls back to `'[REDACTED]'` — a broken policy degrades to over-redaction, + * never to leaking the raw value. + */ +export type RedactReplacement = + | string + | ((matched: unknown, ctx: RedactReplacementContext) => string) + /** * Auto-redaction configuration for PII protection. * Scrubs sensitive data from wide events before console output and draining. @@ -119,11 +153,52 @@ export interface RedactConfig { */ builtins?: false | Array<'creditCard' | 'email' | 'ipv4' | 'phone' | 'jwt' | 'bearer' | 'iban'> /** - * Replacement string used for path- and custom pattern redaction. + * Replacement used for path- and custom pattern redaction. * Built-in patterns use smart partial masking instead (e.g. `****1111` for credit cards). + * + * Pass a function to compute the replacement from the matched value — see + * {@link RedactReplacement}. + * * @default '[REDACTED]' + * + * @example + * ```ts + * // Keep requests correlatable without exposing the credential + * initLogger({ + * redact: { + * patterns: [/\/public\/claim\/([A-Za-z0-9._-]{12,})/g], + * replacement: (_match, ctx) => `/public/claim/[tok:${fingerprint(ctx.groups?.[0] ?? '')}]`, + * }, + * }) + * ``` + */ + replacement?: RedactReplacement + /** + * Escape hatch for policies that cannot be expressed declaratively — + * conditional on a sibling field, tenant-scoped, schema-driven, or + * allowlist-shaped rather than denylist-shaped. + * + * Runs **before** `paths`, `builtins`, and `patterns`, so it sees raw values + * and the declarative rules still apply to whatever it leaves behind. Mutate + * the event in place; it is already a private clone, so the caller's object is + * never touched. Must be synchronous — it runs on the emit path, before the + * console write. + * + * Errors are caught and reported the way drain failures are: the declarative + * stages still run and the event is still logged. + * + * @example + * ```ts + * initLogger({ + * redact: { + * transform: (event) => { + * if (event.tenant === 'regulated') delete event.query + * }, + * }, + * }) + * ``` */ - replacement?: string + transform?: (event: WideEvent) => void /** @internal Resolved masker functions from built-in patterns. Not user-facing. */ _maskers?: Array<[RegExp, (match: string) => string]> /** @internal Precompiled matchers for `paths`, built once by `resolveRedactConfig`. Not user-facing. */ diff --git a/packages/evlog/test/core/redact-integration.test.ts b/packages/evlog/test/core/redact-integration.test.ts index aacff93d..44354dfe 100644 --- a/packages/evlog/test/core/redact-integration.test.ts +++ b/packages/evlog/test/core/redact-integration.test.ts @@ -181,8 +181,81 @@ describe('initLogger + redact integration', () => { expect((event.nested as Record).ip).toBe('***.***.***.100') expect(event.array).toEqual(['***.***.***.1']) }) + + it('applies a computed replacement to the console sink', () => { + const infoSpy = vi.spyOn(console, 'info') + + initLogger({ + pretty: false, + stringify: true, + redact: { + builtins: false, + patterns: [/\/public\/claim\/([A-Za-z0-9._-]{12,})/g], + replacement: (_match, ctx) => `/public/claim/[tok:${fingerprint(defined(ctx.groups?.[0], 'token'))}]`, + }, + }) + + const logger = createLogger({ path: '/public/claim/eyJhbGciOiJIUzI1NiJ9' }) + logger.emit() + + const output = defined(infoSpy.mock.calls[0]?.[0], 'console output') as string + expect(output).toContain('/public/claim/[tok:') + expect(output).not.toContain('eyJhbGciOiJIUzI1NiJ9') + }) + + it('runs transform before the console write', () => { + const infoSpy = vi.spyOn(console, 'info') + + initLogger({ + pretty: false, + stringify: true, + redact: { + builtins: false, + transform: (event) => { + if (event.tenant === 'regulated') delete event.query + }, + }, + }) + + const logger = createLogger({ tenant: 'regulated', query: 'name=alice' }) + const event = defined(logger.emit(), 'emitted event') + + expect(event).not.toHaveProperty('query') + expect(defined(infoSpy.mock.calls[0]?.[0], 'console output') as string).not.toContain('name=alice') + }) + + it('keeps logging when a transform throws', () => { + const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) + const infoSpy = vi.spyOn(console, 'info') + + initLogger({ + pretty: false, + stringify: true, + redact: { + paths: ['password'], + transform: () => { + throw new Error('bad hook') + }, + }, + }) + + const logger = createLogger({ password: 'hunter2', route: '/checkout' }) + const event = defined(logger.emit(), 'emitted event') + + expect(event.password).toBe('[REDACTED]') + expect(event.route).toBe('/checkout') + expect(errorSpy).toHaveBeenCalled() + expect(infoSpy).toHaveBeenCalled() + }) }) +/** Stand-in for a real keyed hash — the point is that it is derived and stable. */ +function fingerprint(value: string): string { + let hash = 0 + for (let i = 0; i < value.length; i++) hash = (hash * 31 + value.charCodeAt(i)) | 0 + return (hash >>> 0).toString(16).slice(0, 6) +} + describe('default redaction behavior', () => { const originalNodeEnv = process.env.NODE_ENV diff --git a/packages/evlog/test/core/redact.test.ts b/packages/evlog/test/core/redact.test.ts index ebe982f6..b754f0f5 100644 --- a/packages/evlog/test/core/redact.test.ts +++ b/packages/evlog/test/core/redact.test.ts @@ -1,5 +1,5 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { redactEvent, normalizeRedactConfig, resolveRedactConfig, builtinPatterns } from '../../src/redact' +import { redactEvent, normalizeRedactConfig, resolveRedactConfig, builtinPatterns, hasFunctionRedactPolicy } from '../../src/redact' import type { RedactConfig } from '../../src/types' import { createLogger, initLogger } from '../../src/logger' import { defined } from '../helpers/defined' @@ -524,3 +524,231 @@ describe('normalizeRedactConfig', () => { }) }) + +describe('redactEvent - function replacement', () => { + it('derives the replacement from a path-matched value', () => { + const event = redactEvent( + { user: { email: 'alice@example.com' } }, + { builtins: false, paths: ['user.email'], replacement: matched => `len:${String(matched).length}` }, + ) + expect((event.user as Record).email).toBe('len:17') + }) + + it('passes the dot path and leaf key for path matches', () => { + const seen: Array<{ path: string, key: string }> = [] + redactEvent( + { user: { email: 'a@b.co' }, items: [{ token: 'x' }] }, + { + builtins: false, + paths: ['user.email', 'token'], + replacement: (_matched, ctx) => { + seen.push({ path: ctx.path, key: ctx.key }) + return '[x]' + }, + }, + ) + expect(seen).toEqual([ + { path: 'user.email', key: 'email' }, + { path: 'items.0.token', key: 'token' }, + ]) + }) + + it('receives the whole subtree when a path matches an object', () => { + let matched: unknown + redactEvent( + { payment: { card: { number: '4111', expiry: '12/26' } } }, + { + builtins: false, + paths: ['payment.card'], + replacement: (value) => { + matched = value + return '[card]' + }, + }, + ) + expect(matched).toEqual({ number: '4111', expiry: '12/26' }) + }) + + it('derives the replacement from a pattern match, with capture groups', () => { + const event = redactEvent( + { path: '/public/claim/eyJhbGciOiJIUzI1NiJ9' }, + { + builtins: false, + patterns: [/\/public\/claim\/([A-Za-z0-9._-]{12,})/g], + replacement: (_match, ctx) => `/public/claim/[tok:${defined(ctx.groups?.[0], 'group').slice(0, 6)}]`, + }, + ) + expect(event.path).toBe('/public/claim/[tok:eyJhbG]') + }) + + it('exposes capture groups alongside named groups', () => { + let groups: Array | undefined + redactEvent( + { path: '/u/42' }, + { + builtins: false, + patterns: [/\/u\/(?\d+)/g], + replacement: (_match, ctx) => { + ({ groups } = ctx) + return '/u/[id]' + }, + }, + ) + expect(groups).toEqual(['42']) + }) + + it('passes the path of the string field a pattern matched', () => { + let path: string | undefined + redactEvent( + { request: { headers: { authorization: 'token abcdef' } } }, + { + builtins: false, + patterns: [/abcdef/g], + replacement: (_match, ctx) => { + ({ path } = ctx) + return '[x]' + }, + }, + ) + expect(path).toBe('request.headers.authorization') + }) + + it('falls back to [REDACTED] when the replacement throws', () => { + const error = vi.spyOn(console, 'error').mockImplementation(() => {}) + const event = redactEvent( + { token: 'secret-value' }, + { + builtins: false, + paths: ['token'], + replacement: () => { + throw new Error('bad policy') + }, + }, + ) + expect(event.token).toBe('[REDACTED]') + expect(error).toHaveBeenCalled() + error.mockRestore() + }) + + it('falls back to [REDACTED] when the replacement returns a non-string', () => { + const error = vi.spyOn(console, 'error').mockImplementation(() => {}) + const event = redactEvent( + { token: 'secret-value' }, + { builtins: false, paths: ['token'], replacement: (() => 42) as unknown as RedactConfig['replacement'] }, + ) + expect(event.token).toBe('[REDACTED]') + expect(error).toHaveBeenCalled() + error.mockRestore() + }) + + it('never leaks the raw value when a pattern replacement throws', () => { + const error = vi.spyOn(console, 'error').mockImplementation(() => {}) + const event = redactEvent( + { path: '/claim/supersecrettoken' }, + { + builtins: false, + patterns: [/supersecrettoken/g], + replacement: () => { + throw new Error('bad policy') + }, + }, + ) + expect(event.path).toBe('/claim/[REDACTED]') + expect(error).toHaveBeenCalled() + error.mockRestore() + }) +}) + +describe('redactEvent - transform', () => { + it('mutates the event in place without touching the source', () => { + const source: Record = { tenant: 'regulated', query: 'name=alice' } + const event = redactEvent(source, { + builtins: false, + transform: (e) => { + if (e.tenant === 'regulated') delete e.query + }, + }) + expect(event).not.toHaveProperty('query') + expect(source.query).toBe('name=alice') + }) + + it('sees raw values, before the declarative stages run', () => { + let seen: unknown + redactEvent( + { user: { email: 'alice@example.com' } }, + { + builtins: false, + paths: ['user.email'], + transform: (e) => { + seen = (e.user as Record).email + }, + }, + ) + expect(seen).toBe('alice@example.com') + }) + + it('still applies the declarative stages to what it leaves behind', () => { + const config = defined(resolveRedactConfig({ builtins: ['email'] }), 'redact config') + const event = redactEvent({ note: 'ping alice@example.com' }, { + ...config, + transform: (e) => { + e.extra = 'ping bob@example.com' + }, + }) + // A field the hook adds is masked by the built-ins that run after it. + expect(event.note).toBe('ping a***@***.com') + expect(event.extra).toBe('ping b***@***.com') + }) + + it('reports a throwing transform and keeps redacting', () => { + const error = vi.spyOn(console, 'error').mockImplementation(() => {}) + const event = redactEvent( + { password: 'hunter2' }, + { + builtins: false, + paths: ['password'], + transform: () => { + throw new Error('bad hook') + }, + }, + ) + expect(event.password).toBe('[REDACTED]') + expect(error).toHaveBeenCalledWith('[evlog] redact transform failed:', expect.any(Error)) + error.mockRestore() + }) +}) + +describe('function-valued redact policy across the config bridge', () => { + it('passes a live function replacement through normalizeRedactConfig', () => { + const replacement = () => '[x]' + const config = normalizeRedactConfig({ paths: ['token'], replacement }) + expect(config?.replacement).toBe(replacement) + }) + + it('passes a live transform through normalizeRedactConfig', () => { + const transform = () => {} + const config = normalizeRedactConfig({ transform }) + expect(config?.transform).toBe(transform) + }) + + it('detects function-valued policy so config bridges can warn', () => { + expect(hasFunctionRedactPolicy({ replacement: () => '[x]' })).toBe(true) + expect(hasFunctionRedactPolicy({ transform: () => {} })).toBe(true) + expect(hasFunctionRedactPolicy({ paths: ['token'], replacement: '***' })).toBe(false) + expect(hasFunctionRedactPolicy(true)).toBe(false) + expect(hasFunctionRedactPolicy(undefined)).toBe(false) + }) + + it('drops function policy when the config is JSON-serialized, as the bridges do', () => { + const serialized = JSON.parse(JSON.stringify({ + paths: ['token'], + replacement: () => '[x]', + transform: () => {}, + })) as Record + + // The warning is the only signal left at this point — hence hasFunctionRedactPolicy + // being called before serialization, not after. + expect(hasFunctionRedactPolicy(serialized)).toBe(false) + expect(normalizeRedactConfig(serialized)?.replacement).toBeUndefined() + }) +}) From d7c458af0824d438cd09c12c20fb33fcaab98d78 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 1 Aug 2026 10:59:36 +0000 Subject: [PATCH 2/3] fix(lab): clear the three lint errors blocking CI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Not related to the redaction change in this branch, but they fail `lint` and `autofix` on every PR until main is clean, so they are fixed here rather than left to mask a real regression on the next push. - index.vue: `:suggested-name` same-name shorthand, matching the sibling props on the same element. Compiles to the identical `_ctx.suggestedName` binding. - assets.ts, db.ts: both functions were `async` with a single `return ` body. The declared `Promise` return type is unchanged and neither callee can throw synchronously — `request` is itself async — so dropping `async` preserves behaviour. Also narrow the changeset wording: it read as though any throwing function fell back to `[REDACTED]`, which is true of `replacement` but not of `transform`. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01Uyng7c1TAGK6eFSBMBE33e --- .changeset/programmable-redaction.md | 2 +- apps/lab/app/pages/index.vue | 2 +- apps/lab/app/utils/lab/assets.ts | 2 +- apps/lab/app/utils/lab/db.ts | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/.changeset/programmable-redaction.md b/.changeset/programmable-redaction.md index 4823cf26..29cbe130 100644 --- a/.changeset/programmable-redaction.md +++ b/.changeset/programmable-redaction.md @@ -17,7 +17,7 @@ initLogger({ `RedactConfig.transform` covers policies that cannot be expressed declaratively — conditional on a sibling field, tenant-scoped, or allowlist-shaped. It runs before the declarative stages, so it sees raw values and `paths` / `builtins` / `patterns` still apply to whatever it leaves behind. -Both run where redaction already runs: after the event is built, before the console write and before any drain. Failures are caught and reported like drain failures — a function that throws falls back to `[REDACTED]` rather than emitting the raw value, and a throwing `transform` does not stop the event from being logged. +Both run where redaction already runs: after the event is built, before the console write and before any drain. Failures are caught and reported like drain failures — a `replacement` function that throws falls back to `[REDACTED]` rather than emitting the raw value, and a throwing `transform` is skipped without stopping the event from being logged. Function-valued policy cannot survive the build-time config bridges, which serialize to JSON; the Nitro modules now warn instead of dropping it silently. diff --git a/apps/lab/app/pages/index.vue b/apps/lab/app/pages/index.vue index 1ee322d9..7e94f482 100644 --- a/apps/lab/app/pages/index.vue +++ b/apps/lab/app/pages/index.vue @@ -1997,7 +1997,7 @@ onBeforeUnmount(() => window.removeEventListener('keydown', onKeydown)) v-model="projectsOpen" :projects :active-id="activeProjectId" - :suggested-name="suggestedName" + :suggested-name :busy="projectBusy" :storage :persisted="storagePersisted" diff --git a/apps/lab/app/utils/lab/assets.ts b/apps/lab/app/utils/lab/assets.ts index 1b008996..31ef5185 100644 --- a/apps/lab/app/utils/lab/assets.ts +++ b/apps/lab/app/utils/lab/assets.ts @@ -91,7 +91,7 @@ export async function putAssetWithId(id: string, blob: Blob, name: string): Prom await put(ASSETS, { id, blob, name, type: blob.type, bytes: blob.size }) } -export async function getAsset(id: string): Promise { +export function getAsset(id: string): Promise { return get(ASSETS, id) } diff --git a/apps/lab/app/utils/lab/db.ts b/apps/lab/app/utils/lab/db.ts index 5d26cc64..f90b543f 100644 --- a/apps/lab/app/utils/lab/db.ts +++ b/apps/lab/app/utils/lab/db.ts @@ -96,7 +96,7 @@ export async function put(store: string, value: T): Promise { await request(store, 'readwrite', target => target.put(value)) } -export async function get(store: string, key: string): Promise { +export function get(store: string, key: string): Promise { return request(store, 'readonly', target => target.get(key)) } From 783e8d2425150b68c0983019dd2b4cddca59e4eb Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 1 Aug 2026 11:05:35 +0000 Subject: [PATCH 3/3] docs(redact): note the non-string replacement fallback in the changeset MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `resolveReplacement` treats a callback returning a non-string the same as one that throws — both fall back to `[REDACTED]`. The prose docs already said so; the changeset mentioned only the throwing case. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01Uyng7c1TAGK6eFSBMBE33e --- .changeset/programmable-redaction.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.changeset/programmable-redaction.md b/.changeset/programmable-redaction.md index 29cbe130..711ff940 100644 --- a/.changeset/programmable-redaction.md +++ b/.changeset/programmable-redaction.md @@ -17,7 +17,7 @@ initLogger({ `RedactConfig.transform` covers policies that cannot be expressed declaratively — conditional on a sibling field, tenant-scoped, or allowlist-shaped. It runs before the declarative stages, so it sees raw values and `paths` / `builtins` / `patterns` still apply to whatever it leaves behind. -Both run where redaction already runs: after the event is built, before the console write and before any drain. Failures are caught and reported like drain failures — a `replacement` function that throws falls back to `[REDACTED]` rather than emitting the raw value, and a throwing `transform` is skipped without stopping the event from being logged. +Both run where redaction already runs: after the event is built, before the console write and before any drain. Failures are caught and reported like drain failures — a `replacement` function that throws or returns a non-string falls back to `[REDACTED]` rather than emitting the raw value, and a throwing `transform` is skipped without stopping the event from being logged. Function-valued policy cannot survive the build-time config bridges, which serialize to JSON; the Nitro modules now warn instead of dropping it silently.