Skip to content

Commit

Permalink
- feature: ability to wrap content with ## which signals no-mask to t…
Browse files Browse the repository at this point in the history
…he processor

- feature: added a flag to always return a fixed-length mask in order to obfuscate actual length of the masked content
- refactoring: Overloaded "maskString" function in order to pass in an "options"
- bumped version: 1.0.8
  • Loading branch information
mvelten committed Oct 18, 2023
1 parent 9c6f6c4 commit c6b9d61
Show file tree
Hide file tree
Showing 4 changed files with 150 additions and 36 deletions.
23 changes: 22 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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.
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -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": [
Expand Down
138 changes: 105 additions & 33 deletions src/lib/dataGuard.ts
Original file line number Diff line number Diff line change
@@ -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<string, RegExp>;
maskingChar?: MaskingChar;
maskLength?: number;
types?: SensitiveContentKey[];
customSensitiveContentRegExp?: Record<string, RegExp>;
keyCheck: (key: string) => boolean;
immutable: boolean;
customPatterns: Record<string, RegExp>;
maskingChar: MaskingChar;
maskLength: number;
types: SensitiveContentKey[];
customSensitiveContentRegExp: Record<string, RegExp>;
/**
* 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<string> = new Set([
Expand Down Expand Up @@ -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<IMaskDataOptions>): 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);
}
Expand All @@ -78,19 +92,32 @@ function maskSensitiveContent(
originalValue: string,
defaultPatterns: typeof sensitiveContentRegExp,
customPatterns: Record<string, RegExp> = {},
options?: IMaskDataOptions
options?: Partial<IMaskDataOptions>
): 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(
Expand All @@ -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<IMaskDataOptions>): string;
export function maskString(
value: string,
types: SensitiveContentKey[] = Object.keys(sensitiveContentRegExp) as SensitiveContentKey[],
types: SensitiveContentKey[], // This remains non-optional
customSensitiveContentRegExp?: Record<string, RegExp>,
options?: Partial<IMaskDataOptions>
): string;

// Function implementation
export function maskString(
value: string,
typesOrOptions?: SensitiveContentKey[] | Partial<IMaskDataOptions>, // This should be optional
customSensitiveContentRegExp: Record<string, RegExp> = {},
options?: IMaskDataOptions
options?: Partial<IMaskDataOptions>
): 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 = {};

Expand All @@ -124,7 +189,7 @@ export function maskString(
return maskSensitiveContent(value, applicablePatterns, customSensitiveContentRegExp, options);
}

function maskMap<K, V>(item: Map<K, V>, options: IMaskDataOptions): Map<K, V> {
function maskMap<K, V>(item: Map<K, V>, options: Partial<IMaskDataOptions>): Map<K, V> {
// Process Map items
const processedMap = options.immutable ? new Map<K, V>() : item;

Expand All @@ -140,7 +205,7 @@ function maskMap<K, V>(item: Map<K, V>, options: IMaskDataOptions): Map<K, V> {
return processedMap;
}

function maskSet<T>(item: Set<T>, options: IMaskDataOptions): Set<T> {
function maskSet<T>(item: Set<T>, options: Partial<IMaskDataOptions>): Set<T> {
const processedSet = options.immutable ? new Set<T>() : item;

const toAdd: T[] = [];
Expand Down Expand Up @@ -178,13 +243,13 @@ function maskSet<T>(item: Set<T>, options: IMaskDataOptions): Set<T> {
return processedSet;
}

function maskError(item: Error, options: IMaskDataOptions): Error {
function maskError(item: Error, options: Partial<IMaskDataOptions>): 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<T>(item: T, options: IMaskDataOptions): T {
function maskObject<T>(item: T, options: Partial<IMaskDataOptions>): T {
// Clone the item if immutability is required
const processedItem = options.immutable ? deepClone(item) : item;

Expand All @@ -201,21 +266,28 @@ function maskObject<T>(item: T, options: IMaskDataOptions): T {
) as T;
}

function maskArray<T>(item: T[], options: IMaskDataOptions): T[] {
function maskArray<T>(item: T[], options: Partial<IMaskDataOptions>): T[] {
return item.map(i => maskData(i, options));
}

function performMasking<T>(key: string, value: unknown, options: IMaskDataOptions): T {
function performMasking<T>(key: string, value: unknown, options: Partial<IMaskDataOptions>): 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<T>(item: T, options: IMaskDataOptions = {}): T {
export function maskData<T>(item: T, options: Partial<IMaskDataOptions> = {}): T {
const { keyCheck = shouldMaskKey, immutable = true } = options;

if (isString(item)) {
Expand Down
23 changes: 22 additions & 1 deletion tests/masking.test.ts
Original file line number Diff line number Diff line change
@@ -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', () => {
Expand Down Expand Up @@ -370,11 +370,32 @@ 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', () => {
expect(
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'
});
});
});

0 comments on commit c6b9d61

Please sign in to comment.