diff --git a/lib/deep-map.js b/lib/deep-map.js index b555cf9..6c61c08 100644 --- a/lib/deep-map.js +++ b/lib/deep-map.js @@ -1,20 +1,12 @@ -function filterError (input) { - return { - errorType: input.name, - message: input.message, - stack: input.stack, - ...(input.code ? { code: input.code } : {}), - ...(input.statusCode ? { statusCode: input.statusCode } : {}), - } -} +const { serializeError } = require('./error') const deepMap = (input, handler = v => v, path = ['$'], seen = new Set([input])) => { // this is in an effort to maintain bole's error logging behavior if (path.join('.') === '$' && input instanceof Error) { - return deepMap({ err: filterError(input) }, handler, path, seen) + return deepMap({ err: serializeError(input) }, handler, path, seen) } if (input instanceof Error) { - return deepMap(filterError(input), handler, path, seen) + return deepMap(serializeError(input), handler, path, seen) } if (input instanceof Buffer) { return `[unable to log instanceof buffer]` diff --git a/lib/error.js b/lib/error.js new file mode 100644 index 0000000..e374b39 --- /dev/null +++ b/lib/error.js @@ -0,0 +1,28 @@ +/** takes an error object and serializes it to a plan object */ +function serializeError (input) { + if (!(input instanceof Error)) { + if (typeof input === 'string') { + const error = new Error(`attempted to serialize a non-error, string String, "${input}"`) + return serializeError(error) + } + const error = new Error(`attempted to serialize a non-error, ${typeof input} ${input?.constructor?.name}`) + return serializeError(error) + } + // different error objects store status code differently + // AxiosError uses `status`, other services use `statusCode` + const statusCode = input.statusCode ?? input.status + // CAUTION: what we serialize here gets add to the size of logs + return { + errorType: input.errorType ?? input.constructor.name, + ...(input.message ? { message: input.message } : {}), + ...(input.stack ? { stack: input.stack } : {}), + // think of this as error code + ...(input.code ? { code: input.code } : {}), + // think of this as http status code + ...(statusCode ? { statusCode } : {}), + } +} + +module.exports = { + serializeError, +} diff --git a/lib/server.js b/lib/server.js index 669e834..41f3f40 100644 --- a/lib/server.js +++ b/lib/server.js @@ -14,6 +14,8 @@ const { redactMatchers, } = require('./utils') +const { serializeError } = require('./error') + const { deepMap } = require('./deep-map') const _redact = redactMatchers( @@ -31,4 +33,23 @@ const _redact = redactMatchers( const redact = (input) => deepMap(input, (value, path) => _redact(value, { path })) -module.exports = { redact } +/** takes an error returns new error keeping some custom properties */ +function redactError (input) { + const { message, ...data } = serializeError(input) + const output = new Error(redact(message)) + return Object.assign(output, redact(data)) +} + +/** runs a function within try / catch and throws error wrapped in redactError */ +async function redactThrow (func) { + if (typeof func !== 'function') { + throw new Error('redactThrow expects a function') + } + try { + return await func() + } catch (error) { + throw redactError(error) + } +} + +module.exports = { redact, redactError, redactThrow } diff --git a/test/error.js b/test/error.js new file mode 100644 index 0000000..c9fffb6 --- /dev/null +++ b/test/error.js @@ -0,0 +1,131 @@ +const { serializeError } = require('../lib/error') +const t = require('tap') + +class CustomError extends Error { + constructor (message, data) { + super(message) + this.sensitive = data + } +} + +class NoMessageNoStackError extends Error { + constructor (message) { + super(message) + delete this.message + delete this.stack + } +} + +t.test('serializeError', async t => { + await t.test('native error', async t => { + const exampleError = new Error('hello world') + + t.same( + serializeError(exampleError), + { + errorType: 'Error', + message: 'hello world', + stack: exampleError.stack, + }, + 'should serialize error' + ) + }) + + await t.test('attached error (status not statusCode)', async t => { + const exampleError = new Error('hello world') + exampleError.code = 'E12345' + exampleError.status = 404 + + t.same( + serializeError(exampleError), + { + errorType: 'Error', + message: 'hello world', + stack: exampleError.stack, + code: 'E12345', + statusCode: 404, + }, + 'should serialize error with code / statusCode' + ) + }) + + await t.test('attached error', async t => { + const exampleError = new Error('hello world') + exampleError.code = 'E12345' + exampleError.statusCode = 404 + + t.same( + serializeError(exampleError), + { + errorType: 'Error', + message: 'hello world', + stack: exampleError.stack, + code: 'E12345', + statusCode: 404, + }, + 'should serialize error with code / statusCode' + ) + }) + + await t.test('assigned error', async t => { + const exampleError = new Error('hello world') + Object.assign(exampleError, { + code: 'E12345', + statusCode: 404, + }) + + t.same( + serializeError(exampleError), + { + errorType: 'Error', + message: 'hello world', + stack: exampleError.stack, + code: 'E12345', + statusCode: 404, + }, + 'should serialize error with code / statusCode' + ) + }) + + await t.test('custom error', async t => { + const exampleError = new CustomError('hello world', 'sensitive data') + t.same( + serializeError(exampleError), + { + errorType: 'CustomError', + message: 'hello world', + stack: exampleError.stack, + }, + 'should serialize error' + ) + }) + + await t.test('no-message no-stack', async t => { + const exampleError = new NoMessageNoStackError('hello world') + t.same( + serializeError(exampleError), + { + errorType: 'NoMessageNoStackError', + }, + 'should serialize error' + ) + }) + + await t.test('undefined', async t => { + const results = serializeError(undefined) + t.same(results.errorType, 'Error', 'should serialize error') + t.same(results.message, 'attempted to serialize a non-error, undefined undefined', 'should serialize message') + }) + + await t.test('date', async t => { + const results = serializeError(new Date()) + t.same(results.errorType, 'Error', 'should serialize error') + t.same(results.message, 'attempted to serialize a non-error, object Date', 'should serialize message') + }) + + await t.test('string', async t => { + const results = serializeError('hello world') + t.same(results.errorType, 'Error', 'should serialize error') + t.same(results.message, 'attempted to serialize a non-error, string String, "hello world"', 'should serialize message') + }) +}) diff --git a/test/server.js b/test/server.js new file mode 100644 index 0000000..c03281a --- /dev/null +++ b/test/server.js @@ -0,0 +1,110 @@ +const t = require('tap') +const { serializeError } = require('../lib/error') +const { redact, redactError, redactThrow } = require('../lib/server') +const matchers = require('../lib/matchers') +const examples = require('./fixtures/examples') + +t.test('redact', async t => { + await t.test('error has npm secret in message', async t => { + const error = new Error(`message contains npm secret: ${examples.NPM_SECRET.npm_36}`) + t.same(error.message, `message contains npm secret: ${examples.NPM_SECRET.npm_36}`) + const output = redact({ error }) + t.same(output, { + error: { + errorType: 'Error', + message: `message contains npm secret: ${matchers.NPM_SECRET.replacement}`, + stack: error.stack.replace(examples.NPM_SECRET.npm_36, matchers.NPM_SECRET.replacement), + }, + }) + }) + await t.test('error has npm secret in statusCode', async t => { + const x = new Error(`message`) + const error = Object.assign(x, { statusCode: examples.NPM_SECRET.npm_36 }) + const output = redact({ error }) + t.same(output, { + error: { + errorType: 'Error', + message: `message`, + stack: error.stack, + statusCode: matchers.NPM_SECRET.replacement, + }, + }) + }) +}) + +class CustomError extends Error { + constructor (message, data) { + super(message) + this.sensitive = data + } +} + +t.test('redactError', async t => { + await t.test('native error', async t => { + const badError = new Error('hello world') + Object.assign(badError, { + sensitive: 'sensitive data', + }) + const goodError = redactError(badError) + + t.same(badError.sensitive, 'sensitive data', 'should have sensitive field') + t.same(goodError.sensitive, undefined, 'should not have sensitive field') + }) + await t.test('custom error', async t => { + const badError = new CustomError('hello world', 'sensitive data') + const goodError = redactError(badError) + + t.same(badError.sensitive, 'sensitive data', 'should have sensitive field') + t.same(goodError.sensitive, undefined, 'should not have sensitive field') + }) + + await t.test('error w/ sensitive status', async t => { + const badError = new Error('hello world') + badError.status = examples.NPM_SECRET.npm_36 + const goodError = redactError(badError) + t.same(badError.status, examples.NPM_SECRET.npm_36, 'should not have sensitive field') + t.same(goodError.statusCode, matchers.NPM_SECRET.replacement, 'should not have sensitive field') + }) + + await t.test('redacts sensitive error.message', async t => { + const badError = new Error(`npm token: ${examples.NPM_SECRET.npm_36}`) + const goodError = redactError(badError) + + t.same(badError.message, `npm token: ${examples.NPM_SECRET.npm_36}`, 'should have message') + t.same(goodError.message, `npm token: ${matchers.NPM_SECRET.replacement}`, 'should have message') + }) +}) + +t.test('redactThrow', async t => { + await t.test('successfully throws error', async t => { + const badError = new CustomError('hello world', 'sensitive data') + t.same(badError.sensitive, 'sensitive data', 'should have sensitive field') + try { + await redactThrow(async () => { + throw badError + }) + t.fail('should throw') + } catch (goodError) { + t.same(goodError.sensitive, undefined, 'should not have sensitive field') + } + }) + t.test('invalid argument not function', async t => { + try { + await redactThrow('hello world') + t.fail('should throw') + } catch (error) { + t.same(error.message, 'redactThrow expects a function', 'should throw with correct message') + } + }) +}) + +t.test('serialize a redactError', async t => { + const badError = new CustomError('hello world', 'sensitive data') + const goodError = redactError(badError) + const serialized = serializeError(goodError) + t.same(serialized.errorType, 'CustomError', 'should serialize error') + t.same(serialized.message, 'hello world', 'should serialize message') + t.same(serialized.stack, badError.stack, 'should serialize stack') + t.same(goodError.stack, badError.stack, 'should serialize stack') + t.same(serialized.sensitive, undefined, 'should not serialize sensitive data') +})