diff --git a/README.md b/README.md index 1708de1..56ea162 100644 --- a/README.md +++ b/README.md @@ -274,6 +274,8 @@ Every format used by `createLogger` is also exported for direct use with your ow | `prettyConsoleFormat` | Applies `colorize` and `timestamp`, then renders logs as coloured YAML using [`yamlify-object`](https://www.npmjs.com/package/yamlify-object). | | `mapAuditLevelForOtel` | Rewrites the triple-beam `LEVEL` symbol from `audit` to `info` and copies the original onto `logLevel` so custom levels survive OTEL's severity enumeration. | +`redactFormat` paths accept plain keys (`email`, matched at every level), dot-notation paths (`user.email`), and `[*]` array wildcards to iterate every element of an array segment — for example `files[*].name`, `users[*].addresses[*].zip`, or `tags[*]` to redact each element of a primitive array. + Direct usage example: ```ts @@ -281,7 +283,10 @@ import { format, createLogger, transports } from 'winston' import { redactFormat, serializeErrorFormat } from '@makerx/node-winston' const logger = createLogger({ - format: format.combine(serializeErrorFormat(), redactFormat({ paths: ['user.email'] })), + format: format.combine( + serializeErrorFormat(), + redactFormat({ paths: ['user.email', 'files[*].name'] }), + ), transports: [new transports.Console({ format: format.json() })], }) ``` diff --git a/package-lock.json b/package-lock.json index fafc551..9b35e44 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@makerx/node-winston", - "version": "2.0.1", + "version": "2.0.2", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@makerx/node-winston", - "version": "2.0.1", + "version": "2.0.2", "license": "MIT", "dependencies": { "@colors/colors": "^1.6.0", diff --git a/package.json b/package.json index 01d3ead..0a21664 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@makerx/node-winston", - "version": "2.0.1", + "version": "2.0.2", "private": false, "description": "A set of winston formats, console transport and logger creation functions", "author": "MakerX", diff --git a/src/index.spec.ts b/src/index.spec.ts index ace947f..e6f919f 100644 --- a/src/index.spec.ts +++ b/src/index.spec.ts @@ -202,6 +202,47 @@ describe('createLogger redactPaths', () => { const info = transport.logs[0] as { users: { email: string }[] } expect(info.users.map((u) => u.email)).toEqual(['', '']) }) + + it('redacts every array element using a [*] wildcard path', () => { + const transport = new InMemoryTransport({}) + const logger = createLogger({ + consoleOptions: { silent: true }, + transports: [transport], + redactPaths: ['files[*].name'], + }) + logger.info('hello', { + files: [ + { name: 'a.txt', size: 1 }, + { name: 'b.txt', size: 2 }, + ], + name: 'top', + }) + const info = transport.logs[0] as { files: { name: string; size: number }[]; name: string } + expect(info.files.map((f) => f.name)).toEqual(['', '']) + expect(info.files.map((f) => f.size)).toEqual([1, 2]) + // Wildcard is path-scoped, so a top-level `name` is not touched. + expect(info.name).toBe('top') + }) + + it('supports nested [*] wildcards and bare-array element redaction', () => { + const transport = new InMemoryTransport({}) + const logger = createLogger({ + consoleOptions: { silent: true }, + transports: [transport], + redactPaths: ['users[*].addresses[*].zip', 'tags[*]'], + }) + logger.info('hello', { + users: [{ addresses: [{ zip: '1000' }, { zip: '2000' }] }, { addresses: [{ zip: '3000' }] }], + tags: ['secret', 'public'], + }) + const info = transport.logs[0] as { + users: { addresses: { zip: string }[] }[] + tags: string[] + } + expect(info.users[0].addresses.map((a) => a.zip)).toEqual(['', '']) + expect(info.users[1].addresses[0].zip).toBe('') + expect(info.tags).toEqual(['', '']) + }) }) describe('createLogger child loggers', () => { diff --git a/src/redact-values.ts b/src/redact-values.ts index a786057..b9b8dd9 100644 --- a/src/redact-values.ts +++ b/src/redact-values.ts @@ -1,10 +1,34 @@ import { cloneDeep, forOwn, get, isNil, isObject, set } from 'es-toolkit/compat' +// Expands a single path against the current node, supporting `[*]` to iterate every element of an +// array segment. Without `[*]` it falls back to lodash-style get/set on a dot path. +const applyPath = (current: unknown, path: string, redactedValue: string) => { + const wildcardIdx = path.indexOf('[*]') + if (wildcardIdx === -1) { + if (!isNil(get(current, path))) set(current as object, path, redactedValue) + return + } + const prefix = path.slice(0, wildcardIdx) + const afterWildcard = path.slice(wildcardIdx + 3) + const suffix = afterWildcard.startsWith('.') ? afterWildcard.slice(1) : afterWildcard + const arr = prefix ? get(current, prefix) : current + if (!Array.isArray(arr)) return + arr.forEach((item, i) => { + if (!suffix) { + if (!isNil(item)) arr[i] = redactedValue + } else if (isObject(item)) { + applyPath(item, suffix, redactedValue) + } + }) +} + /** * Recursively replaces values in an object with '' for the specified keys. Enumerates arrays and applies the same redaction to elements. * @param obj The object to redact - * @param keys The keys to redact, can be a dot-separated path (uses es-toolkit/compat's get/set). - * Use dot notation to specify more specific keys. + * @param keys The keys to redact. Each key may be: + * - a plain key (`email`) — matched at every level via recursion + * - a dot-separated path (`user.email`) — uses es-toolkit/compat's get/set + * - a path with `[*]` wildcards (`files[*].name`, `users[*].addresses[*].zip`, `tags[*]`) — iterates each element of the array at that segment * Key checks are applied at every level of the object via recursion. * @returns A new object with the specified keys redacted */ @@ -14,7 +38,7 @@ export const redactValuesWith = (obj: any, ...keys: string[]) => { return (function redact(current) { for (const k of keys) { - if (!isNil(get(current, k))) set(current, k, redactedValue) + applyPath(current, k, redactedValue) } // isObject returns true for arrays too, so this recurses into both arrays and plain objects forOwn(current, (value) => {