UNPKG

data-guardian

Version:

Tiny, zero-dependencies, package which tries to mask sensitive data in arbitrary collections, errors, objects and strings.

293 lines 11.6 kB
"use strict"; Object.defineProperty(exports, "__esModule", { value: true }); exports.maskArguments = exports.maskData = exports.maskString = void 0; const helpers_1 = require("../utils/helpers"); const defaultSensitiveKeyFragments = new Set([ 'password', 'pwd', 'pass', 'token', 'secret', 'key', 'passphrase', 'privateKey', 'mail', 'shared', 'credential', 'auth', 'signature', 'certificate', 'crypt', 'apikey', 'security', 'phone', 'mobile' ]); const sensitiveContentRegExp = { uuid: /\b[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[89ab][0-9a-f]{3}-[0-9a-f]{12}\b/gi, creditCard: /\b(?:\d[ -]*?){13,16}\b/g, ssn: /\b[0-9]{3}-[0-9]{2}-[0-9]{4}\b/g, url: /\b(?:https?|ftp):\/\/[a-z0-9-+&@#/%?=~_|!:,.;]*[a-z0-9-+&@#/%=~_|]\b/gi, ipv4: /\b(?:[0-9]{1,3}\.){3}[0-9]{1,3}\b/g, email: /(?<=^|[\s'"-#+.><])[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Z|a-z]{2,}\b/g, passwordSubstring: /\b(?!password\b)\w*password\w*\b/gi, password: /\b(?!\w*\/)(?=\S*\d)(?=\S*[A-Za-z])[\w!@#$%^&*()_+=\-,.]{6,32}\b/gm, passwordInUri: /(?<=:\/\/[^:]+:)[^@]+?(?=@)/, passwordMention: /(?<=.*(password|passwd|pwd)(?:\s*:\s*|\s+))\S+/gi }; const SKIP_MASKING_PATTERN = /##([^#]*)##/g; function maskSensitiveValue(value, options) { const skipMaskingPattern = SKIP_MASKING_PATTERN; // Pattern to match content that should not be masked const maskLength = (options === null || options === void 0 ? void 0 : options.maskLength) || value.length - 4; const maskingChar = (options === null || options === void 0 ? void 0 : options.maskingChar) || '*'; // Skip masking if the entire value is wrapped in '##' if (skipMaskingPattern.test(value)) { return value; } if (options === null || options === void 0 ? void 0 : options.fixedMaskLength) { return maskingChar.repeat(16); } if (value.length <= 4 || maskLength >= value.length) { return maskingChar.repeat(value.length); } const visibleLength = (value.length - maskLength) / 2; return (value.substring(0, visibleLength) + maskingChar.repeat(maskLength) + value.substring(value.length - visibleLength)); } function shouldMaskKey(key) { const lowerCaseKey = key.toLowerCase(); for (const sensitiveKey of defaultSensitiveKeyFragments) { if (lowerCaseKey.includes(sensitiveKey)) { return true; } } return false; } function maskSensitiveContent(originalValue, defaultPatterns, customPatterns = {}, options) { const allPatterns = Object.assign(Object.assign({}, defaultPatterns), customPatterns); const skipMaskingPattern = SKIP_MASKING_PATTERN; // Pattern to match content that should not be masked const applyPatternMasking = (currentValue, pattern) => { let result = ''; let lastIndex = 0; // Process parts of the string outside '##' blocks currentValue.replace(skipMaskingPattern, (match, p1, offset) => { // Apply masking to the content before the '##' block const substring = currentValue.substring(lastIndex, offset); result += substring.replace(pattern, match => maskSensitiveValue(match, options)); // Don't mask inside the '##' block result += match; lastIndex = offset + match.length; return match; // Necessary for the replace function, though it's not used here }); // Apply masking to the content after the last '##' block const substring = currentValue.substring(lastIndex); result += substring.replace(pattern, match => maskSensitiveValue(match, options)); pattern.lastIndex = 0; // Reset regex state for global patterns return result; }; return Object.values(allPatterns).reduce((maskedValue, pattern) => applyPatternMasking(maskedValue, pattern), originalValue); } function unmaskContent(value) { const unmaskPattern = /##(.*?)##/g; let isUnmasked = false; const content = value.replace(unmaskPattern, (match, capture) => { isUnmasked = true; return capture; }); return { isUnmasked, content }; } function maskString(value, typesOrOptions, // This should be optional customSensitiveContentRegExp = {}, options) { if (!value || value.length === 0) return value; let types; if (typesOrOptions && !Array.isArray(typesOrOptions)) { // If typesOrOptions is not an array, it means it's the options parameter options = typesOrOptions; } else { types = typesOrOptions; } if (!value || value.length === 0) return value; // try to mask a stringified object correctly if (value.startsWith('{') && value.endsWith('}')) { let parsed; try { // if the provided string is nothing else but a stringified object, // parse it back to object, mask and stringify it again // ref issue: https://github.com/slippyex/data-guardian/issues/8 parsed = JSON.parse(value); const resulting = maskData(parsed); return JSON.stringify(resulting); } catch (err) { // ignore error } } // Check if the content is marked to be unmasked const { isUnmasked, content } = unmaskContent(value); if (isUnmasked) { return content; // return content without '##' and without masking } if (!types) types = Object.keys(sensitiveContentRegExp); if (!customSensitiveContentRegExp) customSensitiveContentRegExp = {}; if (options === null || options === void 0 ? void 0 : options.excludeMatchers) { const excludeMatchersSet = new Set(options.excludeMatchers); types = types.filter(t => !excludeMatchersSet.has(t)); } const applicablePatterns = types.reduce((acc, type) => { if (type in sensitiveContentRegExp) { // check if 'type' is a valid key to satisfy TypeScript's safety checks acc[type] = sensitiveContentRegExp[type]; // TypeScript now knows 'type' is a valid key } return acc; }, {}); // Also define the accumulator's type return maskSensitiveContent(value, applicablePatterns, customSensitiveContentRegExp, options); } exports.maskString = maskString; function maskMap(item, options) { // Process Map items const processedMap = options.immutable ? new Map() : item; item.forEach((value, key) => { if (typeof key === 'string') { processedMap.set(key, performMasking(key, value, options)); } else { // If the key is not a string, we can't check it against sensitive content, but we still mask the value. processedMap.set(key, maskData(value, options)); } }); return processedMap; } function maskSet(item, options) { const processedSet = options.immutable ? new Set() : item; const toAdd = []; const toRemove = []; item.forEach(value => { let maskedValue; if (typeof value === 'string') { // If the value is a string, perform masking maskedValue = maskString(value, undefined, options.customPatterns, options); } else if ((0, helpers_1.isObject)(value) || Array.isArray(value) || value instanceof Map) { // If the value is an object, array, or map, recursively call maskData maskedValue = maskData(value, options); } else { // If the value is of any other type, just keep the original value maskedValue = value; } if (options.immutable) { // If immutability is required, add masked value to the new set processedSet.add(maskedValue); } else { // If immutability is not required, track the original and masked values for later update toAdd.push(maskedValue); toRemove.push(value); } }); if (!options.immutable) { // Update the original set toRemove.forEach(value => item.delete(value)); toAdd.forEach(value => item.add(value)); } return processedSet; } function maskError(item, options) { const maskedError = new Error(maskString(item.message, undefined, options.customPatterns, options)); maskedError.stack = maskString(item.stack, undefined, options.customPatterns, options); return maskedError; } function maskObject(item, options) { if ((0, helpers_1.hasCircularReference)(item)) { return item; } // Clone the item if immutability is required const processedItem = options.immutable ? (0, helpers_1.deepClone)(item) : item; const assignMaskedValue = (obj, key, value) => { obj[key] = performMasking(key, value, options); }; return Object.entries(processedItem).reduce((acc, [key, value]) => { assignMaskedValue(acc, key, value); return acc; }, options.immutable ? {} : processedItem); } function maskNumber(item, options) { return maskSensitiveValue(String(item), options); } function maskArray(item, options) { return item.map(i => maskData(i, options)); } function performMasking(key, value, options) { // Check if the content is marked to be unmasked and it's a string if ((0, helpers_1.isString)(value)) { const { isUnmasked, content } = unmaskContent(value); if (isUnmasked) { return content; // return content without '##' and without masking } } if (options.keyCheck(key)) { if (options.convertNumbersToStringWhenFieldMatch && (0, helpers_1.isNumber)(value)) { return maskNumber(value, options); } if ((0, helpers_1.isString)(value)) { return maskSensitiveValue(value, options); // Pass maskLength here } } return maskData(value, options); } /** * Masks data based on the type and the provided masking options. * This function is recursively called for nested objects. * * @template T * @param {T} item - The original data to mask. * @param {Partial<IMaskDataOptions>} [options={}] - Optional masking options. * @returns {T} The masked data. */ function maskData(item, options = {}) { const { keyCheck = shouldMaskKey, immutable = true } = options; if ((0, helpers_1.isString)(item)) { // Pass custom regex patterns to maskString return maskString(item, undefined, options.customPatterns, options); } options.keyCheck = keyCheck; options.immutable = immutable; if (item instanceof Map) { return maskMap(item, options); } if (item instanceof Set) { return maskSet(item, options); } if (item instanceof Error) { return maskError(item, options); } if ((0, helpers_1.isObject)(item)) { return maskObject(item, options); } if (Array.isArray(item)) { return maskArray(item, options); } return item; } exports.maskData = maskData; /** * Masks arguments of a function call, based on the provided masking options. * * @template T * @param {unknown[]} args - The original arguments to mask. * @param {IMaskDataOptions} [options] - Optional masking options. * @returns {T[]} The masked arguments. */ function maskArguments(args, options) { if ((0, helpers_1.isNullish)(args) || args.length === 0) return args; return args.map(arg => typeof arg !== 'object' && typeof arg !== 'string' ? arg : maskData(arg, options)); } exports.maskArguments = maskArguments; //# sourceMappingURL=dataGuard.js.map