diff --git a/package-lock.json b/package-lock.json index f7a555f..45cdf31 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,6 +1,6 @@ { "name": "@luckbox/logger-factory", - "version": "3.2.0", + "version": "4.0.0", "lockfileVersion": 1, "requires": true, "dependencies": { diff --git a/package.json b/package.json index 57b4a4e..092ccae 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@luckbox/logger-factory", - "version": "3.2.0", + "version": "4.0.0", "description": "Easy to use logger with several levels of logging as well as different adapters that can be used separately or in combinations", "author": "Luckbox", "license": "ISC", diff --git a/src/Masker.ts b/src/Masker.ts new file mode 100644 index 0000000..5b46bf5 --- /dev/null +++ b/src/Masker.ts @@ -0,0 +1,111 @@ +import lodash from 'lodash'; + +enum Tag { + PII = 'PII', + SECRET = 'SECRET' +} + +type PlainObject = Record; + +class Masker { + public maskString(value: string, tag: Tag): string { + return `[${tag}]${value}[/${tag}]`; + } + + public maskNumber(value: number, tag: Tag): string { + return `[${tag}]${value}[/${tag}]`; + } + + public maskObject(object: T, maskSettings: Array<[string, Tag]>): T { + if (this.isError(object)) { + return this.maskError(object, maskSettings); + } else if (this.isPlainObject(object)) { + return this.maskPlainObject(object, maskSettings); + } + + throw new Error('Input must be plain object or a class inheriting from Error'); + } + + private maskPlainObject(plainObject: T, maskSettings: Array<[string, Tag]>): T { + const clonedObj = this.clone(plainObject); + const pathToTagMap = this.constructPathToTagMap(maskSettings); + + const allPaths = this.collectPaths(plainObject); + for (const path of allPaths) { + const tag = pathToTagMap.get(path); + if (!tag) { + continue; + } + + const rawValue = lodash.get(clonedObj, path); + if (typeof rawValue === 'string') { + lodash.set(clonedObj, path, this.maskString(rawValue, tag)); + } + + if (typeof rawValue === 'number') { + lodash.set(clonedObj, path, this.maskNumber(rawValue, tag)); + } + } + + return clonedObj; + } + + private maskError(err: T, maskSettings: Array<[string, Tag]>): T { + const clonedErr = new Error(err.message); + Object.setPrototypeOf(clonedErr, Object.getPrototypeOf(err)); + + const dataToAssign = {}; + for (const prop of Object.getOwnPropertyNames(err)) { + dataToAssign[prop] = this.clone(err[prop]); + } + + Object.assign(clonedErr, this.maskPlainObject(dataToAssign, maskSettings)); + + return clonedErr as T; + } + + private collectPaths(input: any, currentPath?: string) { + const paths: Array = []; + + if (lodash.isPlainObject(input)) { + for (const key in input) { + const fullPath = this.buildPath(key, currentPath); + const value = input[key]; + + paths.push(fullPath, ...this.collectPaths(value).map((nestedPath) => this.buildPath(nestedPath, fullPath))); + } + } + + return paths; + } + + private buildPath(propPath: string, basePath?: string) { + return basePath === undefined ? String(propPath) : `${basePath}.${propPath}`; + } + + private isPlainObject(value: unknown): value is PlainObject { + return lodash.isPlainObject(value); + } + + private isError(value: unknown): value is Error { + return value instanceof Error; + } + + private constructPathToTagMap(maskSettings: Array<[string, Tag]>) { + const pathToTagMap = new Map(); + for (const [path, tag] of maskSettings) { + pathToTagMap.set(path, tag); + } + + return pathToTagMap; + } + + private clone(value: T): T { + return lodash.cloneDeep(value); + } +} + +export { + Masker, + Tag, +}; diff --git a/src/Obfuscator.ts b/src/Obfuscator.ts deleted file mode 100644 index a7aa512..0000000 --- a/src/Obfuscator.ts +++ /dev/null @@ -1,104 +0,0 @@ -import lodash from 'lodash'; - -enum Tag { - PII = 'PII', - SECRET = 'SECRET' -} - -type PlainObject = Record; - -class Obfuscator { - public obfuscateString(value: string, tag: Tag): string { - return `[${tag}]${value}[/${tag}]`; - } - - public obfuscateObject(object: T, obfuscateSettings: Array<[string, Tag]>): T { - if (this.isError(object)) { - return this.obfuscateError(object, obfuscateSettings); - } else if (this.isPlainObject(object)) { - return this.obfuscatePlainObject(object, obfuscateSettings); - } - - throw new Error('Input must be plain object or a class inheriting from Error'); - - } - - private obfuscatePlainObject(plainObject: T, obfuscateSettings: Array<[string, Tag]>): T { - const clonedObj = lodash.cloneDeep(plainObject); - const allPaths: Array = this.collectPaths(plainObject); - - const pathToTagMap = new Map(); - const allPathsToObfuscate = []; - - for (const anObfuscateSetting of obfuscateSettings) { - pathToTagMap.set(anObfuscateSetting[0], anObfuscateSetting[1]); - allPathsToObfuscate.push(anObfuscateSetting[0]); - } - - for (const path of allPaths) { - const actualPath = path[0]; - if (allPathsToObfuscate.some((propPath) => new RegExp(propPath[0]).test(actualPath))) { - const rawValue = lodash.get(clonedObj, path); - - const tag = this.determineTag(pathToTagMap, path); - if (!tag) { - continue; - } - lodash.set(clonedObj, path, this.obfuscateString(rawValue as string, tag)); - } - } - - return clonedObj; - } - - private obfuscateError(err: T, obfuscateSettings: Array<[string, Tag]>): T { - const clonedErr = new Error(err.message); - Object.setPrototypeOf(clonedErr, Object.getPrototypeOf(err)); - - const dataToAssign = {}; - for (const prop of Object.getOwnPropertyNames(err)) { - dataToAssign[prop] = lodash.cloneDeep(err[prop]); - } - - Object.assign(clonedErr, this.obfuscatePlainObject(dataToAssign, obfuscateSettings)); - - return clonedErr as T; - } - - private collectPaths(input: any, currentPath?: string) { - const paths = []; - - if (lodash.isPlainObject(input)) { - for (const key in input) { - const fullPath: string = this.buildPath(key, currentPath); - const value = input[key]; - - paths.push(fullPath, ...this.collectPaths(value).map((nestedPath) => this.buildPath(nestedPath, fullPath))); - } - } - - return paths; - } - - private buildPath(propPath: string, basePath?: string) { - return basePath === undefined ? String(propPath) : `${basePath}.${propPath}`; - } - - private determineTag(pathToTagMap: any, path: string) { - const tag = pathToTagMap.get(path); - return tag; - } - - private isPlainObject(value: unknown): value is PlainObject { - return lodash.isPlainObject(value); - } - - private isError(value: unknown): value is Error { - return value instanceof Error; - } -} - -export { - Obfuscator, - Tag, -}; diff --git a/src/index.ts b/src/index.ts index 48b348d..65f6005 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,6 +1,6 @@ import { LoggerFactory, Adapters, LogLevels, ConsoleAdapterSettings, SentryAdapterSettings } from './LoggerFactory'; import { Logger } from './Logger'; -import { Obfuscator, Tag } from './Obfuscator'; +import { Masker, Tag } from './Masker'; export { LoggerFactory, @@ -9,6 +9,6 @@ export { Logger, ConsoleAdapterSettings, SentryAdapterSettings, - Obfuscator, + Masker, Tag as ObfuscatorTag, }; diff --git a/src/tests/unit-tests/Masker.test.ts b/src/tests/unit-tests/Masker.test.ts new file mode 100644 index 0000000..718d2b3 --- /dev/null +++ b/src/tests/unit-tests/Masker.test.ts @@ -0,0 +1,78 @@ +import { Masker, Tag } from '../../Masker'; + +describe('Masker', () => { + const masker = new Masker(); + + describe('maskString', () => { + it('should wrap the provided tag around the string that needs to be maskd', () => { + expect(masker.maskString('string', Tag.PII)).toEqual('[PII]string[/PII]'); + }); + }); + + describe('maskNumber', () => { + it('should wrap the provided tag around the number that needs to be maskd', () => { + expect(masker.maskNumber(42, Tag.PII)).toEqual('[PII]42[/PII]'); + }); + }); + + describe('maskObject', () => { + it('should wrap the provided tag around root-level elements in object', () => { + const originalObject = { name: 'Pencho' }; + const maskedObject = { name: '[PII]Pencho[/PII]' }; + expect(masker.maskObject(originalObject, [['name', Tag.PII]])).toEqual(maskedObject); + }); + + it('should wrap the provided tag around nested elements in object', () => { + const originalObject = { id: 1, data: { name: 'Gosho', email: 'email@example.com' } }; + const maskedObject = { id: 1, data: { name: '[PII]Gosho[/PII]', email: '[PII]email@example.com[/PII]' } }; + expect(masker.maskObject(originalObject, [['data.name', Tag.PII], ['data.email', Tag.PII]])).toEqual(maskedObject); + }); + + it('should work with numbers', () => { + const originalObject = { id: 1 }; + + expect(masker.maskObject(originalObject, [['id', Tag.PII]])).toEqual({ + id: '[PII]1[/PII]', + }); + }); + + it('should NOT wrap the provided tag around elements that are not specified for obfuscating in object', () => { + const originalObject = { favouriteColor: 'red', nested: { field: 'value' } }; + expect(masker.maskObject(originalObject, [['name', Tag.PII]])).toEqual(originalObject); + }); + + it('should return a copy of the error and not modify the original', () => { + const originalError = new Error(); + const maskedError = masker.maskObject(originalError, [['bar', Tag.PII]]); + expect(maskedError).not.toBe(originalError); + }); + + it('should preserve the prototype, name, message and stack of the error', () => { + class CustomError extends Error {} + const originalError = new CustomError(); + const maskedError = masker.maskObject(originalError, [['bar', Tag.PII]]); + + expect(maskedError).toBeInstanceOf(CustomError); + expect(maskedError.name).toEqual(originalError.name); + expect(maskedError.message).toEqual(originalError.message); + expect(maskedError.stack).toEqual(originalError.stack); + }); + + it('should mask error specific props', () => { + class CustomError extends Error { + bar = 'foo' + foo = { + test: 'test', + } + } + const originalError = new CustomError(); + const maskedError = masker.maskObject(originalError, [ + ['bar', Tag.PII], + ['foo.test', Tag.PII], + ]); + + expect(maskedError.bar).toEqual('[PII]foo[/PII]'); + expect(maskedError.foo.test).toEqual('[PII]test[/PII]'); + }); + }); +}); diff --git a/src/tests/unit-tests/Obfuscator.test.ts b/src/tests/unit-tests/Obfuscator.test.ts deleted file mode 100644 index cbdc939..0000000 --- a/src/tests/unit-tests/Obfuscator.test.ts +++ /dev/null @@ -1,64 +0,0 @@ -import { Obfuscator, Tag } from '../../Obfuscator'; - -describe('Obfuscator', () => { - const obfuscator = new Obfuscator(); - - describe('obfuscateString', () => { - it('should wrap the provided tag around the string that needs to be obfuscated', () => { - expect(obfuscator.obfuscateString('string', Tag.PII)).toEqual('[PII]string[/PII]'); - }); - }); - - describe('obfuscateObject', () => { - it('should wrap the provided tag around root-level elements in object', () => { - const originalObject = { name: 'Pencho' }; - const obfuscatedObject = { name: '[PII]Pencho[/PII]' }; - expect(obfuscator.obfuscateObject(originalObject, [['name', Tag.PII]])).toEqual(obfuscatedObject); - }); - - it('should wrap the provided tag around nested elements in object', () => { - const originalObject = { id: 1, data: { name: 'Gosho', email: 'email@example.com' } }; - const obfuscatedObject = { id: 1, data: { name: '[PII]Gosho[/PII]', email: '[PII]email@example.com[/PII]' } }; - expect(obfuscator.obfuscateObject(originalObject, [['data.name', Tag.PII], ['data.email', Tag.PII]])).toEqual(obfuscatedObject); - }); - - it('should NOT wrap the provided tag around elements that are not specified for obfuscating in object', () => { - const originalObject = { favouriteColor: 'red', nested: { field: 'value' } }; - expect(obfuscator.obfuscateObject(originalObject, [['name', Tag.PII]])).toEqual(originalObject); - }); - - it('should return a copy of the error and not modify the original', () => { - const originalError = new Error(); - const obfuscatedError = obfuscator.obfuscateObject(originalError, [['bar', Tag.PII]]); - expect(obfuscatedError).not.toBe(originalError); - }); - - it('should preserve the prototype, name, message and stack of the error', () => { - class CustomError extends Error {} - const originalError = new CustomError(); - const obfuscatedError = obfuscator.obfuscateObject(originalError, [['bar', Tag.PII]]); - - expect(obfuscatedError).toBeInstanceOf(CustomError); - expect(obfuscatedError.name).toEqual(originalError.name); - expect(obfuscatedError.message).toEqual(originalError.message); - expect(obfuscatedError.stack).toEqual(originalError.stack); - }); - - it('should obfuscate error specific props', () => { - class CustomError extends Error { - bar = 'foo' - foo = { - test: 'test', - } - } - const originalError = new CustomError(); - const obfuscatedError = obfuscator.obfuscateObject(originalError, [ - ['bar', Tag.PII], - ['foo.test', Tag.PII], - ]); - - expect(obfuscatedError.bar).toEqual('[PII]foo[/PII]'); - expect(obfuscatedError.foo.test).toEqual('[PII]test[/PII]'); - }); - }); -});