From 99c1b6c616eb310f2f81db12563e7483339cac63 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 17 May 2026 16:53:06 +0000 Subject: [PATCH 01/10] Initial plan From eff924636dfb2e8cad81c35f5a94576389d76b4a Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 17 May 2026 16:56:32 +0000 Subject: [PATCH 02/10] fix: sanitize non-string sensitive object values Agent-Logs-Url: https://github.com/ioncache/data-sanitization/sessions/044841f7-a98a-45e9-99ea-23a14c0e74ba --- src/index.ts | 15 +++++-- src/replacers.ts | 89 ++++++++++++++++++++++++++++++++++++++- test/index-errors.test.ts | 79 +++++++++++++++++++++------------- test/replacers.test.ts | 51 +++++++++++++++++++++- 4 files changed, 199 insertions(+), 35 deletions(-) diff --git a/src/index.ts b/src/index.ts index 09cf7f9..b2224cf 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'; /** @@ -93,9 +93,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..543069f 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,90 @@ 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(); + + const sanitizeValue = (value: unknown): unknown => { + if (typeof value !== 'object' || value === null) { + return value; + } + + if (seen.has(value)) { + throw new TypeError('Converting circular structure to JSON'); + } + + seen.add(value); + + if (Array.isArray(value)) { + const nextArray = value.map((item) => sanitizeValue(item)); + seen.delete(value); + return nextArray; + } + + 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..43ba109 100644 --- a/test/index-errors.test.ts +++ b/test/index-errors.test.ts @@ -61,7 +61,7 @@ describe('DataSanitizationIndexAndErrors', () => { it('should sanitize objects containing arrays', () => { // Arrange const input = { - tokens: ['a', 'b'], + items: ['a', 'b'], username: 'bar', password: 'secret', }; @@ -71,10 +71,53 @@ describe('DataSanitizationIndexAndErrors', () => { // Assert expect(output.password).toEqual(DEFAULT_PATTERN_MASK); - expect(output.tokens).toEqual(['a', 'b']); + expect(output.items).toEqual(['a', 'b']); 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 sanitize object values with embedded quotes without parse errors', () => { // 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..6586592 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,53 @@ 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'); + }); + }); + + 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' }); + }); + }); + }); }); From 1514e0f45c3326bdc564bdaff95166d445473e6f Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 17 May 2026 16:57:48 +0000 Subject: [PATCH 03/10] test: align object sanitizer regressions and docs Agent-Logs-Url: https://github.com/ioncache/data-sanitization/sessions/044841f7-a98a-45e9-99ea-23a14c0e74ba --- src/replacers.ts | 11 +++++++++++ test/index-errors.test.ts | 4 ++-- 2 files changed, 13 insertions(+), 2 deletions(-) diff --git a/src/replacers.ts b/src/replacers.ts index 543069f..d2722a2 100644 --- a/src/replacers.ts +++ b/src/replacers.ts @@ -122,6 +122,17 @@ const objectReplacer: DataSanitizationReplacer = (data, options = {}) => { ); 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; diff --git a/test/index-errors.test.ts b/test/index-errors.test.ts index 43ba109..854d80c 100644 --- a/test/index-errors.test.ts +++ b/test/index-errors.test.ts @@ -61,7 +61,7 @@ describe('DataSanitizationIndexAndErrors', () => { it('should sanitize objects containing arrays', () => { // Arrange const input = { - items: ['a', 'b'], + tokens: ['a', 'b'], username: 'bar', password: 'secret', }; @@ -71,7 +71,7 @@ describe('DataSanitizationIndexAndErrors', () => { // Assert expect(output.password).toEqual(DEFAULT_PATTERN_MASK); - expect(output.items).toEqual(['a', 'b']); + expect(output.tokens).toEqual(DEFAULT_PATTERN_MASK); expect(output.username).toEqual('bar'); }); From 6a436e0a6722f19a3ed3c2588f7d855df03a8024 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 17 May 2026 16:58:55 +0000 Subject: [PATCH 04/10] chore: polish object sanitizer review feedback Agent-Logs-Url: https://github.com/ioncache/data-sanitization/sessions/044841f7-a98a-45e9-99ea-23a14c0e74ba --- src/replacers.ts | 2 +- test/index-errors.test.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/replacers.ts b/src/replacers.ts index d2722a2..650652b 100644 --- a/src/replacers.ts +++ b/src/replacers.ts @@ -139,7 +139,7 @@ const objectReplacer: DataSanitizationReplacer = (data, options = {}) => { } if (seen.has(value)) { - throw new TypeError('Converting circular structure to JSON'); + throw new TypeError('Circular reference detected in object structure'); } seen.add(value); diff --git a/test/index-errors.test.ts b/test/index-errors.test.ts index 854d80c..06944ca 100644 --- a/test/index-errors.test.ts +++ b/test/index-errors.test.ts @@ -288,7 +288,7 @@ describe('DataSanitizationIndexAndErrors', () => { }); }); - it('should sanitize object values with embedded quotes without parse errors', () => { + it('should sanitize object values with embedded quotes', () => { // Arrange const input = { password: 'abc"def', From 2d72747daf98c64ccb36760d4405d9feda9ec338 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 17 May 2026 16:59:52 +0000 Subject: [PATCH 05/10] test: clarify array-sensitive key masking expectation Agent-Logs-Url: https://github.com/ioncache/data-sanitization/sessions/044841f7-a98a-45e9-99ea-23a14c0e74ba --- test/index-errors.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/index-errors.test.ts b/test/index-errors.test.ts index 06944ca..4b89595 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'], From 8b93bf0b98b2b1fe729274a5056cf2244bf8e762 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 17 May 2026 17:00:45 +0000 Subject: [PATCH 06/10] test: refine quote-masking regression title Agent-Logs-Url: https://github.com/ioncache/data-sanitization/sessions/044841f7-a98a-45e9-99ea-23a14c0e74ba --- test/index-errors.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/index-errors.test.ts b/test/index-errors.test.ts index 4b89595..b1ff7e9 100644 --- a/test/index-errors.test.ts +++ b/test/index-errors.test.ts @@ -288,7 +288,7 @@ describe('DataSanitizationIndexAndErrors', () => { }); }); - it('should sanitize object values with embedded quotes', () => { + it('should mask sensitive string values containing quotes', () => { // Arrange const input = { password: 'abc"def', From 8e1dbd89c1ef6de2893184ae61fe06722e1755de Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 17 May 2026 17:16:50 +0000 Subject: [PATCH 07/10] test: cover object replacer option branches Agent-Logs-Url: https://github.com/ioncache/data-sanitization/sessions/5fae50c0-a3fc-45f6-a3d2-5e0a6dc98cf5 --- test/replacers.test.ts | 36 ++++++++++++++++++++++++++++++++++++ 1 file changed, 36 insertions(+) diff --git a/test/replacers.test.ts b/test/replacers.test.ts index 6586592..010eec8 100644 --- a/test/replacers.test.ts +++ b/test/replacers.test.ts @@ -319,5 +319,41 @@ describe('DataSanitizationReplacers', () => { expect(result).toEqual({ username: 'safe' }); }); }); + + describe('options', () => { + it('should return non-object input unchanged for runtime safety', () => { + // Arrange + const testData = 'password=secret' as unknown as Record; + + // Act + const result = objectReplacer(testData); + + // Assert + expect(result).toBe(testData); + }); + + it('should support custom patterns when default patterns are disabled', () => { + // Arrange + const testData = { + password: 'keep-me', + ssn: 123456789, + username: 'safe', + }; + + // Act + const result = objectReplacer(testData, { + customPatterns: ['ssn'], + patternMask: '[MASKED]', + useDefaultPatterns: false, + }) as Record; + + // Assert + expect(result).toEqual({ + password: 'keep-me', + ssn: '[MASKED]', + username: 'safe', + }); + }); + }); }); }); From b905583ac93731cc2987063ca2ebc50c9504a664 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 17 May 2026 17:17:30 +0000 Subject: [PATCH 08/10] test: tighten object replacer coverage assertions Agent-Logs-Url: https://github.com/ioncache/data-sanitization/sessions/5fae50c0-a3fc-45f6-a3d2-5e0a6dc98cf5 --- test/replacers.test.ts | 16 ++++++++++------ 1 file changed, 10 insertions(+), 6 deletions(-) diff --git a/test/replacers.test.ts b/test/replacers.test.ts index 010eec8..4107884 100644 --- a/test/replacers.test.ts +++ b/test/replacers.test.ts @@ -321,27 +321,31 @@ describe('DataSanitizationReplacers', () => { }); describe('options', () => { - it('should return non-object input unchanged for runtime safety', () => { + it('should return non-object input unchanged', () => { // Arrange - const testData = 'password=secret' as unknown as Record; + const nonObjectInput = 'password=secret' as unknown as Record< + string, + unknown + >; // Act - const result = objectReplacer(testData); + const result = objectReplacer(nonObjectInput); // Assert - expect(result).toBe(testData); + expect(() => objectReplacer(nonObjectInput)).not.toThrow(); + expect(result).toBe(nonObjectInput); }); it('should support custom patterns when default patterns are disabled', () => { // Arrange - const testData = { + const dataWithCustomPattern = { password: 'keep-me', ssn: 123456789, username: 'safe', }; // Act - const result = objectReplacer(testData, { + const result = objectReplacer(dataWithCustomPattern, { customPatterns: ['ssn'], patternMask: '[MASKED]', useDefaultPatterns: false, From 6c1157ba3604d2f26666be61fc45943f39c44d09 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 17 May 2026 17:17:57 +0000 Subject: [PATCH 09/10] test: remove redundant coverage assertion Agent-Logs-Url: https://github.com/ioncache/data-sanitization/sessions/5fae50c0-a3fc-45f6-a3d2-5e0a6dc98cf5 --- test/replacers.test.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/test/replacers.test.ts b/test/replacers.test.ts index 4107884..3bb8ad1 100644 --- a/test/replacers.test.ts +++ b/test/replacers.test.ts @@ -332,7 +332,6 @@ describe('DataSanitizationReplacers', () => { const result = objectReplacer(nonObjectInput); // Assert - expect(() => objectReplacer(nonObjectInput)).not.toThrow(); expect(result).toBe(nonObjectInput); }); From 1ff40ebec06d728ab97e053726781506add66e93 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 17 May 2026 17:27:06 +0000 Subject: [PATCH 10/10] fix: address CodeRabbit review comments --- src/index.ts | 16 ++++++---------- src/replacers.ts | 6 ++++++ test/replacers.test.ts | 28 ++++++++++++++++++++++++++++ 3 files changed, 40 insertions(+), 10 deletions(-) diff --git a/src/index.ts b/src/index.ts index b2224cf..6d3d894 100644 --- a/src/index.ts +++ b/src/index.ts @@ -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. * diff --git a/src/replacers.ts b/src/replacers.ts index 650652b..1a4c836 100644 --- a/src/replacers.ts +++ b/src/replacers.ts @@ -150,6 +150,12 @@ const objectReplacer: DataSanitizationReplacer = (data, options = {}) => { 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( diff --git a/test/replacers.test.ts b/test/replacers.test.ts index 3bb8ad1..fa434f4 100644 --- a/test/replacers.test.ts +++ b/test/replacers.test.ts @@ -295,6 +295,8 @@ describe('DataSanitizationReplacers', () => { expect(result.api_key).toEqual(DEFAULT_PATTERN_MASK); expect(result.apikey).toEqual(DEFAULT_PATTERN_MASK); expect(result.username).toEqual('safe'); + + // Revert: no cleanup required }); }); @@ -317,6 +319,8 @@ describe('DataSanitizationReplacers', () => { // Assert expect(result).toEqual({ username: 'safe' }); + + // Revert: no cleanup required }); }); @@ -333,6 +337,8 @@ describe('DataSanitizationReplacers', () => { // Assert expect(result).toBe(nonObjectInput); + + // Revert: no cleanup required }); it('should support custom patterns when default patterns are disabled', () => { @@ -356,6 +362,28 @@ describe('DataSanitizationReplacers', () => { 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 }); }); });