diff --git a/README.md b/README.md index 269e70e..4951592 100644 --- a/README.md +++ b/README.md @@ -101,6 +101,44 @@ logger.error( Bearer tokens are redacted regardless of their placement in the log object. +#### Pino Redaction + +To redact other properties, use the `redact` logger options as per [pino redaction] docs. + +Pino has a limitation where you can either redact or remove specified `redact.paths` by setting `redact.remove` to `true` or `false`. + +A logger option has been added to allow you to redact some paths and remove other paths. + +Example: + +```typescript +const logger = createLogger({ + name: 'my-app', + redact: { + paths: ['req.headers["x-redact-this"]'], + removePaths: ['req.headers["x-remove-this"]'], + }, +}); +``` + +#### Removal of Default Properties + +The library will remove a set of default properties. +To bypass this, set `ignoreDefaultRemovePaths` to `true` on the `redact` options. + +Example: + +```typescript +const logger = createLogger({ + name: 'my-app', + redact: { + paths: ['req.headers["x-redact-this"]'], + removePaths: ['req.headers["x-remove-this"]'], + ignoreDefaultRemovePaths: true, + }, +}); +``` + ### Trimming The following trimming rules apply to all logging data: @@ -162,3 +200,4 @@ const logger = createLogger({ [pino]: https://github.com/pinojs/pino [pino options]: https://github.com/pinojs/pino/blob/master/docs/api.md#options [pino-pretty]: https://github.com/pinojs/pino-pretty +[pino redaction]: https://github.com/pinojs/pino/blob/master/docs/api.md#redact-array--object diff --git a/src/index.test.ts b/src/index.test.ts index 5a3fe36..9b53565 100644 --- a/src/index.test.ts +++ b/src/index.test.ts @@ -1,5 +1,7 @@ import split from 'split2'; +import { defaultRemovePaths } from './redact'; + import createLogger, { type LoggerOptions } from '.'; const bearerToken = @@ -38,6 +40,7 @@ function testLog( output: any, method?: 'error' | 'info', loggerOptions?: LoggerOptions, + shouldNotHavePropertyPaths?: Array, ) { // eslint-disable-next-line jest/valid-title test(testName, async () => { @@ -50,6 +53,9 @@ function testLog( expect(log).toMatchObject(output); expect(inputString).toEqual(JSON.stringify(input)); expect(log).toHaveProperty('timestamp'); + shouldNotHavePropertyPaths?.forEach((path) => { + expect(log).not.toHaveProperty(path); + }); }); } @@ -422,6 +428,229 @@ testLog( }, ); +testLog( + 'should redact specified paths', + { + msg: 'allowed', + req: { + headers: { + ['x-leave-me']: 'Should be present', + secret: 'Should be redacted', + }, + }, + }, + { + msg: 'allowed', + req: { + headers: { ['x-leave-me']: 'Should be present', secret: '[Redacted]' }, + }, + }, + 'info', + { + redact: ['req.headers.secret'], + }, + ['req.headers.x-remove-me'], +); + +testLog( + 'should redact or remove specified paths', + { + msg: 'allowed', + req: { + headers: { + ['x-remove-me']: 'Should be removed', + secret: 'Should be redacted', + }, + }, + }, + { msg: 'allowed', req: { headers: { secret: '[Redacted]' } } }, + 'info', + { + redact: { + paths: ['req.headers.secret'], + removePaths: ['req.headers["x-remove-me"]'], + }, + }, + ['req.headers.x-remove-me'], +); + +const buildObjectFromPath = (path: string): Record => + path + .split(/[.\[]/) + .reverse() + .reduce( + (previous, current) => { + const key = current.replace(/["\[\]]/g, ''); + if (!previous) { + return { [key]: 'Default path property' }; + } + + return { [key]: previous }; + }, + undefined as unknown as Record, + ); + +const buildObjectFromDefaultRemovePaths = (): Record => + !defaultRemovePaths?.[0] ? {} : buildObjectFromPath(defaultRemovePaths[0]); + +testLog( + 'should remove default paths when ignoreDefaultRemovePaths is missing', + { + redact: 'Should be redacted', + remove: 'Should be removed', + ...buildObjectFromDefaultRemovePaths(), + }, + { + redact: '[Redacted]', + }, + 'info', + { + maxObjectDepth: 20, + redact: { + paths: ['redact'], + removePaths: ['remove'], + }, + }, + ['remove', ...defaultRemovePaths], +); + +testLog( + 'should not remove default paths when ignoreDefaultRemovePaths is true', + { + redact: 'Should be redacted', + remove: 'Should be removed', + ...buildObjectFromDefaultRemovePaths(), + }, + { + redact: '[Redacted]', + ...buildObjectFromDefaultRemovePaths(), + }, + 'info', + { + maxObjectDepth: 20, + redact: { + paths: ['redact'], + removePaths: ['remove'], + ignoreDefaultRemovePaths: true, + }, + }, + ['remove'], +); + +testLog( + 'should use input censor when redacting', + { + redact: 'Should be redacted', + }, + { + redact: '[SHHH]', + }, + 'info', + { + redact: { + paths: ['redact'], + censor: (_value, _path) => '[SHHH]', + }, + }, +); + +testLog( + 'should not use input censor when removing', + { + remove: 'Should be "removed"', + }, + {}, + 'info', + { + redact: { + paths: ['remove'], + remove: true, + censor: (_value, _path) => 'You have been erased!', + }, + }, + ['remove'], +); + +testLog( + 'should not use input censor when redacting and removing with no redact paths', + { + remove: 'Should be "removed"', + }, + {}, + 'info', + { + redact: { + paths: [], + removePaths: ['remove'], + censor: (_value, _path) => 'You have been erased!', + }, + }, + ['remove'], +); + +testLog( + 'should use input censor function to redact when redacting and removing', + { + redact: 'Should be redacted', + remove: 'Should be "removed"', + }, + { + redact: '[SHHH]', + }, + 'info', + { + redact: { + paths: ['redact'], + removePaths: ['remove'], + censor: (_value, path) => + path.includes('redact') ? '[SHHH]' : 'You have been erased!', + }, + }, + ['remove'], +); + +testLog( + 'should use input censor text to redact when redacting and removing', + { + redact: 'Should be redacted', + remove: 'Should be "removed"', + }, + { + redact: '[SHHH]', + }, + 'info', + { + redact: { + paths: ['redact'], + removePaths: ['remove'], + censor: '[SHHH]', + }, + }, + ['remove'], +); + +testLog( + 'should remove specified paths', + { + msg: 'allowed', + req: { + headers: { + ['x-remove-me']: 'Should be removed', + secret: 'Should be removed', + }, + }, + }, + { msg: 'allowed', req: { headers: {} } }, + 'info', + { + redact: { + paths: [], + removePaths: ['req.headers.secret', 'req.headers["x-remove-me"]'], + }, + }, + ['req.headers.secret', 'req.headers.x-remove-me'], +); + interface ExampleMessageContext { activity: string; err?: { diff --git a/src/index.ts b/src/index.ts index be65d6a..0474612 100644 --- a/src/index.ts +++ b/src/index.ts @@ -8,7 +8,8 @@ import serializers from './serializers'; export { pino }; -export type LoggerOptions = pino.LoggerOptions & FormatterOptions; +export type LoggerOptions = Omit & + FormatterOptions & { redact?: redact.ExtendedRedact }; export type Logger = pino.Logger; /** @@ -24,7 +25,7 @@ export default ( sync: true, }), ): Logger => { - opts.redact = redact.addDefaultRedactPathStrings(opts.redact); + opts.redact = redact.configureRedact(opts.redact); opts.serializers = { ...serializers, ...opts.serializers, diff --git a/src/redact/index.test.ts b/src/redact/index.test.ts new file mode 100644 index 0000000..c25a3af --- /dev/null +++ b/src/redact/index.test.ts @@ -0,0 +1,15 @@ +import { keyFromPath } from '.'; + +it.each` + paths | path + ${['data', 'top', 'prop1']} | ${'data.top.prop1'} + ${['data', 'top.prop1']} | ${'data["top.prop1"]'} + ${['headers', 'x-request-id']} | ${'headers["x-request-id"]'} + ${['_start', '$with', 'allowed', 'Char']} | ${'_start.$with.allowed.Char'} + ${['-start', '.with', '4notAllowed', '~Char']} | ${'["-start"][".with"]["4notAllowed"]["~Char"]'} + ${['con-tain', 'not!', 'allow∑∂', 'Chår']} | ${'["con-tain"]["not!"]["allow∑∂"]["Chår"]'} +`(`should convert '$paths' to '$path'`, ({ paths, path }) => { + const result = keyFromPath(paths); + + expect(result).toBe(path); +}); diff --git a/src/redact/index.ts b/src/redact/index.ts index c2ebc40..44acf44 100644 --- a/src/redact/index.ts +++ b/src/redact/index.ts @@ -1,23 +1,90 @@ +import type * as pino from 'pino'; + // TODO: Redact cookies? export const defaultRedact = []; -/** - * Private interface vendored from `pino` - */ -interface redactOptions { - paths: string[]; - censor?: string | ((value: unknown, path: string[]) => unknown); - remove?: boolean; -} - -export const addDefaultRedactPathStrings = ( - redact: string[] | redactOptions | undefined, -) => { - if (!redact) { - return defaultRedact; - } - if (Array.isArray(redact)) { - return redact.concat(defaultRedact); +export const defaultRemovePaths: string[] = []; + +type redactOptions = Extract; + +type extendedRedactOptions = redactOptions & { + /** + * A list of paths to remove from the logged object instead of redacting them. + * Note that if you are only removing, rather use the `paths` property and set `remove` to `true`. + */ + removePaths?: string[]; + + /** + * When `true`, the `defaultRemovePaths` will not be appended to the `removePaths` list. + */ + ignoreDefaultRemovePaths?: true; +}; + +export type ExtendedRedact = string[] | extendedRedactOptions | undefined; + +const appendDefaultRedactAndRemove = ( + redact: ExtendedRedact, +): extendedRedactOptions | undefined => { + const inputRedact = + typeof redact !== 'undefined' && !Array.isArray(redact) + ? redact + : { paths: redact ?? [] }; + + const paths = inputRedact.paths.concat(defaultRedact); + const inputRemovePaths = inputRedact.removePaths ?? []; + const removePaths = inputRedact.ignoreDefaultRemovePaths + ? inputRemovePaths + : inputRemovePaths.concat(defaultRemovePaths); + + return paths.length === 0 && removePaths.length === 0 + ? undefined + : { ...inputRedact, paths, removePaths }; +}; + +const STARTS_WITH_INVALID_CHARS = '^[^$_a-zA-Z]'; +const CONTAINS_INVALID_CHARS = '[^a-zA-Z0-9_$]+'; +const nonStandardIdentifierRegex = new RegExp( + `(${STARTS_WITH_INVALID_CHARS}|${CONTAINS_INVALID_CHARS})`, +); + +export const keyFromPath = (paths: string[]): string => { + const path = paths.reduce((previous, current) => { + const dotDelimiter = previous === '' ? '' : '.'; + const escapedPath = nonStandardIdentifierRegex.test(current) + ? `["${current}"]` + : `${dotDelimiter}${current}`; + return `${previous}${escapedPath}`; + }, ''); + + return path; +}; + +const configureRedactCensor = (redact: ExtendedRedact): ExtendedRedact => { + if ( + !redact || + Array.isArray(redact) || + !redact.removePaths || + redact.removePaths.length === 0 + ) { + return redact; } - return redact; + + const { paths: redactPaths, removePaths, censor } = redact; + const removeSet = new Set(removePaths); + const censorText = typeof censor === 'string' ? censor : '[Redacted]'; + const censorPath = (value: unknown, path: string[]): unknown => + typeof censor === 'function' ? censor(value, path) : censorText; + + return redactPaths.length === 0 + ? { paths: removePaths, remove: true, censor } + : { + paths: [...redactPaths, ...removePaths], + censor: (value, path) => + removeSet.has(keyFromPath(path)) + ? undefined + : censorPath(value, path), + }; }; + +export const configureRedact = (redact: ExtendedRedact): ExtendedRedact => + configureRedactCensor(appendDefaultRedactAndRemove(redact));