Skip to content

Commit

Permalink
Add support for serializers
Browse files Browse the repository at this point in the history
  • Loading branch information
Dakrs committed May 10, 2023
1 parent 7ab7ac8 commit ef8f1f4
Show file tree
Hide file tree
Showing 6 changed files with 531 additions and 8 deletions.
65 changes: 63 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,13 +7,15 @@ Object redaction with whitelist and blacklist. Blacklist items have higher prior
3. `options` _(Object)_: An object with optional options.

`options.replacement` _(Function)_: A function that allows customizing the replacement value (default implementation is `--REDACTED--`).


`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`).

### Example

```js
const anonymizer = require('@uphold/anonymizer');
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 });
Expand All @@ -39,6 +41,65 @@ anonymize(data);
// }
```

#### Example using serializers

```js
const { anonymizer } = require('@uphold/anonymizer');
const whitelist = ['foo.key', 'foo.depth.*', 'bar.*', 'toAnonymize.baz'];
const blacklist = ['foo.depth.innerBlacklist'];
const serializers = [
{ path: 'foo.key', serializer: () => 'biz' },
{ path: 'toAnonymize', serializer: () => ({ baz: 'baz' }) }
]
const anonymize = anonymizer({ blacklist, whitelist }, { serializers });

const data = {
foo: { key: 'public', another: 'bar', depth: { bar: 10, innerBlacklist: 11 } },
bar: { foo: 1, bar: 2 },
toAnonymize: {}
};

anonymize(data);

// {
// foo: {
// key: 'biz',
// another: '--REDACTED--',
// depth: { bar: 10, innerBlacklist: '--REDACTED--' }
// },
// bar: { foo: 1, bar: 2 },
// toAnonymize: { baz: 'baz' }
// }
```

### Default serializers

The introduction of serializers also added the possibility of using serializer functions exported by our module. The list of default serializers is presented below:
- error

#### Example

```js
const { anonymizer, defaultSerializers } = require('@uphold/anonymizer');
const serializers = [
{ path: 'foo', serializer: defaultSerializers.error }
];

const anonymize = anonymizer({}, { serializers });

const data = { foo: new Error('Foobar') };

anonymize(data);

// {
// foo: {
// name: '--REDACTED--',
// message: '--REDACTED--',
// stack: '--REDACTED--'
// }
// }
```

## Releasing a new version

- Diff the current code with the latest tag and make sure the output is expected.
Expand Down
4 changes: 4 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,11 @@
},
"dependencies": {
"json-stringify-safe": "^5.0.1",
"lodash.clonedeepwith": "^4.5.0",
"lodash.get": "^4.4.2",
"lodash.iserror": "^3.1.1",
"lodash.set": "^4.3.2",
"serialize-error": "8.0.0",
"traverse": "^0.6.6"
},
"devDependencies": {
Expand Down
114 changes: 109 additions & 5 deletions src/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,11 @@
* Module dependencies.
*/

const { serializeError } = require('serialize-error');
const cloneDeepWith = require('lodash.clonedeepwith');
const get = require('lodash.get');
const isError = require('lodash.iserror');
const set = require('lodash.set');
const stringify = require('json-stringify-safe');
const traverse = require('traverse');

Expand All @@ -15,25 +19,105 @@ const traverse = require('traverse');
const DEFAULT_REPLACEMENT = '--REDACTED--';

/**
* Module exports.
* Gets a list with all properties of an object.
*/

module.exports = (
function getAllPropertyNames(obj, maxChainLength = 10) {
const set = new Set();
let i = 0;

while (obj.constructor !== Object && i < maxChainLength) {
Object.getOwnPropertyNames(obj).forEach(name => set.add(name));
obj = Object.getPrototypeOf(obj);
i++;
}

return [...set];
}

/**
* Validate serializers.
*/

function validateSerializers(serializers) {
serializers.forEach(({ path, serializer }) => {
if (typeof serializer !== 'function') {
throw new TypeError(`Invalid serializer for \`${path}\` path: must be a function`);
}
});
}

/**
* `parseAndSerialize` builds a copy of the original object, computes the serializer's results and mutates the
* copy with the serialized values. To perform the copy, we are using JSON.parse(stringify(values)), which does
* not construct an exact replication of the initial input, but it can't be swapped for another solution due to
* its performance and because it can also handle classes correctly. While most of the existing deep clones, when
* cloning Classes receive a `Class` as input and return a `Class` as output, `stringify` does things differently.
* When it receives a `Class`, it calls the method `toJSON` and returns its output or if the `Class` doesn't have
* `toJSON` it returns an empty object. This is very powerful since `traverse` can't iterate over over `Classes`.
* During `parseAndSerialize` execution, we perform additional copies to avoid having a serializer updating the
* original object by reference. These copies are only done in the values passed to serializers to avoid two full
* copies of the original values. For this, we used `cloneDeepWith` with a custom clone only for errors. When an
* error is found, we compute a list with all properties (properties from the class itself and from extended cla-
* sses). Then we use these properties to get the original values and copying them into a new object.
*/

function parseAndSerialize(values, serializers) {
const target = JSON.parse(stringify(values));

for (const { path, serializer } of serializers) {
const value = get(values, path);

if (value === undefined) {
continue;
}

const clone = cloneDeepWith(value, node => {
if (isError(node)) {
const copy = {};

getAllPropertyNames(node).forEach(key => {
copy[key] = node[key];
});

return copy;
}

return undefined;
});

try {
set(target, path, serializer(clone));
} catch (error) {
set(target, path, `Anonymize ERROR: Error while applying ${path} serializer`);
}
}

return target;
}

/**
* Module exports `anonymizer` function.
*/

module.exports.anonymizer = (
{ blacklist = [], whitelist = [] } = {},
{ replacement = () => DEFAULT_REPLACEMENT, trim = false } = {}
{ replacement = () => DEFAULT_REPLACEMENT, serializers = [], trim = false } = {}
) => {
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');

validateSerializers(serializers);

return values => {
if (!(values instanceof Object)) {
return values;
}

const blacklistedKeys = new Set();
const obj = JSON.parse(stringify(values));
const obj = parseAndSerialize(values, serializers);

traverse(obj).forEach(function() {
const path = this.path.join('.');
Expand All @@ -51,7 +135,7 @@ module.exports = (
return;
}

if (isBuffer && (!blacklistPaths.test(path) && whitelistPaths.test(path))) {
if (isBuffer && !blacklistPaths.test(path) && whitelistPaths.test(path)) {
return this.update(Buffer.from(this.node), true);
}

Expand All @@ -78,3 +162,23 @@ module.exports = (
return obj;
};
};

/**
* Default serializer for Datadog.
*/

function datadogSerializer(error) {
return {
...error,
kind: error.name || 'Error'
};
}

/**
* Module exports `defaultSerializers`.
*/

module.exports.defaultSerializers = {
datadogSerializer,
error: serializeError
};
48 changes: 48 additions & 0 deletions test/src/benchmark/samples.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
'use strict';

/**
* `generateObjectSample` generates a sample object with a tree structure.
*/

module.exports.generateObjectSample = ({ depth = 6, branches = 2, leafValue = () => 'foobar', leafs = 32 }) => {
const sample = {};

if (depth === 0) {
for (let leaf = 0; leaf < leafs; leaf++) {
sample[`leaf-${leaf}`] = leafValue();
}

return sample;
}

for (let branch = 0; branch < branches; branch++) {
sample[`branch-${branch}`] = this.generateObjectSample({ branches, depth: depth - 1, leafs });
}

return sample;
};

/**
* `generateObjectSamplePaths` generates a list with all paths contained in a sample generated using `generateObjectSample`.
*/

module.exports.generateObjectSamplePaths = ({ depth = 6, branches = 2, leafs = 32, path = '' }) => {
let paths = [];

if (depth === 0) {
for (let leaf = 0; leaf < leafs; leaf++) {
paths.push(`${path}.leaf-${leaf}`);
}

return paths;
}

for (let branch = 0; branch < branches; branch++) {
const childPathString = path === '' ? `branch-${branch}` : `${path}.branch-${branch}`;
const childPaths = this.generateObjectSamplePaths({ branches, depth: depth - 1, leafs, path: childPathString });

paths = paths.concat(childPaths);
}

return paths;
};
Loading

0 comments on commit ef8f1f4

Please sign in to comment.