diff --git a/README.md b/README.md index acf2283..12529c1 100644 --- a/README.md +++ b/README.md @@ -15,7 +15,11 @@ - 🔮 **Immutability (Optional)**: By default, it doesn't alter your original data structure unless configured to do so. - 🖌️ **Custom Masking**: Define your custom logic to pinpoint which keys in objects should be masked. - 📦 **Lightweight**: No dependencies, no bloat. `data-guardian` is a lightweight package that won't slow down your app. -- ⚙️ **Configurable**: Set masking char to one of commonly used chars and length of chars to mask out +- ⚙️ **Configurable**: Set the masking character to any commonly used character, and specify the length of the content to mask. +- 🕵️ **Fixed length masking**: Ability to mask a fixed length of characters in a string. This removes hints of actual length of the sensitive data. +- 📚 **Typescript Support**: `data-guardian` is written in TypeScript and comes with full type definitions. +- 📜 **Explicit exclusion for masking**: Explicitly exclude potential sensitive content in strings by wrapping the content with '##' + ## 🚀 Getting Started @@ -162,6 +166,23 @@ console.log(maskData(data, customMaskingConfig)); // Output: { id: 1, SensitiveInfo: 'Very##########Data' } ``` +### Explicit exclusion for masking: +`data-guardian` allows for explicit bypassing masking of potential sensitive content in strings by wrapping the content with '##' + +```javascript +const exampleString = 'SpanWidth01 is invalid!'; +console.log(maskString(exampleString)); +// Output: "Sp*******01 is invalid!" + +const exampleNonMaskedString = '##SpanWidth01## is invalid!'; +console.log(maskString(exampleNonMaskedString)); +// Output: "SpanWidth01 is invalid!" + +console.log(maskData({ username: 'johndoe', password: '##SuperSecretPassword!##' })); +// Output: { username: 'johndoe', password: 'SuperSecretPassword!' } + +``` + ## ⚠️ Disclaimer `data-guardian` is designed to provide an additional layer of security by masking strings and object properties that contain sensitive information. However, it is not a substitute for comprehensive security practices. Ensure you follow industry standards and regulations for data protection and privacy. diff --git a/package.json b/package.json index 40f9b62..341d4a0 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "data-guardian", - "version": "1.0.7", + "version": "1.0.8", "description": "Tiny, zero-dependencies, package which tries to mask sensitive data in arbitrary collections, errors, objects and strings.", "main": ".build/src/index.js", "files": [ diff --git a/src/lib/dataGuard.ts b/src/lib/dataGuard.ts index dabef92..429633a 100644 --- a/src/lib/dataGuard.ts +++ b/src/lib/dataGuard.ts @@ -1,16 +1,23 @@ import { deepClone, isNullish, isObject, isString } from '../utils/helpers'; -type SensitiveContentKey = keyof typeof sensitiveContentRegExp; +export type SensitiveContentKey = keyof typeof sensitiveContentRegExp; type MaskingChar = 'X' | 'x' | '$' | '/' | '.' | '*' | '#' | '+' | '@' | '-' | '_' | ' '; interface IMaskDataOptions { - keyCheck?: (key: string) => boolean; - immutable?: boolean; - customPatterns?: Record; - maskingChar?: MaskingChar; - maskLength?: number; - types?: SensitiveContentKey[]; - customSensitiveContentRegExp?: Record; + keyCheck: (key: string) => boolean; + immutable: boolean; + customPatterns: Record; + maskingChar: MaskingChar; + maskLength: number; + types: SensitiveContentKey[]; + customSensitiveContentRegExp: Record; + /** + * When enabled, masks the sensitive content with a fixed number of characters, + * irrespective of the original content's length. This prevents inferring the + * length of the original content from the masked output. + * Default is 'false', which means the mask will reflect the original content's length. + */ + fixedMaskLength: boolean; } const defaultSensitiveKeyFragments: Set = new Set([ @@ -43,16 +50,23 @@ const sensitiveContentRegExp = { email: /(?<=^|[\s'"-#+.><])[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Z|a-z]{2,}\b/g, password: /\b(?=\S*\d)(?=\S*[A-Za-z])[\w!@#$%^&*()_+=\-,.]{6,}\b/gm, passwordInUri: /(?<=:\/\/[^:]+:)[^@]+?(?=@)/, - // passwordMention: /(?<=.*(password|passwd|pwd)[:\s*]?)[^\s:]+/gi, passwordMention: /(?<=.*(password|passwd|pwd)(?:\s*:\s*|\s+))\S+/gi, - - // passwordMentionWithColon: /(?<=.*(password|passwd|pwd):\s*)[^\s:]+/gi, - // passwordMentionWithoutColon: /(?<=.*(password|passwd|pwd)\s+)[^\s:]+/gi, - 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 } as const; -function maskSensitiveValue(value: string, maskingChar: MaskingChar = '*', maskLength = value.length - 4): string { +function maskSensitiveValue(value: string, options: Partial): string { + const skipMaskingPattern = /##([^#]*)##/g; // Pattern to match content that should not be masked + const maskLength = options?.maskLength || value.length - 4; + const maskingChar = options?.maskingChar || '*'; + // Skip masking if the entire value is wrapped in '##' + if (skipMaskingPattern.test(value)) { + return value; + } + + if (options?.fixedMaskLength) { + return maskingChar.repeat(16); + } + if (value.length <= 4 || maskLength >= value.length) { return maskingChar.repeat(value.length); } @@ -78,19 +92,32 @@ function maskSensitiveContent( originalValue: string, defaultPatterns: typeof sensitiveContentRegExp, customPatterns: Record = {}, - options?: IMaskDataOptions + options?: Partial ): string { const allPatterns = { ...defaultPatterns, ...customPatterns }; + const skipMaskingPattern = /##([^#]*)##/g; // Pattern to match content that should not be masked const applyPatternMasking = (currentValue: string, pattern: RegExp): string => { - if (pattern) { - currentValue = currentValue.replace( - pattern, - match => maskSensitiveValue(match, options?.maskingChar, options?.maskLength) as string - ); - pattern.lastIndex = 0; // Reset regex state for global patterns - } - return currentValue; + 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( @@ -99,14 +126,52 @@ function maskSensitiveContent( ); } +function unmaskContent(value: string): { isUnmasked: boolean; content: string } { + const unmaskPattern = /##(.*?)##/g; + let isUnmasked = false; + const content = value.replace(unmaskPattern, (match, capture) => { + isUnmasked = true; + return capture; + }); + + return { isUnmasked, content }; +} + +// Overload signatures +export function maskString(value: string): string; +export function maskString(value: string, options: Partial): string; export function maskString( value: string, - types: SensitiveContentKey[] = Object.keys(sensitiveContentRegExp) as SensitiveContentKey[], + types: SensitiveContentKey[], // This remains non-optional + customSensitiveContentRegExp?: Record, + options?: Partial +): string; + +// Function implementation +export function maskString( + value: string, + typesOrOptions?: SensitiveContentKey[] | Partial, // This should be optional customSensitiveContentRegExp: Record = {}, - options?: IMaskDataOptions + options?: Partial ): string { if (!value || value.length === 0) return value; + let types: SensitiveContentKey[] | undefined; + if (typesOrOptions && !Array.isArray(typesOrOptions)) { + // If typesOrOptions is not an array, it means it's the options parameter + options = typesOrOptions; + } else { + types = typesOrOptions as SensitiveContentKey[]; + } + + if (!value || value.length === 0) return value; + + // 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) as SensitiveContentKey[]; if (!customSensitiveContentRegExp) customSensitiveContentRegExp = {}; @@ -124,7 +189,7 @@ export function maskString( return maskSensitiveContent(value, applicablePatterns, customSensitiveContentRegExp, options); } -function maskMap(item: Map, options: IMaskDataOptions): Map { +function maskMap(item: Map, options: Partial): Map { // Process Map items const processedMap = options.immutable ? new Map() : item; @@ -140,7 +205,7 @@ function maskMap(item: Map, options: IMaskDataOptions): Map { return processedMap; } -function maskSet(item: Set, options: IMaskDataOptions): Set { +function maskSet(item: Set, options: Partial): Set { const processedSet = options.immutable ? new Set() : item; const toAdd: T[] = []; @@ -178,13 +243,13 @@ function maskSet(item: Set, options: IMaskDataOptions): Set { return processedSet; } -function maskError(item: Error, options: IMaskDataOptions): Error { +function maskError(item: Error, options: Partial): Error { 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: T, options: IMaskDataOptions): T { +function maskObject(item: T, options: Partial): T { // Clone the item if immutability is required const processedItem = options.immutable ? deepClone(item) : item; @@ -201,21 +266,28 @@ function maskObject(item: T, options: IMaskDataOptions): T { ) as T; } -function maskArray(item: T[], options: IMaskDataOptions): T[] { +function maskArray(item: T[], options: Partial): T[] { return item.map(i => maskData(i, options)); } -function performMasking(key: string, value: unknown, options: IMaskDataOptions): T { +function performMasking(key: string, value: unknown, options: Partial): T { + // Check if the content is marked to be unmasked and it's a string + if (isString(value)) { + const { isUnmasked, content } = unmaskContent(value); + if (isUnmasked) { + return content as T; // return content without '##' and without masking + } + } return ( options.keyCheck(key) ? isString(value) - ? maskSensitiveValue(value, options?.maskingChar, options?.maskLength) // Pass maskLength here + ? maskSensitiveValue(value, options) // Pass maskLength here : maskData(value, options) : maskData(value, options) ) as T; } -export function maskData(item: T, options: IMaskDataOptions = {}): T { +export function maskData(item: T, options: Partial = {}): T { const { keyCheck = shouldMaskKey, immutable = true } = options; if (isString(item)) { diff --git a/tests/masking.test.ts b/tests/masking.test.ts index 1b7c336..3f1f626 100644 --- a/tests/masking.test.ts +++ b/tests/masking.test.ts @@ -1,4 +1,4 @@ -import { maskArguments, maskData, maskString } from '../src/'; +import { maskArguments, maskData, maskString, SensitiveContentKey } from '../src/'; describe('Test all possible masking', () => { it('should be able to deal with nullish values', () => { @@ -370,6 +370,7 @@ describe('Test all possible masking', () => { expect(maskString('here is my SecretPassword: test')).toBe('here is my SecretPassword: ****'); expect(maskString('here is my passwd test')).toBe('here is my passwd ****'); expect(maskString('here is my pwd test01!')).toBe('here is my pwd te***1!'); + expect(maskString('here is my pwd test01!')).toBe('here is my pwd te***1!'); }); it('should capture any password in an uri-based string fragment', () => { @@ -377,4 +378,24 @@ describe('Test all possible masking', () => { maskString('connection to postgres://dbuser:MySuperSecretPassword@myhost.com successfully established') ).toBe('connection to postgres://dbuser:My*****************rd@myhost.com successfully established'); }); + + it('should skip masking a marked content in a string', () => { + expect(maskString('simpleSpan01 is invalid!')).toBe('si********01 is invalid!'); + expect(maskString('##simpleSpan01## is invalid!')).toBe('simpleSpan01 is invalid!'); + expect(maskData({ username: 'johndoe', password: '##SuperSecretPassword!##' })).toEqual({ + username: 'johndoe', + password: 'SuperSecretPassword!' + }); + }); + + it('should return a fixed mask length content', () => { + expect(maskString('my shiny password: test01!')).toBe('my shiny password: te***1!'); + expect(maskString('my shiny password: test01!', { fixedMaskLength: true })).toBe( + 'my shiny password: ****************' + ); + expect(maskData({ username: 'johndoe', password: 'testPass' }, { maskingChar: 'X', fixedMaskLength: true })).toEqual({ + username: 'johndoe', + password: 'XXXXXXXXXXXXXXXX' + }); + }); });