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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions .changeset/recursive-key-redaction.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
---
"evlog": minor
---

Add glob path redaction to `RedactConfig.paths`. Single-segment patterns like `password` are shorthand for `**.password` (any nesting depth). Key-name globs (`*_token`) and path globs (`user.*`) are supported. `auditRedactPreset` simplified to path globs.

```ts
initLogger({
redact: {
paths: ['password', '*_token', 'headers.x-forwarded-for'],
},
})
```
31 changes: 23 additions & 8 deletions apps/docs/content/2.learn/6.redaction.md
Original file line number Diff line number Diff line change
Expand Up @@ -76,19 +76,34 @@ Built-in patterns use **partial masking** instead of flat `[REDACTED]` — prese

## Configuration

### Custom Paths
### Path Patterns

Add dot-notation paths to redact specific fields with `[REDACTED]`, on top of the built-in patterns:
Use a single `paths` array with dot-notation and globs. A bare segment like `password` is shorthand for `**.password` — it redacts that key at **any nesting depth**:

```typescript
evlog: {
redact: {
paths: ['user.password', 'headers.authorization'],
paths: [
'password', // same as '**.password'
'*_token', // key-name glob at any depth
'headers.x-forwarded-for', // exact path
'user.*', // everything directly under user
],
}
}
```

Path-based redaction replaces the **entire value** with the `replacement` string (default `[REDACTED]`), regardless of content.
| Pattern | Matches |
|---------|---------|
| `user.email` | Exact path only |
| `password` or `**.password` | `password` key at any depth |
| `*_token` | Key names like `access_token`, `refresh_token` |
| `user.*` | `user.email`, `user.password`, etc. |
| `audit.changes.*.password` | Mixed exact + wildcard segments |

Path redaction replaces the **entire value** (including nested objects) with `replacement`. Use `patterns` when you need regex on **string values** inside fields.

This matches `auditDiff({ redactPaths: ['password'] })` — same glob syntax, applied globally at emit time.

### Selective Built-ins

Expand Down Expand Up @@ -134,18 +149,18 @@ evlog: {
| Option | Type | Default | Description |
|--------|------|---------|-------------|
| `redact` | `boolean \| RedactConfig` | `true` in production | Enabled by default in production. `false` to disable. Object for fine-grained control |
| `paths` | `string[]` | `undefined` | Dot-notation paths to redact entirely (e.g. `user.password`) |
| `patterns` | `RegExp[]` | `undefined` | Custom regex patterns. Uses flat `replacement` string |
| `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 string for paths and custom patterns. Built-in patterns use smart masking instead |
| `replacement` | `string` | `'[REDACTED]'` | Replacement for paths and custom patterns. Built-ins use smart masking instead |

Available built-in names: `creditCard`, `email`, `ipv4`, `phone`, `jwt`, `bearer`, `iban`.

## How It Works

Redaction runs inside the emit pipeline, after the wide event is fully built but before any output:

1. **Path redaction** — targeted fields replaced with `[REDACTED]`
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
Expand Down
3 changes: 1 addition & 2 deletions apps/docs/content/4.use-cases/4.audit/05.compliance.md
Original file line number Diff line number Diff line change
Expand Up @@ -47,13 +47,12 @@ initLogger({
redact: {
paths: [
...(auditRedactPreset.paths ?? []),
'user.password',
],
},
})
```

The preset drops `Authorization` / `Cookie` headers and common credential field names (`password`, `token`, `apiKey`, `cardNumber`, `cvv`, `ssn`) wherever they appear inside `audit.changes.before` and `audit.changes.after`.
The preset redacts `authorization`, `cookie`, `set-cookie`, and common credential key names (`password`, `token`, `apiKey`, `cardNumber`, `cvv`, `ssn`) **at any nesting depth** — including inside `audit.changes.before` / `audit.changes.after`.

## GDPR vs append-only

Expand Down
2 changes: 2 additions & 0 deletions apps/docs/content/6.reference/1.configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,8 @@ 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).

### `minLevel` vs sampling

- **`minLevel`** is a **hard threshold** on the simple `log.*` API: levels below the threshold are never emitted. It does **not** apply to wide events from `useLogger` / `createLogger().emit()` — use **`sampling.rates`** (and tail `keep`) for request volume.
Expand Down
4 changes: 2 additions & 2 deletions apps/docs/skills/build-audit-logs/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -282,14 +282,14 @@ function authorize(actor, action, resource) {

### Step 5 — Redact

Apply `auditRedactPreset` (or merge it into the existing `RedactConfig`). It drops `Authorization` / `Cookie` headers and common credential field names (`password`, `token`, `apiKey`, `cardNumber`, `cvv`, `ssn`) wherever they appear inside `audit.changes.before` / `audit.changes.after`:
Apply `auditRedactPreset` (or merge it into the existing `RedactConfig`). It redacts HTTP auth headers and common credential field names (`password`, `token`, `apiKey`, `cardNumber`, `cvv`, `ssn`, plus `cookie` / `set-cookie`) at any nesting depth — including inside `audit.changes.before` / `audit.changes.after`:

```ts
import { initLogger, auditRedactPreset } from 'evlog'

initLogger({
redact: {
paths: [...(auditRedactPreset.paths ?? []), 'user.password', 'user.token'],
paths: [...(auditRedactPreset.paths ?? [])],
},
})
```
Expand Down
76 changes: 24 additions & 52 deletions packages/evlog/src/audit.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import type { AuditActor, AuditActionDefinition, AuditFields, AuditPatchOp, AuditTarget, DrainContext, EnrichContext, FieldContext, RedactConfig, RequestLogger, WideEvent } from './types'
import { createLogger } from './logger'
import { compileRedactPathMatchers, redactValueByPaths } from './redact'
import { getHeader as getSharedHeader } from './shared/headers'

/**
Expand Down Expand Up @@ -303,8 +304,8 @@ export interface WithAuditContext {
* `changes` field. Output is a JSON Patch-style array (RFC 6902 subset:
* `add`, `remove`, `replace`) — small enough to ship over the wire.
*
* Object keys whose name matches one of the `redactPaths` (dot-notation, e.g.
* `'user.password'`, `'card.cvv'`) are replaced with `'[REDACTED]'` so PII
* Fields matching `redactPaths` glob patterns (e.g. `'password'`, `'**.password'`,
* `'*_token'`, `'user.email'`) are replaced with `'[REDACTED]'` so PII
* never leaks through the diff.
*
* @example
Expand All @@ -323,17 +324,13 @@ export function auditDiff(
options: AuditDiffOptions = {},
): { before?: unknown, after?: unknown, patch: AuditPatchOp[] } {
const replacement = options.replacement ?? '[REDACTED]'
const redactSet = new Set((options.redactPaths ?? []).map(p => p))
const patch: AuditPatchOp[] = []

function isRedacted(path: string): boolean {
if (redactSet.size === 0) return false
if (redactSet.has(path)) return true
for (const p of redactSet) {
if (path.endsWith(`.${p}`)) return true
}
return false
const pathMatchers = compileRedactPathMatchers(options.redactPaths) ?? {
exactPaths: new Set<string>(),
pathGlobs: [],
keyGlobs: [],
caseInsensitiveLeaves: new Set<string>(),
}
const patch: AuditPatchOp[] = []

function diff(a: unknown, b: unknown, path: string): void {
if (a === b) return
Expand Down Expand Up @@ -363,20 +360,7 @@ export function auditDiff(
}

function redactValue(value: unknown, path: string): unknown {
if (value === null || typeof value !== 'object') {
const segs = path.split('/').filter(Boolean)
const last = segs[segs.length - 1]
if (last && isRedacted(last)) return replacement
return value
}
if (Array.isArray(value)) {
return value.map((v, i) => redactValue(v, `${path}/${i}`))
}
const out: Record<string, unknown> = {}
for (const [k, v] of Object.entries(value as Record<string, unknown>)) {
out[k] = isRedacted(k) ? replacement : redactValue(v, `${path}/${k}`)
}
return out
return redactValueByPaths(value, pathMatchers, replacement, path)
}

diff(before, after, '')
Expand All @@ -390,7 +374,7 @@ export type { AuditPatchOp } from './types'

/** Options for {@link auditDiff}. */
export interface AuditDiffOptions {
/** Object keys (dot-notation) whose values should be replaced with `[REDACTED]`. */
/** Path globs (same syntax as `RedactConfig.paths`) whose values are replaced with `[REDACTED]`. */
redactPaths?: string[]
/** Custom replacement string. @default '[REDACTED]' */
replacement?: string
Expand Down Expand Up @@ -881,30 +865,18 @@ function stripIntegrity(event: WideEvent): WideEvent {
*/
export const auditRedactPreset: RedactConfig = {
paths: [
'audit.changes.before.password',
'audit.changes.before.passwordHash',
'audit.changes.before.token',
'audit.changes.before.apiKey',
'audit.changes.before.secret',
'audit.changes.before.accessToken',
'audit.changes.before.refreshToken',
'audit.changes.before.cardNumber',
'audit.changes.before.cvv',
'audit.changes.before.ssn',
'audit.changes.after.password',
'audit.changes.after.passwordHash',
'audit.changes.after.token',
'audit.changes.after.apiKey',
'audit.changes.after.secret',
'audit.changes.after.accessToken',
'audit.changes.after.refreshToken',
'audit.changes.after.cardNumber',
'audit.changes.after.cvv',
'audit.changes.after.ssn',
'headers.authorization',
'headers.cookie',
'headers.set-cookie',
'audit.context.headers.authorization',
'audit.context.headers.cookie',
'password',
'passwordHash',
'token',
'apiKey',
'secret',
'accessToken',
'refreshToken',
'cardNumber',
'cvv',
'ssn',
'authorization',
'cookie',
'set-cookie',
],
}
Loading
Loading