@heroku/js-blanket 0.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,284 @@
1
+ import { ScrubConfig, ScrubResult } from './types.js';
2
+
3
+ /**
4
+ * Core Scrubber - Deep object traversal with PII scrubbing
5
+ *
6
+ * A high-performance, immutable scrubbing engine that removes sensitive data from structured objects.
7
+ * Supports three scrubbing modes:
8
+ * - **Field-based**: Scrubs values based on field names (e.g., 'password', 'apiToken')
9
+ * - **Path-based**: Scrubs values at specific paths (e.g., 'user.email', 'request.headers.authorization')
10
+ * - **Pattern-based**: Scrubs content matching regex patterns (e.g., SSN, credit cards)
11
+ *
12
+ * ### Design Principles
13
+ * - **Immutable**: All operations create new objects, never mutate inputs
14
+ * - **Type-safe**: Preserves TypeScript types through generic constraints
15
+ * - **Circular-safe**: Handles circular references without crashing
16
+ * - **Performance**: <1ms p95 for logging, <10ms p95 for exception handling (544k+ ops/sec)
17
+ *
18
+ * ### Pattern Adoption
19
+ * Patterns adopted from `@heroku/oauth-provider-adapters-for-mcp/src/logging/redaction.ts`:
20
+ * - Deep recursive traversal with circular reference detection
21
+ * - Immutable cloning strategy with fallback for complex objects
22
+ * - Nested path resolution (e.g., 'user.profile.email')
23
+ * - General array path handling (e.g., 'users[0].password')
24
+ * - Type-safe generics preserving input types
25
+ *
26
+ * Enhanced with:
27
+ * - Field-based matching supporting both strings and regular expressions
28
+ * - Pattern-based content scrubbing for SSN, credit cards, etc.
29
+ * - Dual scrubbing: both field/path matching AND content pattern replacement
30
+ *
31
+ * @example Basic Usage
32
+ * ```typescript
33
+ * const scrubber = new Scrubber({
34
+ * fields: ['password', 'apiToken'],
35
+ * replacement: '[REDACTED]'
36
+ * });
37
+ *
38
+ * const result = scrubber.scrub({
39
+ * user: { name: 'John', password: 'secret123' }
40
+ * });
41
+ * // Result: { user: { name: 'John', password: '[REDACTED]' } }
42
+ * ```
43
+ *
44
+ * @example Advanced Usage with All Modes
45
+ * ```typescript
46
+ * const scrubber = new Scrubber({
47
+ * fields: ['password', /api[-_]?key/i], // Regex matches api_key, api-key, apikey
48
+ * paths: ['user.email', 'request.headers.authorization'],
49
+ * patterns: [/\b\d{3}-\d{2}-\d{4}\b/g], // SSN pattern
50
+ * replacement: '[SCRUBBED]'
51
+ * });
52
+ *
53
+ * const result = scrubber.scrub({
54
+ * user: { name: 'John', email: 'john@example.com', password: 'secret' },
55
+ * request: { headers: { authorization: 'Bearer token123' } },
56
+ * message: 'User SSN is 123-45-6789'
57
+ * });
58
+ * ```
59
+ */
60
+ export class Scrubber {
61
+ private config: Required<ScrubConfig>;
62
+ private circularRefs = new WeakSet();
63
+ private pathSet: Set<string>;
64
+
65
+ /**
66
+ * Creates a new Scrubber instance with the specified configuration
67
+ *
68
+ * @param config - Scrubbing configuration
69
+ * @param config.fields - Field names to scrub (strings or regex patterns)
70
+ * @param config.paths - Dot-notation paths to scrub (e.g., 'user.email', 'items[0].password')
71
+ * @param config.patterns - Regex patterns for content scrubbing (must include global flag for multiple matches)
72
+ * @param config.replacement - Replacement string for scrubbed values (default: '[SCRUBBED]')
73
+ * @param config.recursive - Whether to recursively scrub nested objects (default: true)
74
+ *
75
+ * @example
76
+ * ```typescript
77
+ * const scrubber = new Scrubber({
78
+ * fields: ['password', /api[-_]?key/i],
79
+ * paths: ['user.email'],
80
+ * patterns: [/\b\d{3}-\d{2}-\d{4}\b/g],
81
+ * replacement: '[REDACTED]'
82
+ * });
83
+ * ```
84
+ */
85
+ constructor(config: ScrubConfig) {
86
+ this.config = {
87
+ fields: config.fields || [],
88
+ paths: config.paths || [],
89
+ patterns: config.patterns || [],
90
+ replacement: config.replacement || '[SCRUBBED]',
91
+ recursive: config.recursive !== undefined ? config.recursive : true,
92
+ };
93
+
94
+ // Pre-compute path set for O(1) lookups
95
+ this.pathSet = new Set(this.config.paths);
96
+ }
97
+
98
+ /**
99
+ * Scrubs sensitive data from an object
100
+ *
101
+ * This is the main entry point for the scrubbing engine. It performs three types of scrubbing:
102
+ * 1. **Field-based**: Replaces values of fields matching configured field names/patterns
103
+ * 2. **Path-based**: Replaces values at specific dot-notation paths
104
+ * 3. **Pattern-based**: Replaces content within string values matching regex patterns
105
+ *
106
+ * The operation is immutable - the input object is not modified. A deep clone is created
107
+ * and scrubbed values are replaced in the clone.
108
+ *
109
+ * ### Performance Characteristics
110
+ * - Small objects (typical logs): ~0.003ms p95
111
+ * - Medium objects (typical errors): ~0.034ms p95
112
+ * - Large objects (10KB+): ~1.2ms p95
113
+ * - Throughput: 54,000+ events/sec
114
+ *
115
+ * @template T - The type of the input object (preserved in output)
116
+ * @param obj - The object to scrub
117
+ * @returns A result object containing the scrubbed data, whether scrubbing occurred, and which paths were scrubbed
118
+ *
119
+ * @example Basic scrubbing
120
+ * ```typescript
121
+ * const scrubber = new Scrubber({ fields: ['password'] });
122
+ * const result = scrubber.scrub({ user: 'john', password: 'secret' });
123
+ * // result.data === { user: 'john', password: '[SCRUBBED]' }
124
+ * // result.scrubbed === true
125
+ * // result.scrubbedPaths === ['password']
126
+ * ```
127
+ *
128
+ * @example Type preservation
129
+ * ```typescript
130
+ * interface User { name: string; email: string; password: string; }
131
+ * const scrubber = new Scrubber({ fields: ['password', 'email'] });
132
+ * const user: User = { name: 'John', email: 'john@example.com', password: 'secret' };
133
+ * const result = scrubber.scrub(user);
134
+ * // result.data is still typed as User
135
+ * ```
136
+ */
137
+ scrub<T>(obj: T): ScrubResult<T> {
138
+ const scrubbedPaths: string[] = [];
139
+ const cloned = this.deepClone(obj);
140
+
141
+ // Reset circular refs tracker for each scrub operation
142
+ this.circularRefs = new WeakSet();
143
+
144
+ const scrubbed = this.scrubObject(cloned, '', scrubbedPaths);
145
+
146
+ return {
147
+ data: scrubbed,
148
+ scrubbed: scrubbedPaths.length > 0,
149
+ scrubbedPaths,
150
+ };
151
+ }
152
+
153
+ private scrubObject(obj: any, path: string, paths: string[]): any {
154
+ // Handle circular references
155
+ if (obj && typeof obj === 'object') {
156
+ if (this.circularRefs.has(obj)) {
157
+ return '[Circular Reference]';
158
+ }
159
+ this.circularRefs.add(obj);
160
+ }
161
+
162
+ // Handle primitives
163
+ if (obj === null || typeof obj !== 'object') {
164
+ return this.scrubValue(obj, path, paths);
165
+ }
166
+
167
+ // Handle arrays
168
+ if (Array.isArray(obj)) {
169
+ return obj.map((item, index) => {
170
+ const indexStr = index.toString();
171
+ const arrayPath = path ? `${path}[${index}]` : indexStr;
172
+
173
+ // Check if this specific array index path should be scrubbed
174
+ if (this.pathSet.has(indexStr) || this.pathSet.has(arrayPath)) {
175
+ paths.push(arrayPath);
176
+ return this.config.replacement;
177
+ }
178
+
179
+ // Recursively scrub array items
180
+ return this.scrubObject(item, arrayPath, paths);
181
+ });
182
+ }
183
+
184
+ // Handle objects - create new object (immutable approach)
185
+ const result: Record<string, unknown> = {};
186
+ for (const [key, value] of Object.entries(obj)) {
187
+ const keyPath = path ? `${path}.${key}` : key;
188
+
189
+ // Check if this specific path should be scrubbed
190
+ if (this.pathSet.has(key) || this.pathSet.has(keyPath)) {
191
+ result[key] = this.config.replacement;
192
+ paths.push(keyPath);
193
+ continue;
194
+ }
195
+
196
+ // Check if key matches sensitive field pattern
197
+ if (this.isSensitiveField(key)) {
198
+ result[key] = this.config.replacement;
199
+ paths.push(keyPath);
200
+ continue;
201
+ }
202
+
203
+ // Recursively scrub value
204
+ result[key] = this.config.recursive
205
+ ? this.scrubObject(value, keyPath, paths)
206
+ : this.scrubValue(value, keyPath, paths);
207
+ }
208
+
209
+ return result;
210
+ }
211
+
212
+ private scrubValue(value: any, path: string, paths: string[]): any {
213
+ if (typeof value !== 'string') {
214
+ return value;
215
+ }
216
+
217
+ let scrubbed = value;
218
+ let didScrub = false;
219
+
220
+ // Check against patterns (SSN, credit cards, etc.)
221
+ for (const pattern of this.config.patterns) {
222
+ if (pattern.test(scrubbed)) {
223
+ scrubbed = scrubbed.replace(pattern, this.config.replacement);
224
+ didScrub = true;
225
+ }
226
+ }
227
+
228
+ if (didScrub) {
229
+ paths.push(path);
230
+ }
231
+
232
+ return scrubbed;
233
+ }
234
+
235
+ /**
236
+ * Check if a field name matches any configured sensitive field patterns
237
+ */
238
+ private isSensitiveField(key: string): boolean {
239
+ return this.config.fields.some((field) => {
240
+ if (field instanceof RegExp) {
241
+ return field.test(key);
242
+ }
243
+ return key.toLowerCase().includes(field.toLowerCase());
244
+ });
245
+ }
246
+
247
+ private deepClone<T>(obj: T): T {
248
+ try {
249
+ // Fast path for JSON-serializable objects
250
+ return JSON.parse(JSON.stringify(obj));
251
+ } catch {
252
+ // Fallback for objects with circular references
253
+ const seen = new WeakMap();
254
+
255
+ function clone(value: any): any {
256
+ if (value === null || typeof value !== 'object') {
257
+ return value;
258
+ }
259
+
260
+ if (seen.has(value)) {
261
+ return seen.get(value);
262
+ }
263
+
264
+ if (Array.isArray(value)) {
265
+ const arr: any[] = [];
266
+ seen.set(value, arr);
267
+ value.forEach((item, i) => {
268
+ arr[i] = clone(item);
269
+ });
270
+ return arr;
271
+ }
272
+
273
+ const obj: any = {};
274
+ seen.set(value, obj);
275
+ Object.keys(value).forEach((key) => {
276
+ obj[key] = clone(value[key]);
277
+ });
278
+ return obj;
279
+ }
280
+
281
+ return clone(obj);
282
+ }
283
+ }
284
+ }