diff --git a/packages/integrations/src/index.ts b/packages/integrations/src/index.ts index c1e8d69794ad..5bb3271c9d3e 100644 --- a/packages/integrations/src/index.ts +++ b/packages/integrations/src/index.ts @@ -6,6 +6,7 @@ export { Ember } from './ember'; export { ExtraErrorData } from './extraerrordata'; export { ReportingObserver } from './reportingobserver'; export { RewriteFrames } from './rewriteframes'; +export { ScrubData } from './scrubdata'; export { SessionTiming } from './sessiontiming'; export { Transaction } from './transaction'; export { Vue } from './vue'; diff --git a/packages/integrations/src/scrubdata.ts b/packages/integrations/src/scrubdata.ts new file mode 100644 index 000000000000..022d7a6e2d13 --- /dev/null +++ b/packages/integrations/src/scrubdata.ts @@ -0,0 +1,116 @@ +import { Event, EventHint, EventProcessor, Hub, Integration } from '@sentry/types'; +import { isPlainObject, isRegExp, Memo } from '@sentry/utils'; + +/** JSDoc */ +interface ScrubDataOptions { + sanitizeKeys: Array; +} + +/** JSDoc */ +export class ScrubData implements Integration { + /** + * @inheritDoc + */ + public name: string = ScrubData.id; + + /** + * @inheritDoc + */ + public static id: string = 'ScrubData'; + + /** JSDoc */ + private readonly _options: ScrubDataOptions; + private readonly _sanitizeMask: string; + private _lazySanitizeRegExp?: RegExp; + + /** + * @inheritDoc + */ + public constructor(options: ScrubDataOptions) { + this._options = { + sanitizeKeys: [], + ...options, + }; + this._sanitizeMask = '********'; + } + + /** + * @inheritDoc + */ + public setupOnce(addGlobalEventProcessor: (callback: EventProcessor) => void, getCurrentHub: () => Hub): void { + addGlobalEventProcessor((event: Event, _hint?: EventHint) => { + const self = getCurrentHub().getIntegration(ScrubData); + if (self) { + return self.process(event); + } + return event; + }); + } + + /** JSDoc */ + public process(event: Event): Event { + if (this._options.sanitizeKeys.length === 0) { + // nothing to sanitize + return event; + } + + return this._sanitize(event) as Event; + } + + /** + * lazily generate regexp + */ + private _sanitizeRegExp(): RegExp { + if (this._lazySanitizeRegExp) { + return this._lazySanitizeRegExp; + } + + const sources = this._options.sanitizeKeys.reduce( + (acc, key) => { + if (typeof key === 'string') { + // escape string value + // see also: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Guide/Regular_Expressions#Escaping + acc.push(key.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')); + } else if (isRegExp(key)) { + acc.push(key.source); + } + return acc; + }, + [] as string[], + ); + + return (this._lazySanitizeRegExp = RegExp(sources.join('|'), 'i')); + } + + /** + * sanitize event data recursively + */ + private _sanitize(input: unknown, memo: Memo = new Memo()): unknown { + const inputIsArray = Array.isArray(input); + const inputIsPlainObject = isPlainObject(input); + + if (!inputIsArray && !inputIsPlainObject) { + return input; + } + + // Avoid circular references + if (memo.memoize(input)) { + return input; + } + + let sanitizedValue; + if (inputIsArray) { + sanitizedValue = (input as any[]).map(value => this._sanitize(value, memo)); + } else if (inputIsPlainObject) { + const inputVal = input as { [key: string]: unknown }; + sanitizedValue = Object.keys(inputVal).reduce>((acc, key) => { + acc[key] = this._sanitizeRegExp().test(key) ? this._sanitizeMask : this._sanitize(inputVal[key], memo); + return acc; + }, {}); + } + + memo.unmemoize(input); + + return sanitizedValue; + } +} diff --git a/packages/integrations/test/scrubdata.test.ts b/packages/integrations/test/scrubdata.test.ts new file mode 100644 index 000000000000..721897a169fa --- /dev/null +++ b/packages/integrations/test/scrubdata.test.ts @@ -0,0 +1,151 @@ +import { ScrubData } from '../src/scrubdata'; + +/** JSDoc */ +function clone(data: T): T { + return JSON.parse(JSON.stringify(data)); +} + +let scrubData: ScrubData; +const sanitizeMask = '********'; +const messageEvent = { + fingerprint: ['MrSnuffles'], + message: 'PickleRick', + stacktrace: { + frames: [ + { + colno: 1, + filename: 'filename.js', + function: 'function', + lineno: 1, + }, + { + colno: 2, + filename: 'filename.js', + function: 'function', + lineno: 2, + }, + ], + }, +}; + +describe('ScrubData', () => { + describe('sanitizeKeys option is empty', () => { + beforeEach(() => { + scrubData = new ScrubData({ + sanitizeKeys: [], + }); + }); + + it('should not affect any changes', () => { + const event = clone(messageEvent); + const processedEvent = scrubData.process(event); + expect(processedEvent).toEqual(event); + }); + }); + + describe('sanitizeKeys option has type of string', () => { + beforeEach(() => { + scrubData = new ScrubData({ + sanitizeKeys: ['message', 'filename'], + }); + }); + + it('should mask matched value in object', () => { + const event = scrubData.process(clone(messageEvent)); + expect(event.message).toEqual(sanitizeMask); + }); + + it('should not mask unmatched value in object', () => { + const event = scrubData.process(clone(messageEvent)); + expect(event.fingerprint).toEqual(messageEvent.fingerprint); + }); + + it('should mask matched value in Array', () => { + const event: any = scrubData.process(clone(messageEvent)); + expect(event.stacktrace.frames[0].filename).toEqual(sanitizeMask); + expect(event.stacktrace.frames[1].filename).toEqual(sanitizeMask); + }); + + it('should not mask unmatched value in Array', () => { + const event: any = scrubData.process(clone(messageEvent)); + expect(event.stacktrace.frames[0].function).toEqual(messageEvent.stacktrace.frames[0].function); + expect(event.stacktrace.frames[1].function).toEqual(messageEvent.stacktrace.frames[1].function); + }); + }); + + describe('sanitizeKeys option has type of RegExp', () => { + beforeEach(() => { + scrubData = new ScrubData({ + sanitizeKeys: [/^name$/], + }); + }); + + it('should mask only matched value', () => { + const testEvent: any = { + filename: 'to be show', + name: 'do not show', + }; + const event: any = scrubData.process(testEvent); + expect(event.filename).toEqual(testEvent.filename); + expect(event.name).toEqual(sanitizeMask); + }); + }); + + describe('sanitizeKeys option has mixed type of RegExp and string', () => { + beforeEach(() => { + scrubData = new ScrubData({ + sanitizeKeys: [/^filename$/, 'function'], + }); + }); + + it('should mask only matched value', () => { + const event: any = scrubData.process(clone(messageEvent)); + expect(event.stacktrace.frames[0].function).toEqual(sanitizeMask); + expect(event.stacktrace.frames[1].function).toEqual(sanitizeMask); + expect(event.stacktrace.frames[0].filename).toEqual(sanitizeMask); + expect(event.stacktrace.frames[1].filename).toEqual(sanitizeMask); + }); + + it('should not mask unmatched value', () => { + const event: any = scrubData.process(clone(messageEvent)); + expect(event.stacktrace.frames[0].colno).toEqual(messageEvent.stacktrace.frames[0].colno); + expect(event.stacktrace.frames[1].colno).toEqual(messageEvent.stacktrace.frames[1].colno); + expect(event.stacktrace.frames[0].lineno).toEqual(messageEvent.stacktrace.frames[0].lineno); + expect(event.stacktrace.frames[1].lineno).toEqual(messageEvent.stacktrace.frames[1].lineno); + }); + }); + + describe('event has circular objects', () => { + beforeEach(() => { + scrubData = new ScrubData({ + sanitizeKeys: [/message/], + }); + }); + + it('should not show call stack size exceeded when circular reference in object', () => { + const event: any = { + contexts: {}, + extra: { + message: 'do not show', + }, + }; + event.contexts.circular = event.contexts; + + const actual: any = scrubData.process(event); + expect(actual.extra.message).toEqual(sanitizeMask); + }); + + it('should not show call stack size exceeded when circular reference in Array', () => { + const event: any = { + contexts: [], + extra: { + message: 'do not show', + }, + }; + event.contexts[0] = event.contexts; + + const actual: any = scrubData.process(event); + expect(actual.extra.message).toEqual(sanitizeMask); + }); + }); +});