Skip to content
31 changes: 17 additions & 14 deletions src/index.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { DataSanitizationError } from './errors';
import { stringReplacer } from './replacers';
import { objectReplacer, stringReplacer } from './replacers';
import { DataSanitizationReplacer } from './types';

/**
Expand Down Expand Up @@ -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.
*
Expand All @@ -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);
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}

throw new DataSanitizationError('Invalid data type', {
Expand Down
106 changes: 105 additions & 1 deletion src/replacers.ts
Original file line number Diff line number Diff line change
@@ -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.
Expand Down Expand Up @@ -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<object>();

/**
* 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<string, unknown> = {};

for (const [key, item] of Object.entries(
value as Record<string, unknown>,
)) {
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;
Comment thread
coderabbitai[bot] marked this conversation as resolved.
};

return sanitizeValue(data) as Record<string, unknown>;
};

export { objectReplacer, stringReplacer };
79 changes: 50 additions & 29 deletions test/index-errors.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'],
Expand All @@ -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<string, unknown>;

// 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<string, unknown>;

// Assert
expect(output).toEqual({ username: 'bar' });
});

it('should sanitize an empty object without error', () => {
// Arrange
const input = {};
Expand Down Expand Up @@ -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<string, unknown>;

// 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);
});
});

Expand Down
Loading
Loading