From 6c4de2eb79b1175d11c64a3cc1b4c110fca4c40a Mon Sep 17 00:00:00 2001 From: Diogo Sobral Date: Wed, 25 Jan 2023 18:22:30 +0000 Subject: [PATCH] Add support for trimming strategies --- README.md | 67 ++++++++++++++++++++++- src/enums/index.js | 9 ++++ src/enums/strategy-enum.js | 11 ++++ src/index.js | 11 +++- test/src/index.test.js | 107 +++++++++++++++++++++++++++++++++++-- 5 files changed, 196 insertions(+), 9 deletions(-) create mode 100644 src/enums/index.js create mode 100644 src/enums/strategy-enum.js diff --git a/README.md b/README.md index a53aeb0..fd89741 100644 --- a/README.md +++ b/README.md @@ -10,7 +10,10 @@ Object redaction with whitelist and blacklist. Blacklist items have higher prior `options.serializers` _(List[Object])_: A list with serializers to apply. Each serializers must contain two properties: `path` (path for the value to be serialized, must be a `string`) and `serializer` (function to be called on the path's value). - `options.trim` _(Boolean)_: A flag that enables trimming all redacted values, saving their keys to a `__redacted__` list (default value is `false`). + `options.strategy` _(string)_: An option that specifies the trimming strategy. Should be one of the following: + * `redact` is the default behavior and obfuscates the values without removing the redacted ones. + * `trim` removes the redacted values from the output. + * `trim-and-list` removes and adds a list with all deleted paths. ### Example @@ -37,9 +40,69 @@ anonymize(data); // }, // bar: { foo: 1, bar: 2 }, // toAnonymize: { baz: '--REDACTED--', bar: '--REDACTED--' }, -// toAnonymizeSuperString: '--REDACTED--' +// toAnonymizeSuperString: 'foo' // } ``` +#### Example using `trim` as strategy + +```js +const { anonymizer } = require('@uphold/anonymizer'); +const whitelist = ['foo.key', 'foo.depth.*', 'bar.*', 'toAnonymize.baz', 'toAnonymizeSuperString']; +const blacklist = ['foo.depth.innerBlacklist', 'toAnonymize.*']; +const anonymize = anonymizer({ blacklist, whitelist }, { strategy: 'trim' }); + +const data = { + foo: { key: 'public', another: 'bar', depth: { bar: 10, innerBlacklist: 11 } }, + bar: { foo: 1, bar: 2 }, + toAnonymize: { baz: 11, bar: 12 }, + toAnonymizeSuperString: 'foo' +}; + +anonymize(data); + +// { +// foo: { +// key: 'public', +// depth: { bar: 10 } +// }, +// bar: { foo: 1, bar: 2 }, +// toAnonymizeSuperString: 'foo' +// } +``` + +#### Example using `trim-and-list` as strategy + +```js +const { anonymizer } = require('@uphold/anonymizer'); +const whitelist = ['foo.key', 'foo.depth.*', 'bar.*', 'toAnonymize.baz', 'toAnonymizeSuperString']; +const blacklist = ['foo.depth.innerBlacklist', 'toAnonymize.*']; +const anonymize = anonymizer({ blacklist, whitelist }, { strategy: 'trim-and-list' }); + +const data = { + foo: { key: 'public', another: 'bar', depth: { bar: 10, innerBlacklist: 11 } }, + bar: { foo: 1, bar: 2 }, + toAnonymize: { baz: 11, bar: 12 }, + toAnonymizeSuperString: 'foo' +}; + +anonymize(data); + +// { +// foo: { +// key: 'public', +// depth: { bar: 10 } +// }, +// bar: { foo: 1, bar: 2 }, +// toAnonymizeSuperString: 'foo', +// __redacted__: [ +// 'foo.another', +// 'foo.depth.innerBlacklist', +// 'toAnonymize.baz', +// 'toAnonymize.bar' +// ] +// } +``` + #### Example using serializers diff --git a/src/enums/index.js b/src/enums/index.js new file mode 100644 index 0000000..62a63fe --- /dev/null +++ b/src/enums/index.js @@ -0,0 +1,9 @@ +'use strict'; + +/** + * Export `strategies` enum. + */ + +module.exports = { + strategies: require('./strategy-enum') +}; diff --git a/src/enums/strategy-enum.js b/src/enums/strategy-enum.js new file mode 100644 index 0000000..179f567 --- /dev/null +++ b/src/enums/strategy-enum.js @@ -0,0 +1,11 @@ +'use strict'; + +/** + * Export `strategy` enum. + */ + +module.exports = { + REDACT: 'redact', + TRIM: 'trim', + TRIM_AND_LIST: 'trim-and-list' +}; diff --git a/src/index.js b/src/index.js index 511c9a2..74ec3ec 100644 --- a/src/index.js +++ b/src/index.js @@ -5,6 +5,7 @@ */ const { serializeError } = require('serialize-error'); +const { strategies } = require('./enums'); const get = require('lodash.get'); const set = require('lodash.set'); const stringify = require('json-stringify-safe'); @@ -14,6 +15,7 @@ const traverse = require('traverse'); * Constants. */ +const { REDACT, TRIM, TRIM_AND_LIST } = strategies; const DEFAULT_REPLACEMENT = '--REDACTED--'; /** @@ -69,12 +71,17 @@ function computeSerializedChanges(values, serializers) { module.exports.anonymizer = ( { blacklist = [], whitelist = [] } = {}, - { replacement = () => DEFAULT_REPLACEMENT, serializers = [], trim = false } = {} + { replacement = () => DEFAULT_REPLACEMENT, serializers = [], strategy = REDACT } = {} ) => { + if (!Object.values(strategies).includes(strategy)) { + throw new Error(`Strategy ${strategy} not supported. Choose one from [${Object.values(strategies).join(', ')}]`); + } + const whitelistTerms = whitelist.join('|'); const whitelistPaths = new RegExp(`^(${whitelistTerms.replace(/\./g, '\\.').replace(/\*/g, '.*')})$`, 'i'); const blacklistTerms = blacklist.join('|'); const blacklistPaths = new RegExp(`^(${blacklistTerms.replace(/\./g, '\\.').replace(/\*/g, '.*')})$`, 'i'); + const trim = [TRIM, TRIM_AND_LIST].includes(strategy); validateSerializers(serializers); @@ -133,7 +140,7 @@ module.exports.anonymizer = ( } }); - if (blacklistedKeys.size) { + if (strategy === TRIM_AND_LIST && blacklistedKeys.size) { // eslint-disable-next-line no-underscore-dangle obj.__redacted__ = Array.from(blacklistedKeys); } diff --git a/test/src/index.test.js b/test/src/index.test.js index 7c43573..11df0f2 100644 --- a/test/src/index.test.js +++ b/test/src/index.test.js @@ -7,6 +7,13 @@ const { anonymizer } = require('src'); const { generateObjectSample, generateObjectSamplePaths } = require('./benchmark/samples'); const { serializeError } = require('serialize-error'); +const { strategies } = require('src/enums'); + +/** + * Constants. + */ + +const { TRIM, TRIM_AND_LIST } = strategies; /** * Test `Anonymizer`. @@ -347,9 +354,37 @@ describe('Anonymizer', () => { }); }); - describe('trim', () => { + describe('strategy', () => { + it('should throw an error if strategy is not supported', () => { + try { + anonymizer({ whitelist: ['*'] }, { strategy: 'foobar' }); + + fail(); + } catch (error) { + expect(error).toBeInstanceOf(Error); + expect(error.message).toEqual( + `Strategy foobar not supported. Choose one from [${Object.values(strategies).join(', ')}]` + ); + } + }); + + it('should use `REDACT` as default strategy if strategy is not provided', () => { + const anonymize = anonymizer({ whitelist: ['foo*'] }); + const result = anonymize({ + buz: 'baz', + foo: { bar: 'bar', biz: 'biz' }, + foz: { baz: 'baz' } + }); + + expect(result).toEqual({ + buz: '--REDACTED--', + foo: { bar: 'bar', biz: 'biz' }, + foz: { baz: '--REDACTED--' } + }); + }); + it('should group array keys', () => { - const anonymize = anonymizer({ whitelist: ['foo'] }, { trim: true }); + const anonymize = anonymizer({ whitelist: ['foo'] }, { strategy: TRIM_AND_LIST }); expect( anonymize({ @@ -363,7 +398,7 @@ describe('Anonymizer', () => { }); it('should trim obfuscated fields and add their paths to a `__redacted__` list', () => { - const anonymize = anonymizer({ whitelist: ['foo'] }, { trim: true }); + const anonymize = anonymizer({ whitelist: ['foo'] }, { strategy: TRIM_AND_LIST }); expect( anonymize({ @@ -377,7 +412,49 @@ describe('Anonymizer', () => { }); }); - it('should not trim obfuscated values that have different obfuscation techniques', () => { + it('should trim obfuscated fields without adding their paths to a `__redacted__` list', () => { + const anonymize = anonymizer({ whitelist: ['foo'] }, { strategy: TRIM }); + + expect( + anonymize({ + biz: 'baz', + buz: { bux: { qux: 'quux' } }, + foo: 'bar' + }) + ).toEqual({ + foo: 'bar' + }); + }); + + it(`should not trim obfuscated values if 'replacement' evaluated value is different from the default and strategy is ${TRIM}`, () => { + const replacement = (key, value) => { + if (key === 'biz') { + return value; + } + + if (value === 'bux') { + return '--HIDDEN--'; + } + + return '--REDACTED--'; + }; + const anonymize = anonymizer({ whitelist: ['foo'] }, { replacement, strategy: TRIM }); + + expect( + anonymize({ + biz: 'baz', + buz: 'bux', + foo: 'bar', + qux: 'quux' + }) + ).toEqual({ + biz: 'baz', + buz: '--HIDDEN--', + foo: 'bar' + }); + }); + + it(`should not trim obfuscated values if 'replacement' evaluated value is different from the default and strategy is ${TRIM_AND_LIST}`, () => { const replacement = (key, value) => { if (key === 'biz') { return value; @@ -389,7 +466,7 @@ describe('Anonymizer', () => { return '--REDACTED--'; }; - const anonymize = anonymizer({ whitelist: ['foo'] }, { replacement, trim: true }); + const anonymize = anonymizer({ whitelist: ['foo'] }, { replacement, strategy: TRIM_AND_LIST }); expect( anonymize({ @@ -459,6 +536,26 @@ describe('Anonymizer', () => { expect(msElapsed).toBeLessThan(175); expect(serializer).toHaveBeenCalledTimes(32768); }); + + [TRIM, TRIM_AND_LIST].forEach(strategy => { + it(`should run in '${strategy}' mode with an object with '32768' properties in less than '300' ms`, () => { + const depth = 10; + const data = generateObjectSample({ depth }); + const serializer = jest.fn(() => 'bii'); + const serializers = generateObjectSamplePaths({ depth }).map(path => ({ path, serializer })); + const anonymize = anonymizer({ blacklist: ['*'] }, { serializers, strategy }); + + const startTime = process.hrtime(); + + anonymize(data); + + const endTime = process.hrtime(startTime); + const msElapsed = endTime[1] / 1000000; + + expect(msElapsed).toBeLessThan(225); + expect(serializer).toHaveBeenCalledTimes(Math.pow(2, depth) * 32); + }); + }); }); }); });