Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions packages/integrations/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
116 changes: 116 additions & 0 deletions packages/integrations/src/scrubdata.ts
Original file line number Diff line number Diff line change
@@ -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<string | RegExp>;
}

/** 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<Record<string, unknown>>((acc, key) => {
acc[key] = this._sanitizeRegExp().test(key) ? this._sanitizeMask : this._sanitize(inputVal[key], memo);
return acc;
}, {});
}

memo.unmemoize(input);

return sanitizedValue;
}
}
151 changes: 151 additions & 0 deletions packages/integrations/test/scrubdata.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,151 @@
import { ScrubData } from '../src/scrubdata';

/** JSDoc */
function clone<T>(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);
});
});
});