UNPKG

@hackylabs/deep-redact

Version:

A fast, safe and configurable zero-dependency library for redacting strings or deeply redacting arrays and objects.

488 lines (487 loc) 20.1 kB
import { standardTransformers } from './standardTransformers'; import { TransformerRegistry } from './TransformerRegistry'; const defaultConfig = { stringTests: [], blacklistedKeys: [], fuzzyKeyMatch: false, caseSensitiveKeyMatch: true, retainStructure: false, remove: false, replaceStringByLength: false, replacement: '[REDACTED]', types: ['string'], transformers: standardTransformers, }; class RedactorUtils { /** * The configuration for the redaction. * @private */ config = defaultConfig; /** * The computed regex pattern generated from sanitised blacklist keys of flat strings * @private */ computedRegex = null; /** * Regex to sanitise strings for the computed regex * @private */ sanitiseRegex = /[^a-zA-Z0-9_\-\$]/g; /** * The transformed blacklist keys of flat regex patterns and complex config objects * @private */ blacklistedKeysTransformed = []; /** * The transformer registry for efficient transformer lookup * @private */ transformerRegistry = new TransformerRegistry(); constructor(customConfig) { this.config = { ...defaultConfig, ...customConfig, }; this.blacklistedKeysTransformed = (customConfig.blacklistedKeys ?? []).filter(key => typeof key !== 'string').map((key) => this.createTransformedBlacklistedKey(key, customConfig)); const stringKeys = (customConfig.blacklistedKeys ?? []).filter(key => typeof key === 'string'); if (stringKeys.length > 0) this.computedRegex = new RegExp(stringKeys.map(this.sanitiseStringForRegex).filter(Boolean).join('|')); this.setupTransformerRegistry(this.config.transformers); } /** * Sets up the transformer registry based on the configuration * @param transformers - The transformer configuration * @private */ setupTransformerRegistry(transformers) { if (Array.isArray(transformers)) { transformers.forEach(transformer => { this.transformerRegistry.addFallbackTransformer(transformer); }); } else { const organised = transformers; if (organised.byType) { Object.entries(organised.byType).forEach(([type, typeTransformers]) => { if (typeTransformers) { typeTransformers.forEach(transformer => { this.transformerRegistry.addTypeTransformer(type, transformer); }); } }); } if (organised.byConstructor) { Object.entries(organised.byConstructor).forEach(([constructorName, constructorTransformers]) => { if (constructorTransformers) { const constructorMap = { Date, Error, Map, Set, RegExp, URL, }; const constructor = constructorMap[constructorName]; if (constructor) { constructorTransformers.forEach(transformer => { this.transformerRegistry.addConstructorTransformer(constructor, transformer); }); } } }); } } } createTransformedBlacklistedKey = (key, customConfig) => { if (key instanceof RegExp) { return { key, fuzzyKeyMatch: customConfig.fuzzyKeyMatch ?? defaultConfig.fuzzyKeyMatch, caseSensitiveKeyMatch: customConfig.caseSensitiveKeyMatch ?? defaultConfig.caseSensitiveKeyMatch, retainStructure: customConfig.retainStructure ?? defaultConfig.retainStructure, replacement: customConfig.replacement ?? defaultConfig.replacement, replaceStringByLength: customConfig.replaceStringByLength ?? defaultConfig.replaceStringByLength, remove: customConfig.remove ?? defaultConfig.remove, }; } return { fuzzyKeyMatch: key.fuzzyKeyMatch ?? customConfig.fuzzyKeyMatch ?? defaultConfig.fuzzyKeyMatch, caseSensitiveKeyMatch: key.caseSensitiveKeyMatch ?? customConfig.caseSensitiveKeyMatch ?? defaultConfig.caseSensitiveKeyMatch, retainStructure: key.retainStructure ?? customConfig.retainStructure ?? defaultConfig.retainStructure, replacement: key.replacement ?? customConfig.replacement ?? defaultConfig.replacement, replaceStringByLength: key.replaceStringByLength ?? customConfig.replaceStringByLength ?? defaultConfig.replaceStringByLength, remove: key.remove ?? customConfig.remove ?? defaultConfig.remove, key: key.key, }; }; /** * Applies transformers to a value * @param value - The value to transform * @param key - The key to check * @returns The transformed value * @private */ applyTransformers = (value, key, referenceMap) => { return this.transformerRegistry.applyTransformers(value, key, referenceMap); }; /** * Sanitises a string for the computed regex * @param key - The string to sanitise * @returns The sanitised string * @private */ sanitiseStringForRegex = (key) => key.replace(this.sanitiseRegex, ''); /** * Checks if a key should be redacted * @param key - The key to check * @returns Whether the key should be redacted * @private */ shouldRedactKey = (key) => { if (this.computedRegex?.test(this.sanitiseStringForRegex(key))) return true; return this.blacklistedKeysTransformed.some(config => { const pattern = config.key; if (pattern instanceof RegExp) return pattern.test(key); if (!config.fuzzyKeyMatch && !config.caseSensitiveKeyMatch) return key.toLowerCase() === pattern.toLowerCase(); if (config.fuzzyKeyMatch && !config.caseSensitiveKeyMatch) return key.toLowerCase().includes(pattern.toLowerCase()); if (config.fuzzyKeyMatch && config.caseSensitiveKeyMatch) return key.includes(pattern); if (!config.fuzzyKeyMatch && config.caseSensitiveKeyMatch) return key === pattern; }); }; /** * Checks if a value should be redacted * @param value - The value to check * @param key - The key to check * @returns Whether the value should be redacted * @private */ shouldRedactValue = (value, valueKey) => { if (!this.config.types.includes(typeof value)) return false; return this.shouldRedactKey(valueKey); }; /** * Redacts a value based on the key-specific config * @param value - The value to redact * @param key - The key to check * @param redactingParent - Whether the parent is being redacted * @returns The redacted value * @private */ redactValue = (value, redactingParent, keyConfig) => { if (!this.config.types.includes(typeof value)) return { transformed: value, redactingParent }; const remove = keyConfig?.remove ?? this.config.remove; const replacement = keyConfig?.replacement ?? this.config.replacement; const replaceStringByLength = keyConfig?.replaceStringByLength ?? this.config.replaceStringByLength; const retainStructure = keyConfig?.retainStructure ?? this.config.retainStructure; if (retainStructure && typeof value === 'object' && value !== null) return { transformed: value, redactingParent: true }; if (remove) return { transformed: undefined, redactingParent }; if (typeof replacement === 'function') return { transformed: replacement(value), redactingParent }; return { redactingParent, transformed: (typeof value === 'string' && replaceStringByLength) ? replacement.toString().repeat(value.length) : replacement, }; }; /** * Applies string transformations * @param value - The value to transform * @param key - The key to check * @returns The transformed value * @private */ applyStringTransformations(value, amRedactingParent, keyConfig) { if ((this.config.stringTests ?? []).length === 0) return { transformed: value, redactingParent: amRedactingParent }; for (const test of this.config.stringTests) { if (test instanceof RegExp) { if (test.test(value)) { const { transformed, redactingParent } = this.redactValue(value, amRedactingParent, keyConfig); return { transformed: transformed, redactingParent }; } } else { if (test.pattern.test(value)) { const transformed = test.replacer(value, test.pattern); return { transformed, redactingParent: amRedactingParent }; } } } return { transformed: value, redactingParent: amRedactingParent }; } /** * Handles primitive values * @param value - The value to handle * @param key - The key to check * @param redactingParent - Whether the parent is being redacted * @param keyConfig - The key config * @returns The transformed value * @private */ handlePrimitiveValue(value, valueKey, redactingParent, keyConfig) { let transformed = value; if (redactingParent) { if (valueKey === '_transformer' || !this.config.types.includes(typeof value)) { return { transformed: value, redactingParent }; } const { transformed: transformedValue } = this.redactValue(value, redactingParent, keyConfig); return { transformed: transformedValue, redactingParent }; } if (keyConfig || this.shouldRedactValue(value, valueKey)) { return this.redactValue(value, redactingParent, keyConfig); } if (typeof value === 'string') { return this.applyStringTransformations(value, redactingParent, keyConfig); } return { transformed, redactingParent }; } /** * Handles object values * @param value - The value to handle * @param key - The key to check * @param path - The path to the value * @param redactingParent - Whether the parent is being redacted * @param referenceMap - The reference map * @returns The transformed value and stack * @private */ handleObjectValue(value, key, path, amRedactingParent, referenceMap, keyConfig) { const fullPath = path.join('.'); const shouldRedact = amRedactingParent || Boolean(keyConfig) || this.shouldRedactValue(value, key); referenceMap.set(value, fullPath); if (shouldRedact && !(keyConfig?.retainStructure ?? this.config.retainStructure)) { const { transformed, redactingParent } = this.redactValue(value, amRedactingParent, keyConfig); return { transformed, redactingParent, stack: [] }; } return this.handleRetainStructure(value, path, shouldRedact); } /** * Handles object values * @param value - The value to handle * @param path - The path to the value * @param redactingParent - Whether the parent is being redacted * @returns The transformed value and stack * @private */ handleRetainStructure(value, path, redactingParent) { const newValue = Array.isArray(value) ? [] : {}; const stack = []; if (Array.isArray(value)) { for (let i = value.length - 1; i >= 0; i--) { stack.push({ parent: newValue, key: i.toString(), value: value[i], path: [...path, i], redactingParent, keyConfig: this.findMatchingKeyConfig(i.toString()), }); } } else { for (const [propKey, propValue] of Object.entries(value).reverse()) { stack.push({ parent: newValue, key: propKey, value: propValue, path: [...path, propKey], redactingParent, keyConfig: this.findMatchingKeyConfig(propKey), }); } } return { transformed: newValue, redactingParent, stack }; } /** * Finds the matching key config * @param key - The key to find * @returns The matching key config * @private */ findMatchingKeyConfig(key) { if (this.computedRegex?.test(key)) { return { key, fuzzyKeyMatch: this.config.fuzzyKeyMatch, caseSensitiveKeyMatch: this.config.caseSensitiveKeyMatch, replaceStringByLength: this.config.replaceStringByLength, replacement: this.config.replacement, retainStructure: this.config.retainStructure, remove: this.config.remove, }; } return this.blacklistedKeysTransformed.find(config => { const pattern = config.key; if (pattern instanceof RegExp) return pattern.test(key); if (config.fuzzyKeyMatch) { const compareKey = config.caseSensitiveKeyMatch ? key : key.toLowerCase(); const comparePattern = config.caseSensitiveKeyMatch ? pattern : pattern.toLowerCase(); return compareKey.includes(comparePattern); } return config.caseSensitiveKeyMatch ? key === pattern : key.toLowerCase() === pattern.toLowerCase(); }); } /** * Initialises the traversal * @param raw - The raw value to traverse * @returns The output and stack * @private */ initialiseTraversal(raw) { const output = Array.isArray(raw) ? [] : {}; const stack = []; if (typeof raw === 'object' && raw !== null) { if (Array.isArray(raw)) { for (let i = raw.length - 1; i >= 0; i--) { stack.push({ parent: output, key: i.toString(), value: raw[i], path: [i], redactingParent: false, keyConfig: this.findMatchingKeyConfig(i.toString()), }); } } else { for (const [propKey, propValue] of Object.entries(raw).reverse()) { stack.push({ parent: output, key: propKey, value: propValue, path: [propKey], redactingParent: false, keyConfig: this.findMatchingKeyConfig(propKey), }); } } } return { output, stack }; } /** * Pre-processes the input to replace circular references with transformer objects * @param raw - The raw value to process * @returns The processed value with circular references replaced * @private */ replaceCircularReferences(raw) { if (typeof raw !== 'object' || raw === null) return raw; const visiting = new WeakSet(); const pathMap = new WeakMap(); const processValue = (value, path) => { if (typeof value !== 'object' || value === null) return value; if (visiting.has(value)) { const originalPath = pathMap.get(value) || ''; return { _transformer: 'circular', value: originalPath, path: path }; } visiting.add(value); pathMap.set(value, path); let result; if (Array.isArray(value)) { let hasCircular = false; const newArray = value.map((item, index) => { const itemPath = path ? `${path}.${index}` : index.toString(); const processed = processValue(item, itemPath); if (processed !== item) hasCircular = true; return processed; }); result = hasCircular ? newArray : value; } else { let hasCircular = false; const newObj = {}; for (const [key, val] of Object.entries(value)) { const valuePath = path ? `${path}.${key}` : key; const processed = processValue(val, valuePath); newObj[key] = processed; if (processed !== val) hasCircular = true; } result = hasCircular ? newObj : value; } visiting.delete(value); return result; }; return processValue(raw, ''); } /** * Checks if a non-traversable value requires transformers * @param value - The value to check * @returns Whether the value requires transformers * @private */ requiresTransformers(value) { if (typeof value === 'bigint') return true; if (value instanceof Date) return true; if (value instanceof Error) return true; if (value instanceof Map) return true; if (value instanceof RegExp) return true; if (value instanceof Set) return true; if (value instanceof URL) return true; return false; } /** * Traverses the raw value * @param raw - The raw value to traverse * @returns The transformed value */ traverse = (raw) => { if (typeof raw === 'string') { const { transformed } = this.applyStringTransformations(raw, false); return transformed; } if (typeof raw !== 'object' || raw === null || this.requiresTransformers(raw)) return this.applyTransformers(raw); const referenceMap = new WeakMap(); const cleanedInput = this.replaceCircularReferences(raw); const { output, stack } = this.initialiseTraversal(cleanedInput); if (typeof cleanedInput === 'object' && cleanedInput !== null) referenceMap.set(cleanedInput, ''); while (stack.length > 0) { const { parent, key, value, path, redactingParent: amRedactingParent, keyConfig } = stack.pop(); let transformed = this.applyTransformers(value, key, referenceMap); let redactingParent = amRedactingParent; if (typeof transformed !== 'object' || transformed === null) { const primitiveResult = this.handlePrimitiveValue(transformed, key, amRedactingParent, keyConfig); redactingParent = primitiveResult.redactingParent; transformed = primitiveResult.transformed; if (typeof transformed === 'undefined') continue; } else { const objectResult = this.handleObjectValue(transformed, key, path, redactingParent, referenceMap, keyConfig); transformed = objectResult.transformed; stack.push(...objectResult.stack); } if (parent !== null && key !== null) parent[key] = transformed; } return output; }; } export default RedactorUtils;