Skip to content

Commit 9154a39

Browse files
authored
feat(integrations): Add scrubbing sensitive data integration (#2422)
Adds an optional `ScrubData` integration, mimicking the behavior of Raven's `sanitizeKeys`.
1 parent f001435 commit 9154a39

File tree

3 files changed

+268
-0
lines changed

3 files changed

+268
-0
lines changed

packages/integrations/src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ export { Ember } from './ember';
66
export { ExtraErrorData } from './extraerrordata';
77
export { ReportingObserver } from './reportingobserver';
88
export { RewriteFrames } from './rewriteframes';
9+
export { ScrubData } from './scrubdata';
910
export { SessionTiming } from './sessiontiming';
1011
export { Transaction } from './transaction';
1112
export { Vue } from './vue';
Lines changed: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,116 @@
1+
import { Event, EventHint, EventProcessor, Hub, Integration } from '@sentry/types';
2+
import { isPlainObject, isRegExp, Memo } from '@sentry/utils';
3+
4+
/** JSDoc */
5+
interface ScrubDataOptions {
6+
sanitizeKeys: Array<string | RegExp>;
7+
}
8+
9+
/** JSDoc */
10+
export class ScrubData implements Integration {
11+
/**
12+
* @inheritDoc
13+
*/
14+
public name: string = ScrubData.id;
15+
16+
/**
17+
* @inheritDoc
18+
*/
19+
public static id: string = 'ScrubData';
20+
21+
/** JSDoc */
22+
private readonly _options: ScrubDataOptions;
23+
private readonly _sanitizeMask: string;
24+
private _lazySanitizeRegExp?: RegExp;
25+
26+
/**
27+
* @inheritDoc
28+
*/
29+
public constructor(options: ScrubDataOptions) {
30+
this._options = {
31+
sanitizeKeys: [],
32+
...options,
33+
};
34+
this._sanitizeMask = '********';
35+
}
36+
37+
/**
38+
* @inheritDoc
39+
*/
40+
public setupOnce(addGlobalEventProcessor: (callback: EventProcessor) => void, getCurrentHub: () => Hub): void {
41+
addGlobalEventProcessor((event: Event, _hint?: EventHint) => {
42+
const self = getCurrentHub().getIntegration(ScrubData);
43+
if (self) {
44+
return self.process(event);
45+
}
46+
return event;
47+
});
48+
}
49+
50+
/** JSDoc */
51+
public process(event: Event): Event {
52+
if (this._options.sanitizeKeys.length === 0) {
53+
// nothing to sanitize
54+
return event;
55+
}
56+
57+
return this._sanitize(event) as Event;
58+
}
59+
60+
/**
61+
* lazily generate regexp
62+
*/
63+
private _sanitizeRegExp(): RegExp {
64+
if (this._lazySanitizeRegExp) {
65+
return this._lazySanitizeRegExp;
66+
}
67+
68+
const sources = this._options.sanitizeKeys.reduce(
69+
(acc, key) => {
70+
if (typeof key === 'string') {
71+
// escape string value
72+
// see also: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Guide/Regular_Expressions#Escaping
73+
acc.push(key.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'));
74+
} else if (isRegExp(key)) {
75+
acc.push(key.source);
76+
}
77+
return acc;
78+
},
79+
[] as string[],
80+
);
81+
82+
return (this._lazySanitizeRegExp = RegExp(sources.join('|'), 'i'));
83+
}
84+
85+
/**
86+
* sanitize event data recursively
87+
*/
88+
private _sanitize(input: unknown, memo: Memo = new Memo()): unknown {
89+
const inputIsArray = Array.isArray(input);
90+
const inputIsPlainObject = isPlainObject(input);
91+
92+
if (!inputIsArray && !inputIsPlainObject) {
93+
return input;
94+
}
95+
96+
// Avoid circular references
97+
if (memo.memoize(input)) {
98+
return input;
99+
}
100+
101+
let sanitizedValue;
102+
if (inputIsArray) {
103+
sanitizedValue = (input as any[]).map(value => this._sanitize(value, memo));
104+
} else if (inputIsPlainObject) {
105+
const inputVal = input as { [key: string]: unknown };
106+
sanitizedValue = Object.keys(inputVal).reduce<Record<string, unknown>>((acc, key) => {
107+
acc[key] = this._sanitizeRegExp().test(key) ? this._sanitizeMask : this._sanitize(inputVal[key], memo);
108+
return acc;
109+
}, {});
110+
}
111+
112+
memo.unmemoize(input);
113+
114+
return sanitizedValue;
115+
}
116+
}
Lines changed: 151 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,151 @@
1+
import { ScrubData } from '../src/scrubdata';
2+
3+
/** JSDoc */
4+
function clone<T>(data: T): T {
5+
return JSON.parse(JSON.stringify(data));
6+
}
7+
8+
let scrubData: ScrubData;
9+
const sanitizeMask = '********';
10+
const messageEvent = {
11+
fingerprint: ['MrSnuffles'],
12+
message: 'PickleRick',
13+
stacktrace: {
14+
frames: [
15+
{
16+
colno: 1,
17+
filename: 'filename.js',
18+
function: 'function',
19+
lineno: 1,
20+
},
21+
{
22+
colno: 2,
23+
filename: 'filename.js',
24+
function: 'function',
25+
lineno: 2,
26+
},
27+
],
28+
},
29+
};
30+
31+
describe('ScrubData', () => {
32+
describe('sanitizeKeys option is empty', () => {
33+
beforeEach(() => {
34+
scrubData = new ScrubData({
35+
sanitizeKeys: [],
36+
});
37+
});
38+
39+
it('should not affect any changes', () => {
40+
const event = clone(messageEvent);
41+
const processedEvent = scrubData.process(event);
42+
expect(processedEvent).toEqual(event);
43+
});
44+
});
45+
46+
describe('sanitizeKeys option has type of string', () => {
47+
beforeEach(() => {
48+
scrubData = new ScrubData({
49+
sanitizeKeys: ['message', 'filename'],
50+
});
51+
});
52+
53+
it('should mask matched value in object', () => {
54+
const event = scrubData.process(clone(messageEvent));
55+
expect(event.message).toEqual(sanitizeMask);
56+
});
57+
58+
it('should not mask unmatched value in object', () => {
59+
const event = scrubData.process(clone(messageEvent));
60+
expect(event.fingerprint).toEqual(messageEvent.fingerprint);
61+
});
62+
63+
it('should mask matched value in Array', () => {
64+
const event: any = scrubData.process(clone(messageEvent));
65+
expect(event.stacktrace.frames[0].filename).toEqual(sanitizeMask);
66+
expect(event.stacktrace.frames[1].filename).toEqual(sanitizeMask);
67+
});
68+
69+
it('should not mask unmatched value in Array', () => {
70+
const event: any = scrubData.process(clone(messageEvent));
71+
expect(event.stacktrace.frames[0].function).toEqual(messageEvent.stacktrace.frames[0].function);
72+
expect(event.stacktrace.frames[1].function).toEqual(messageEvent.stacktrace.frames[1].function);
73+
});
74+
});
75+
76+
describe('sanitizeKeys option has type of RegExp', () => {
77+
beforeEach(() => {
78+
scrubData = new ScrubData({
79+
sanitizeKeys: [/^name$/],
80+
});
81+
});
82+
83+
it('should mask only matched value', () => {
84+
const testEvent: any = {
85+
filename: 'to be show',
86+
name: 'do not show',
87+
};
88+
const event: any = scrubData.process(testEvent);
89+
expect(event.filename).toEqual(testEvent.filename);
90+
expect(event.name).toEqual(sanitizeMask);
91+
});
92+
});
93+
94+
describe('sanitizeKeys option has mixed type of RegExp and string', () => {
95+
beforeEach(() => {
96+
scrubData = new ScrubData({
97+
sanitizeKeys: [/^filename$/, 'function'],
98+
});
99+
});
100+
101+
it('should mask only matched value', () => {
102+
const event: any = scrubData.process(clone(messageEvent));
103+
expect(event.stacktrace.frames[0].function).toEqual(sanitizeMask);
104+
expect(event.stacktrace.frames[1].function).toEqual(sanitizeMask);
105+
expect(event.stacktrace.frames[0].filename).toEqual(sanitizeMask);
106+
expect(event.stacktrace.frames[1].filename).toEqual(sanitizeMask);
107+
});
108+
109+
it('should not mask unmatched value', () => {
110+
const event: any = scrubData.process(clone(messageEvent));
111+
expect(event.stacktrace.frames[0].colno).toEqual(messageEvent.stacktrace.frames[0].colno);
112+
expect(event.stacktrace.frames[1].colno).toEqual(messageEvent.stacktrace.frames[1].colno);
113+
expect(event.stacktrace.frames[0].lineno).toEqual(messageEvent.stacktrace.frames[0].lineno);
114+
expect(event.stacktrace.frames[1].lineno).toEqual(messageEvent.stacktrace.frames[1].lineno);
115+
});
116+
});
117+
118+
describe('event has circular objects', () => {
119+
beforeEach(() => {
120+
scrubData = new ScrubData({
121+
sanitizeKeys: [/message/],
122+
});
123+
});
124+
125+
it('should not show call stack size exceeded when circular reference in object', () => {
126+
const event: any = {
127+
contexts: {},
128+
extra: {
129+
message: 'do not show',
130+
},
131+
};
132+
event.contexts.circular = event.contexts;
133+
134+
const actual: any = scrubData.process(event);
135+
expect(actual.extra.message).toEqual(sanitizeMask);
136+
});
137+
138+
it('should not show call stack size exceeded when circular reference in Array', () => {
139+
const event: any = {
140+
contexts: [],
141+
extra: {
142+
message: 'do not show',
143+
},
144+
};
145+
event.contexts[0] = event.contexts;
146+
147+
const actual: any = scrubData.process(event);
148+
expect(actual.extra.message).toEqual(sanitizeMask);
149+
});
150+
});
151+
});

0 commit comments

Comments
 (0)