diff --git a/.travis.yml b/.travis.yml index 1010d23..3be5db5 100644 --- a/.travis.yml +++ b/.travis.yml @@ -1,9 +1,13 @@ language: node_js +node_js: + - "8.11.0" + - "8" + - "10" + - "11" + - "12" cache: yarn: true directories: - node_modules - script: - - yarn lint - yarn test --ci diff --git a/package.json b/package.json index 76d5eba..eb0505c 100644 --- a/package.json +++ b/package.json @@ -11,7 +11,8 @@ }, "scripts": { "lint": "eslint ./src ./test", - "test": "jest --coverage && yarn lint" + "pretest": "yarn lint", + "test": "jest --coverage" }, "jest": { "collectCoverage": true, diff --git a/src/sensitiveParamFilter.js b/src/sensitiveParamFilter.js index 623bad3..8dba694 100644 --- a/src/sensitiveParamFilter.js +++ b/src/sensitiveParamFilter.js @@ -26,16 +26,19 @@ class SensitiveParamFilter { } recursiveFilter(input) { - if (typeof input === 'string' || input instanceof String) { + if (!input || typeof input === 'number' || typeof input === 'boolean') { + return input + } else if (typeof input === 'string' || input instanceof String) { return this.filterString(input) } else if (input instanceof Error) { return this.filterError(input) - } else if (input && typeof input === 'object' && input.constructor === Object) { - return this.filterObject(input) } else if (Array.isArray(input)) { return this.filterArray(input) + } else if (typeof input === 'object') { + return this.filterObject(input) } - return input + + return null } filterString(input) { @@ -53,16 +56,26 @@ class SensitiveParamFilter { if (id || id === 0) { return this.examinedObjects[id].copy } - let copy = null - try { - copy = new input.constructor(input.message) - } catch (error) { - copy = new Error(input.message) - } - copy.stack = input.stack + + const copy = new Error(input.message) + Object.defineProperties(copy, { + name: { + configurable: true, + enumerable: false, + value: input.name, + writable: true + }, + stack: { + configurable: true, + enumerable: false, + value: input.stack, + writable: true + } + }) if (input.code) { copy.code = input.code } + for (const key in input) { // eslint-disable-line guard-for-in copy[key] = input[key] } diff --git a/test/sensitiveParamFilter.test.js b/test/sensitiveParamFilter.test.js index 0139d1c..efe1379 100644 --- a/test/sensitiveParamFilter.test.js +++ b/test/sensitiveParamFilter.test.js @@ -1,3 +1,5 @@ +/* eslint-disable max-lines, max-statements */ + const { SensitiveParamFilter } = require('../src') describe('SensitiveParamFilter', () => { @@ -54,7 +56,10 @@ describe('SensitiveParamFilter', () => { const numInputKeys = Object.keys(input).length const numBodyKeys = Object.keys(input.body).length - const output = paramFilter.filter(input) + let output = null + beforeEach(() => { + output = paramFilter.filter(input) + }) it('does not modify the original object', () => { expect(Object.keys(input).length).toBe(numInputKeys) @@ -97,102 +102,201 @@ describe('SensitiveParamFilter', () => { }) }) - describe('filtering a JSON parse error', () => { - let input = null - try { - JSON.parse('This is not a JSON string. Do not parse it.') - } catch (error) { - input = error - } - input.Authorization = 'Username: Bob, Password: pa$$word' - input.customData = { - error: input, - info: '{ "json": false, "veryPrivateInfo": "credentials" }' + describe('filtering a custom object with read-only and non-enumerable properties', () => { + class VeryUnusualClass { + constructor () { + this.password = 'hunter12' + Reflect.defineProperty(this, 'readonly', { + enumerable: true, + value: 42, + writable: false + }) + Reflect.defineProperty(this, 'hidden', { + enumerable: false, + value: 'You cannot see me', + writable: true + }) + } + + doSomething() { + return `${this.readonly} ${this.hidden}` + } } - const inputMessage = input.message - const inputStack = input.stack - const inputCode = input.code + const input = { + message: 'hello', + veryUnusualObject: new VeryUnusualClass() + } const numInputKeys = Object.keys(input).length - const numCustomDataKeys = Object.keys(input.customData).length + const numveryUnusualObjectKeys = Object.keys(input.veryUnusualObject).length + const veryUnusualObjectType = typeof input.veryUnusualObject + const veryUnusualObjectConstructor = input.veryUnusualObject.constructor + + let output = null + beforeEach(() => { + output = paramFilter.filter(input) + }) + + it('does not modify the original object', () => { + expect(Object.keys(input).length).toBe(numInputKeys) + expect(Object.keys(input.veryUnusualObject).length).toBe(numveryUnusualObjectKeys) + expect(typeof input.veryUnusualObject).toBe(veryUnusualObjectType) + expect(input.veryUnusualObject.constructor).toBe(veryUnusualObjectConstructor) + + expect(input.message).toBe('hello') + expect(input.veryUnusualObject.password).toBe('hunter12') + expect(input.veryUnusualObject.readonly).toBe(42) + expect(input.veryUnusualObject.hidden).toBe('You cannot see me') + expect(input.veryUnusualObject.doSomething()).toBe('42 You cannot see me') + }) + + it('maintains non-sensitive, enumerable data in the output object', () => { + expect(Object.keys(output).length).toBe(numInputKeys) + expect(Object.keys(output.veryUnusualObject).length).toBe(numveryUnusualObjectKeys) + + expect(output.message).toBe('hello') + expect(input.veryUnusualObject.readonly).toBe(42) + }) + + it('filters out object keys in a case-insensitive, partial-matching manner', () => { + expect(output.veryUnusualObject.password).toBe('FILTERED') + }) + + it('does not maintain hidden properties, methods, or type information from the original object', () => { + expect(output.veryUnusualObject.hidden).toBeUndefined() + expect(output.veryUnusualObject.doSomething).toBeUndefined() + expect(output.veryUnusualObject.constructor).not.toBe(veryUnusualObjectConstructor) + }) + }) + + describe('filtering errors with a code', () => { + const input = new Error('Something broke') + input.code = 'ERR_BROKEN' + + let output = null + beforeEach(() => { + output = paramFilter.filter(input) + }) + + it('maintains the error code and type', () => { + expect(output).toBeInstanceOf(Error) + expect(output.code).toBe(input.code) + }) + + it('preprends error type to the message', () => { + expect(output.message).toBe(input.message) + }) + }) + + describe('filtering a custom error with non-standard fields', () => { + const inputMessage = 'Super broken' + const inputPassword = 'hunter12' + const inputReadonly = 42 + const inputHidden = 'You cannot see me' + + class CustomError extends Error { + constructor (message, password, readonly, hidden) { + super(message) + + this.password = password + Object.defineProperties(this, { + hidden: { + enumerable: false, + value: hidden, + writable: true + }, + name: { + enumerable: false, + value: this.constructor.name, + writable: false + }, + readonly: { + enumerable: true, + value: readonly, + writable: false + } + }) + } + } + + const input = new CustomError(inputMessage, inputPassword, inputReadonly, inputHidden) + const inputKeyCount = Object.keys(input).length const inputType = typeof input const inputConstructor = input.constructor - const output = paramFilter.filter(input) + let output = null + beforeEach(() => { + output = paramFilter.filter(input) + }) it('does not modify the original error', () => { - expect(Object.keys(input).length).toBe(numInputKeys) - expect(Object.keys(input.customData).length).toBe(numCustomDataKeys) + expect(Object.keys(input).length).toBe(inputKeyCount) expect(typeof input).toBe(inputType) expect(input.constructor).toBe(inputConstructor) expect(input.message).toBe(inputMessage) - expect(input.stack).toBe(inputStack) - expect(input.code).toBe(inputCode) - - expect(input.Authorization).toBe('Username: Bob, Password: pa$$word') - expect(input.customData.info).toBe('{ "json": false, "veryPrivateInfo": "credentials" }') - expect(input.customData.error).toBe(input) + expect(input.password).toBe(inputPassword) + expect(input.readonly).toBe(inputReadonly) + expect(input.hidden).toBe(inputHidden) }) - it('maintains non-sensitive data and error type in the output, including circular references', () => { - expect(Object.keys(output).length).toBe(numInputKeys) - expect(typeof output).toBe(inputType) - expect(output.constructor).toBe(inputConstructor) - + it('preprends error type to the message', () => { expect(output.message).toBe(inputMessage) - expect(output.stack).toBe(inputStack) - expect(output.code).toBe(inputCode) + }) - expect(output.customData.error).toBe(output) + it('maintains non-sensitive, enumerable data in the output error', () => { + expect(output.readonly).toBe(inputReadonly) }) - it('filters out error keys in a case-insensitive, partial-matching manner', () => { - expect(output.Authorization).toBe('FILTERED') + it('does not maintain sensitive data in the output error', () => { + expect(output.password).toBe('FILTERED') }) - it('filters out JSON keys (case-insensitive) and matches partials while maintaining non-sensitive data', () => { - const outputInfoObject = JSON.parse(output.customData.info) - expect(Object.keys(outputInfoObject).length).toBe(2) + it('maintains name and stack values', () => { + expect(output.name).toBe('CustomError') + expect(output.stack).toBe(input.stack) + }) - expect(outputInfoObject.veryPrivateInfo).toBe('FILTERED') - expect(outputInfoObject.json).toBe(false) + it('converts to a plain Error', () => { + expect(output.constructor).toBe(Error) }) - }) - describe('filtering a custom error with a non-standard constructor', () => { - class VeryUnusualError extends Error { - constructor (...args) { - super(...args) - this.weirdAttribute = args[1].name - this.code = 'VERY_UNUSUAL_ERROR' - } - } + it('does not maintain hidden properties from the original error', () => { + expect(output.hidden).toBeUndefined() + }) + }) + describe('filtering a JSON parse error', () => { let input = null try { - throw new VeryUnusualError( - 'Something went wrong', - { name: 'Unexpected' } - ) + JSON.parse('This is not a JSON string. Do not parse it.') } catch (error) { input = error } - input.password = 'hunter12' + input.Authorization = 'Username: Bob, Password: pa$$word' + input.customData = { + error: input, + info: '{ "json": false, "veryPrivateInfo": "credentials" }' + } const inputMessage = input.message const inputStack = input.stack const inputCode = input.code const numInputKeys = Object.keys(input).length + const numCustomDataKeys = Object.keys(input.customData).length const inputType = typeof input const inputConstructor = input.constructor - const output = paramFilter.filter(input) + let output = null + beforeEach(() => { + output = paramFilter.filter(input) + }) it('does not modify the original error', () => { expect(Object.keys(input).length).toBe(numInputKeys) + expect(Object.keys(input.customData).length).toBe(numCustomDataKeys) expect(typeof input).toBe(inputType) expect(input.constructor).toBe(inputConstructor) @@ -200,24 +304,35 @@ describe('SensitiveParamFilter', () => { expect(input.stack).toBe(inputStack) expect(input.code).toBe(inputCode) - expect(input.weirdAttribute).toBe('Unexpected') - expect(input.password).toBe('hunter12') + expect(input.Authorization).toBe('Username: Bob, Password: pa$$word') + expect(input.customData.info).toBe('{ "json": false, "veryPrivateInfo": "credentials" }') + expect(input.customData.error).toBe(input) + }) + + it('converts to a plain Error', () => { + expect(output.constructor).toBe(Error) }) - it('maintains non-sensitive data in the output error, but cannot maintain the exact error type', () => { + it('maintains non-sensitive data in the output, including circular references', () => { expect(Object.keys(output).length).toBe(numInputKeys) expect(typeof output).toBe(inputType) - expect(output.constructor).not.toBe(inputConstructor) - expect(output.message).toBe(inputMessage) expect(output.stack).toBe(inputStack) expect(output.code).toBe(inputCode) - expect(output.weirdAttribute).toBe('Unexpected') + expect(output.customData.error).toBe(output) }) - it('filters out JSON keys (case-insensitive) and matches partials', () => { - expect(output.password).toBe('FILTERED') + it('filters out error keys in a case-insensitive, partial-matching manner', () => { + expect(output.Authorization).toBe('FILTERED') + }) + + it('filters out JSON keys (case-insensitive) and matches partials while maintaining non-sensitive data', () => { + const outputInfoObject = JSON.parse(output.customData.info) + expect(Object.keys(outputInfoObject).length).toBe(2) + + expect(outputInfoObject.veryPrivateInfo).toBe('FILTERED') + expect(outputInfoObject.json).toBe(false) }) }) @@ -236,7 +351,10 @@ describe('SensitiveParamFilter', () => { const inputLength = input.length const inputIndex2Length = input[2].length - const output = paramFilter.filter(input) + let output = null + beforeEach(() => { + output = paramFilter.filter(input) + }) it('does not modify the original object', () => { expect(input.length).toBe(inputLength) @@ -278,5 +396,18 @@ describe('SensitiveParamFilter', () => { expect(outputIndex3Object.credit_card_number).toBe('FILTERED') }) }) + + describe('filtering functions', () => { + const input = () => {} // eslint-disable-line no-empty-function + + let output = null + beforeEach(() => { + output = paramFilter.filter(input) + }) + + it('returns null', () => { + expect(output).toBeNull() + }) + }) }) })