diff --git a/src/index.ts b/src/index.ts index 09cf7f9..6d3d894 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,5 +1,5 @@ import { DataSanitizationError } from './errors'; -import { stringReplacer } from './replacers'; +import { objectReplacer, stringReplacer } from './replacers'; import { DataSanitizationReplacer } from './types'; /** @@ -56,18 +56,14 @@ const createSafeErrorDetails = ( * Sanitizes data in an object/string to make it safe for logging * or other purposes of a sensitive nature * - * The approach taken is to always convert the incoming data to a - * string and then perform sanitization on the string. + * Strings are sanitized via {@link stringReplacer}. Non-null objects and arrays + * are sanitized directly via {@link objectReplacer} without any string + * conversion. Null is JSON-stringified, sanitized via {@link stringReplacer}, + * then parsed back. * - * Whenever possible the data will be converted back to the original - * type and returned. - * - * When this fails the data may be returned as a string that has been - * sanitized, or as an object containing an error message. - * - * @param data - String or object data to be sanitized. + * @param data - String, null, or object data to be sanitized. * @param options - Matcher, pattern, masking, and removal options. - * @returns Sanitized data converted back to the original supported input type when possible. + * @returns Sanitized data in the original supported input type when possible. * @throws {DataSanitizationError} When the input data type cannot be sanitized. * @throws {DataSanitizationError} When sanitized object data cannot be parsed back to JSON. * @@ -93,9 +89,16 @@ const sanitizeData: DataSanitizationReplacer = (data, options = {}) => { } if (typeof data === 'object') { - const stringifiedData = JSON.stringify(data); - const sanitizedData = stringReplacer(stringifiedData, options) as string; - return JSON.parse(sanitizedData); + if (data === null) { + const stringifiedData = JSON.stringify(data); + const sanitizedData = stringReplacer( + stringifiedData, + options, + ) as string; + return JSON.parse(sanitizedData); + } + + return objectReplacer(data, options); } throw new DataSanitizationError('Invalid data type', { diff --git a/src/replacers.ts b/src/replacers.ts index 116b6c3..1a4c836 100644 --- a/src/replacers.ts +++ b/src/replacers.ts @@ -1,6 +1,7 @@ import { DataSanitizationMatcher, DataSanitizationReplacer } from './types'; import { DEFAULT_FIELD_NAME_PATTERNS, DEFAULT_PATTERN_MASK } from './constants'; import defaultMatchers from './matchers'; +import { escapePattern } from './matchers'; /** * Sanitizes a string by masking or removing sensitive data. @@ -76,4 +77,107 @@ const stringReplacer: DataSanitizationReplacer = (data, options = {}) => { return data; }; -export { stringReplacer }; +/** + * Sanitizes object fields by key name, masking or removing matched keys. + * + * @param data - Object or array data to sanitize. + * @param options - Pattern, masking, and removal options. + * @returns Sanitized object/array data, or the original non-object data for runtime safety. + * @throws {TypeError} If a circular reference is encountered. + * + * @example + * objectReplacer({ password: 'secret', username: 'mark' }) + * // => { password: '**********', username: 'mark' } + * + * @example + * objectReplacer({ token: 123, username: 'mark' }, { removeMatches: true }) + * // => { username: 'mark' } + */ +const objectReplacer: DataSanitizationReplacer = (data, options = {}) => { + const { + customPatterns, + patternMask, + removeMatches = false, + useDefaultPatterns = true, + } = options; + + if (typeof data !== 'object' || data === null) { + return data; + } + + const mask = patternMask ?? DEFAULT_PATTERN_MASK; + + let patterns: string[] = []; + + if (useDefaultPatterns) { + patterns = [...DEFAULT_FIELD_NAME_PATTERNS]; + } + + if (Array.isArray(customPatterns)) { + patterns = [...patterns, ...customPatterns]; + } + + const keyMatchers = patterns.map( + (pattern) => new RegExp(`\\w*${escapePattern(pattern)}\\w*`, 'i'), + ); + const seen = new WeakSet(); + + /** + * Recursively sanitizes nested object and array values by key name. + * + * @param value - Current value to sanitize. + * @returns Sanitized clone of the provided value. + * @throws {TypeError} If a circular reference is encountered. + * + * @example + * sanitizeValue({ password: 'secret' }) + * // => { password: '**********' } + */ + const sanitizeValue = (value: unknown): unknown => { + if (typeof value !== 'object' || value === null) { + return value; + } + + if (seen.has(value)) { + throw new TypeError('Circular reference detected in object structure'); + } + + seen.add(value); + + if (Array.isArray(value)) { + const nextArray = value.map((item) => sanitizeValue(item)); + seen.delete(value); + return nextArray; + } + + const prototype = Object.getPrototypeOf(value); + if (prototype !== Object.prototype && prototype !== null) { + seen.delete(value); + return value; + } + + const nextObject: Record = {}; + + for (const [key, item] of Object.entries( + value as Record, + )) { + const isSensitiveKey = keyMatchers.some((matcher) => matcher.test(key)); + + if (isSensitiveKey) { + if (!removeMatches) { + nextObject[key] = mask; + } + continue; + } + + nextObject[key] = sanitizeValue(item); + } + + seen.delete(value); + return nextObject; + }; + + return sanitizeValue(data) as Record; +}; + +export { objectReplacer, stringReplacer }; diff --git a/test/index-errors.test.ts b/test/index-errors.test.ts index a845f8b..b1ff7e9 100644 --- a/test/index-errors.test.ts +++ b/test/index-errors.test.ts @@ -58,7 +58,7 @@ describe('DataSanitizationIndexAndErrors', () => { expect(nested.safe).toEqual('visible'); }); - it('should sanitize objects containing arrays', () => { + it('should mask array-valued sensitive object keys', () => { // Arrange const input = { tokens: ['a', 'b'], @@ -71,10 +71,53 @@ describe('DataSanitizationIndexAndErrors', () => { // Assert expect(output.password).toEqual(DEFAULT_PATTERN_MASK); - expect(output.tokens).toEqual(['a', 'b']); + expect(output.tokens).toEqual(DEFAULT_PATTERN_MASK); expect(output.username).toEqual('bar'); }); + it('should sanitize sensitive keys with non-string object values', () => { + // Arrange + const input = { + password: 123456, + secret: false, + token: null, + api_key: ['a', 'b'], + apikey: { nested: true }, + username: 'bar', + }; + + // Act + const output = sanitizeData(input) as Record; + + // Assert + expect(output.password).toEqual(DEFAULT_PATTERN_MASK); + expect(output.secret).toEqual(DEFAULT_PATTERN_MASK); + expect(output.token).toEqual(DEFAULT_PATTERN_MASK); + expect(output.api_key).toEqual(DEFAULT_PATTERN_MASK); + expect(output.apikey).toEqual(DEFAULT_PATTERN_MASK); + expect(output.username).toEqual('bar'); + }); + + it('should remove sensitive keys with non-string object values', () => { + // Arrange + const input = { + password: 123456, + secret: false, + token: null, + api_key: ['a', 'b'], + apikey: { nested: true }, + username: 'bar', + }; + + // Act + const output = sanitizeData(input, { + removeMatches: true, + }) as Record; + + // Assert + expect(output).toEqual({ username: 'bar' }); + }); + it('should sanitize an empty object without error', () => { // Arrange const input = {}; @@ -245,43 +288,21 @@ describe('DataSanitizationIndexAndErrors', () => { }); }); - it('should not expose sensitive object input in parse error details', () => { + it('should mask sensitive string values containing quotes', () => { // Arrange const input = { password: 'abc"def', username: 'mark', }; - let thrownError: unknown; // Act - const act = (): void => { - try { - sanitizeData(input); - } catch (error) { - thrownError = error; - throw error; - } - }; + const output = sanitizeData(input) as Record; // Assert - expect(act).toThrowError(DataSanitizationError); - expect(act).toThrowError('Error parsing data'); - expect(thrownError).toBeInstanceOf(DataSanitizationError); - - const details = (thrownError as DataSanitizationError).details as Record< - string, - unknown - >; - const serializedDetails = JSON.stringify(details); - - expect(details).toEqual({ - errorName: 'SyntaxError', - inputType: 'object', + expect(output).toEqual({ + password: DEFAULT_PATTERN_MASK, + username: 'mark', }); - expect(details).not.toHaveProperty('originalData'); - expect(details).not.toHaveProperty('error'); - expect(serializedDetails).not.toContain(input.password); - expect(serializedDetails).not.toContain(input.username); }); }); diff --git a/test/replacers.test.ts b/test/replacers.test.ts index 7d29d5e..fa434f4 100644 --- a/test/replacers.test.ts +++ b/test/replacers.test.ts @@ -2,7 +2,7 @@ import { describe, expect, it } from 'vitest'; /* local imports */ -import { stringReplacer } from '../src/replacers'; +import { objectReplacer, stringReplacer } from '../src/replacers'; import { DEFAULT_PATTERN_MASK } from '../src/constants'; describe('DataSanitizationReplacers', () => { @@ -271,4 +271,120 @@ describe('DataSanitizationReplacers', () => { }); }); }); + + describe('objectReplacer', () => { + describe('masking', () => { + it('should mask sensitive object keys with non-string values', () => { + // Arrange + const testData = { + password: 123, + secret: false, + token: null, + api_key: ['a', 'b'], + apikey: { nested: true }, + username: 'safe', + }; + + // Act + const result = objectReplacer(testData) as Record; + + // Assert + expect(result.password).toEqual(DEFAULT_PATTERN_MASK); + expect(result.secret).toEqual(DEFAULT_PATTERN_MASK); + expect(result.token).toEqual(DEFAULT_PATTERN_MASK); + expect(result.api_key).toEqual(DEFAULT_PATTERN_MASK); + expect(result.apikey).toEqual(DEFAULT_PATTERN_MASK); + expect(result.username).toEqual('safe'); + + // Revert: no cleanup required + }); + }); + + describe('removal', () => { + it('should remove sensitive object keys with non-string values', () => { + // Arrange + const testData = { + password: 123, + secret: false, + token: null, + api_key: ['a', 'b'], + apikey: { nested: true }, + username: 'safe', + }; + + // Act + const result = objectReplacer(testData, { + removeMatches: true, + }) as Record; + + // Assert + expect(result).toEqual({ username: 'safe' }); + + // Revert: no cleanup required + }); + }); + + describe('options', () => { + it('should return non-object input unchanged', () => { + // Arrange + const nonObjectInput = 'password=secret' as unknown as Record< + string, + unknown + >; + + // Act + const result = objectReplacer(nonObjectInput); + + // Assert + expect(result).toBe(nonObjectInput); + + // Revert: no cleanup required + }); + + it('should support custom patterns when default patterns are disabled', () => { + // Arrange + const dataWithCustomPattern = { + password: 'keep-me', + ssn: 123456789, + username: 'safe', + }; + + // Act + const result = objectReplacer(dataWithCustomPattern, { + customPatterns: ['ssn'], + patternMask: '[MASKED]', + useDefaultPatterns: false, + }) as Record; + + // Assert + expect(result).toEqual({ + password: 'keep-me', + ssn: '[MASKED]', + username: 'safe', + }); + + // Revert: no cleanup required + }); + + it('should preserve non-plain objects without corrupting their type', () => { + // Arrange + const date = new Date('2024-01-01'); + const testData = { + createdAt: date, + password: 'secret', + username: 'mark', + }; + + // Act + const result = objectReplacer(testData) as Record; + + // Assert + expect(result.createdAt).toBe(date); + expect(result.password).toEqual(DEFAULT_PATTERN_MASK); + expect(result.username).toEqual('mark'); + + // Revert: no cleanup required + }); + }); + }); });