n8n-nodes-redactor 2.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.

Potentially problematic release.


This version of n8n-nodes-redactor might be problematic. Click here for more details.

Files changed (37) hide show
  1. package/LICENSE +42 -0
  2. package/README.dev.md +134 -0
  3. package/README.md +376 -0
  4. package/README.npm.md +376 -0
  5. package/dist/nodes/PiiRedactor/PiiRedactor.node.d.ts +5 -0
  6. package/dist/nodes/PiiRedactor/PiiRedactor.node.js +872 -0
  7. package/dist/nodes/PiiRedactor/__tests__/engine.test.d.ts +1 -0
  8. package/dist/nodes/PiiRedactor/__tests__/engine.test.js +524 -0
  9. package/dist/nodes/PiiRedactor/__tests__/operations.test.d.ts +1 -0
  10. package/dist/nodes/PiiRedactor/__tests__/operations.test.js +316 -0
  11. package/dist/nodes/PiiRedactor/__tests__/patterns-global.test.d.ts +1 -0
  12. package/dist/nodes/PiiRedactor/__tests__/patterns-global.test.js +427 -0
  13. package/dist/nodes/PiiRedactor/__tests__/patterns.test.d.ts +1 -0
  14. package/dist/nodes/PiiRedactor/__tests__/patterns.test.js +481 -0
  15. package/dist/nodes/PiiRedactor/__tests__/phase1.test.d.ts +1 -0
  16. package/dist/nodes/PiiRedactor/__tests__/phase1.test.js +343 -0
  17. package/dist/nodes/PiiRedactor/__tests__/security.test.d.ts +1 -0
  18. package/dist/nodes/PiiRedactor/__tests__/security.test.js +178 -0
  19. package/dist/nodes/PiiRedactor/__tests__/semantic.test.d.ts +1 -0
  20. package/dist/nodes/PiiRedactor/__tests__/semantic.test.js +319 -0
  21. package/dist/nodes/PiiRedactor/__tests__/vault.test.d.ts +1 -0
  22. package/dist/nodes/PiiRedactor/__tests__/vault.test.js +247 -0
  23. package/dist/nodes/PiiRedactor/context.d.ts +57 -0
  24. package/dist/nodes/PiiRedactor/context.js +260 -0
  25. package/dist/nodes/PiiRedactor/engine.d.ts +17 -0
  26. package/dist/nodes/PiiRedactor/engine.js +813 -0
  27. package/dist/nodes/PiiRedactor/names.d.ts +25 -0
  28. package/dist/nodes/PiiRedactor/names.js +188 -0
  29. package/dist/nodes/PiiRedactor/patterns.d.ts +17 -0
  30. package/dist/nodes/PiiRedactor/patterns.js +1741 -0
  31. package/dist/nodes/PiiRedactor/redact.png +0 -0
  32. package/dist/nodes/PiiRedactor/redact.svg +3 -0
  33. package/dist/nodes/PiiRedactor/types.d.ts +78 -0
  34. package/dist/nodes/PiiRedactor/types.js +3 -0
  35. package/dist/nodes/PiiRedactor/vault.d.ts +60 -0
  36. package/dist/nodes/PiiRedactor/vault.js +299 -0
  37. package/package.json +87 -0
@@ -0,0 +1,343 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ const vault_1 = require("../vault");
4
+ const engine_1 = require("../engine");
5
+ const context_1 = require("../context");
6
+ const names_1 = require("../names");
7
+ function ctx(overrides = {}) {
8
+ return {
9
+ enabledPatterns: ['email', 'phone', 'ssn', 'creditCard', 'iban'],
10
+ customPatterns: [],
11
+ mode: 'token',
12
+ dedup: true,
13
+ fieldRules: [],
14
+ fieldMode: 'all',
15
+ ...overrides,
16
+ };
17
+ }
18
+ // ═══════════════════════════════════════════════════════
19
+ // 1.1 ALLOW LIST TESTS
20
+ // ═══════════════════════════════════════════════════════
21
+ describe('Allow List', () => {
22
+ let vault;
23
+ beforeEach(() => { vault = new vault_1.MemoryVault(); });
24
+ test('exact match: bypass specific email', () => {
25
+ vault.getOrCreateSession('al1', 0);
26
+ const hits = [];
27
+ const result = (0, engine_1.redactValue)({ text: 'Contact support@mycompany.com or john@gmail.com' }, ctx({ allowList: [{ value: 'support@mycompany.com', type: 'exact' }] }), vault, 'al1', hits, 0);
28
+ expect(result.text).toContain('support@mycompany.com'); // NOT redacted
29
+ expect(result.text).not.toContain('john@gmail.com'); // redacted
30
+ });
31
+ test('contains match: bypass company domain', () => {
32
+ vault.getOrCreateSession('al2', 0);
33
+ const hits = [];
34
+ const result = (0, engine_1.redactValue)({ text: 'Email alice@mycompany.com and bob@mycompany.com and external@gmail.com' }, ctx({ allowList: [{ value: '@mycompany.com', type: 'contains' }] }), vault, 'al2', hits, 0);
35
+ expect(result.text).toContain('alice@mycompany.com');
36
+ expect(result.text).toContain('bob@mycompany.com');
37
+ expect(result.text).not.toContain('external@gmail.com');
38
+ });
39
+ test('regex match: bypass pattern', () => {
40
+ vault.getOrCreateSession('al3', 0);
41
+ const hits = [];
42
+ const result = (0, engine_1.redactValue)({ text: 'Public: info@acme.com, private: ceo@acme.com, external: john@gmail.com' }, ctx({ allowList: [{ value: 'info@.*\\.com', type: 'regex' }] }), vault, 'al3', hits, 0);
43
+ expect(result.text).toContain('info@acme.com');
44
+ expect(result.text).not.toContain('ceo@acme.com');
45
+ expect(result.text).not.toContain('john@gmail.com');
46
+ });
47
+ test('allow list works with semantic detection', () => {
48
+ vault.getOrCreateSession('al4', 0);
49
+ const hits = [];
50
+ const result = (0, engine_1.redactValue)({ name: 'Support Team', email: 'support@mycompany.com' }, ctx({ allowList: [
51
+ { value: 'Support Team', type: 'exact' },
52
+ { value: '@mycompany.com', type: 'contains' },
53
+ ] }), vault, 'al4', hits, 0);
54
+ expect(result.name).toBe('Support Team');
55
+ expect(result.email).toBe('support@mycompany.com');
56
+ });
57
+ test('empty allow list does not affect redaction', () => {
58
+ vault.getOrCreateSession('al5', 0);
59
+ const hits = [];
60
+ const result = (0, engine_1.redactValue)({ email: 'test@test.com' }, ctx({ allowList: [] }), vault, 'al5', hits, 0);
61
+ expect(result.email).not.toBe('test@test.com');
62
+ });
63
+ test('allow list with invalid regex does not crash', () => {
64
+ vault.getOrCreateSession('al6', 0);
65
+ const hits = [];
66
+ const result = (0, engine_1.redactValue)({ email: 'test@test.com' }, ctx({ allowList: [{ value: '[invalid(', type: 'regex' }] }), vault, 'al6', hits, 0);
67
+ // Should not crash, email should still be redacted
68
+ expect(result.email).not.toBe('test@test.com');
69
+ });
70
+ test('multiple allow list entries combined', () => {
71
+ vault.getOrCreateSession('al7', 0);
72
+ const hits = [];
73
+ const result = (0, engine_1.redactValue)({ text: 'a@safe.com, b@safe.org, c@unsafe.com' }, ctx({ allowList: [
74
+ { value: '@safe.com', type: 'contains' },
75
+ { value: '@safe.org', type: 'contains' },
76
+ ] }), vault, 'al7', hits, 0);
77
+ expect(result.text).toContain('a@safe.com');
78
+ expect(result.text).toContain('b@safe.org');
79
+ expect(result.text).not.toContain('c@unsafe.com');
80
+ });
81
+ });
82
+ // ═══════════════════════════════════════════════════════
83
+ // 1.2 DENY LIST TESTS
84
+ // ═══════════════════════════════════════════════════════
85
+ describe('Deny List', () => {
86
+ let vault;
87
+ beforeEach(() => { vault = new vault_1.MemoryVault(); });
88
+ test('exact match: always redact specific value', () => {
89
+ vault.getOrCreateSession('dl1', 0);
90
+ const hits = [];
91
+ const result = (0, engine_1.redactValue)({ text: 'Project codename: Project Falcon is confidential' }, ctx({ enabledPatterns: [], denyList: [{ value: 'Project Falcon', type: 'exact' }] }), vault, 'dl1', hits, 0);
92
+ expect(result.text).not.toContain('Project Falcon');
93
+ expect(hits).toHaveLength(1);
94
+ expect(hits[0].patternLabel).toBe('DENY_LIST');
95
+ expect(hits[0].confidence).toBe(1.0);
96
+ });
97
+ test('contains match: redact any occurrence', () => {
98
+ vault.getOrCreateSession('dl2', 0);
99
+ const hits = [];
100
+ const result = (0, engine_1.redactValue)({ text: 'INTERNAL-SECRET-2024 and INTERNAL-SECRET-2025 are classified' }, ctx({ enabledPatterns: [], denyList: [{ value: 'INTERNAL-SECRET', type: 'contains' }] }), vault, 'dl2', hits, 0);
101
+ expect(result.text).not.toContain('INTERNAL-SECRET');
102
+ });
103
+ test('deny list combined with allow list (deny takes priority)', () => {
104
+ vault.getOrCreateSession('dl3', 0);
105
+ const hits = [];
106
+ const result = (0, engine_1.redactValue)({ text: 'Contact ceo@acme.com for Project Falcon details' }, ctx({
107
+ denyList: [{ value: 'Project Falcon', type: 'exact' }],
108
+ allowList: [{ value: '@acme.com', type: 'contains' }],
109
+ }), vault, 'dl3', hits, 0);
110
+ expect(result.text).toContain('ceo@acme.com'); // allowed
111
+ expect(result.text).not.toContain('Project Falcon'); // denied
112
+ });
113
+ test('empty deny list does not affect redaction', () => {
114
+ vault.getOrCreateSession('dl4', 0);
115
+ const hits = [];
116
+ const result = (0, engine_1.redactValue)({ text: 'Normal text' }, ctx({ denyList: [] }), vault, 'dl4', hits, 0);
117
+ expect(result.text).toBe('Normal text');
118
+ });
119
+ });
120
+ // ═══════════════════════════════════════════════════════
121
+ // 1.3 CONTEXT WORD BOOSTING TESTS
122
+ // ═══════════════════════════════════════════════════════
123
+ describe('Context Word Boosting', () => {
124
+ test('hasContextWords finds nearby keywords', () => {
125
+ const text = 'Please provide your SSN: 123-45-6789 for verification';
126
+ expect((0, context_1.hasContextWords)(text, 25, 36, ['SSN', 'social security'])).toBe(true);
127
+ });
128
+ test('hasContextWords returns false when no keywords nearby', () => {
129
+ const text = 'The code is 123-45-6789 in the system';
130
+ expect((0, context_1.hasContextWords)(text, 12, 23, ['SSN', 'social security'])).toBe(false);
131
+ });
132
+ test('context words are case-insensitive', () => {
133
+ const text = 'your ssn is 123-45-6789';
134
+ expect((0, context_1.hasContextWords)(text, 12, 23, ['SSN'])).toBe(true);
135
+ });
136
+ test('CONTEXT_WORDS has entries for key patterns', () => {
137
+ expect(context_1.CONTEXT_WORDS.email).toBeDefined();
138
+ expect(context_1.CONTEXT_WORDS.email.length).toBeGreaterThan(0);
139
+ expect(context_1.CONTEXT_WORDS.ssn).toBeDefined();
140
+ expect(context_1.CONTEXT_WORDS.creditCard).toBeDefined();
141
+ expect(context_1.CONTEXT_WORDS.iban).toBeDefined();
142
+ expect(context_1.CONTEXT_WORDS.phone).toBeDefined();
143
+ });
144
+ test('context boost increases confidence score', () => {
145
+ const textWithContext = 'SSN: 123-45-6789';
146
+ const textWithout = 'Code: 123-45-6789';
147
+ const confWith = (0, context_1.calculateConfidence)('ssn', false, true, textWithContext, 5, 16);
148
+ const confWithout = (0, context_1.calculateConfidence)('ssn', false, true, textWithout, 6, 17);
149
+ expect(confWith).toBeGreaterThan(confWithout);
150
+ });
151
+ });
152
+ // ═══════════════════════════════════════════════════════
153
+ // 1.4 CONFIDENCE SCORING TESTS
154
+ // ═══════════════════════════════════════════════════════
155
+ describe('Confidence Scoring', () => {
156
+ let vault;
157
+ beforeEach(() => { vault = new vault_1.MemoryVault(); });
158
+ test('checksum-validated patterns get 0.95 confidence', () => {
159
+ const conf = (0, context_1.calculateConfidence)('creditCard', true, true, 'Card: 4532015112830366', 6, 22);
160
+ expect(conf).toBeGreaterThanOrEqual(0.95);
161
+ });
162
+ test('email gets high confidence', () => {
163
+ const conf = (0, context_1.calculateConfidence)('email', false, true, 'test@example.com', 0, 16);
164
+ expect(conf).toBeGreaterThanOrEqual(0.85);
165
+ });
166
+ test('broad patterns get lower confidence', () => {
167
+ const conf = (0, context_1.calculateConfidence)('postalCodeDE', false, true, '80331', 0, 5);
168
+ expect(conf).toBeLessThanOrEqual(0.50);
169
+ });
170
+ test('confidence threshold filters out low-confidence matches', () => {
171
+ vault.getOrCreateSession('ct1', 0);
172
+ const hits = [];
173
+ const result = (0, engine_1.redactValue)({ text: 'Email: test@example.com' }, ctx({ confidenceThreshold: 0.80 }), vault, 'ct1', hits, 0);
174
+ // Email has high confidence, should be redacted
175
+ expect(result.text).not.toContain('test@example.com');
176
+ expect(hits.length).toBeGreaterThan(0);
177
+ expect(hits[0].confidence).toBeGreaterThanOrEqual(0.80);
178
+ });
179
+ test('high threshold blocks low-confidence patterns', () => {
180
+ vault.getOrCreateSession('ct2', 0);
181
+ const hits = [];
182
+ // With threshold 0.99, only checksum-validated should pass
183
+ const result = (0, engine_1.redactValue)({ text: 'Random number: 80331' }, ctx({
184
+ enabledPatterns: ['postalCodeDE'],
185
+ confidenceThreshold: 0.99,
186
+ }), vault, 'ct2', hits, 0);
187
+ // postalCodeDE has low confidence (0.40), should NOT be redacted
188
+ expect(result.text).toBe('Random number: 80331');
189
+ expect(hits).toHaveLength(0);
190
+ });
191
+ test('threshold 0 redacts everything (default)', () => {
192
+ vault.getOrCreateSession('ct3', 0);
193
+ const hits = [];
194
+ const result = (0, engine_1.redactValue)({ text: 'test@test.com' }, ctx({ confidenceThreshold: 0 }), vault, 'ct3', hits, 0);
195
+ expect(result.text).not.toContain('test@test.com');
196
+ });
197
+ test('all hits include confidence field', () => {
198
+ vault.getOrCreateSession('ct4', 0);
199
+ const hits = [];
200
+ (0, engine_1.redactValue)({ text: 'Email: a@b.com, SSN: 123-45-6789' }, ctx(), vault, 'ct4', hits, 0);
201
+ for (const hit of hits) {
202
+ expect(hit.confidence).toBeDefined();
203
+ expect(hit.confidence).toBeGreaterThan(0);
204
+ expect(hit.confidence).toBeLessThanOrEqual(1.0);
205
+ }
206
+ });
207
+ });
208
+ // ═══════════════════════════════════════════════════════
209
+ // 1.5 NAME DICTIONARY TESTS
210
+ // ═══════════════════════════════════════════════════════
211
+ describe('Name Dictionary', () => {
212
+ test('FIRST_NAMES contains common English names', () => {
213
+ expect(names_1.FIRST_NAMES.has('James')).toBe(true);
214
+ expect(names_1.FIRST_NAMES.has('Mary')).toBe(true);
215
+ expect(names_1.FIRST_NAMES.has('Michael')).toBe(true);
216
+ });
217
+ test('FIRST_NAMES contains German names', () => {
218
+ expect(names_1.FIRST_NAMES.has('Hans')).toBe(true);
219
+ expect(names_1.FIRST_NAMES.has('Klaus')).toBe(true);
220
+ expect(names_1.FIRST_NAMES.has('Ursula')).toBe(true);
221
+ });
222
+ test('FIRST_NAMES contains French names', () => {
223
+ expect(names_1.FIRST_NAMES.has('Jean')).toBe(true);
224
+ expect(names_1.FIRST_NAMES.has('Marie')).toBe(true);
225
+ });
226
+ test('FIRST_NAMES contains Spanish names', () => {
227
+ expect(names_1.FIRST_NAMES.has('Antonio')).toBe(true);
228
+ expect(names_1.FIRST_NAMES.has('Carmen')).toBe(true);
229
+ });
230
+ test('FIRST_NAMES contains Indian names', () => {
231
+ expect(names_1.FIRST_NAMES.has('Adeel')).toBe(true);
232
+ expect(names_1.FIRST_NAMES.has('Priya')).toBe(true);
233
+ expect(names_1.FIRST_NAMES.has('Rahul')).toBe(true);
234
+ });
235
+ test('LAST_NAMES contains common surnames', () => {
236
+ expect(names_1.LAST_NAMES.has('Smith')).toBe(true);
237
+ expect(names_1.LAST_NAMES.has('Mueller')).toBe(true);
238
+ expect(names_1.LAST_NAMES.has('Garcia')).toBe(true);
239
+ expect(names_1.LAST_NAMES.has('Patel')).toBe(true);
240
+ expect(names_1.LAST_NAMES.has('Solangi')).toBe(true);
241
+ });
242
+ test('detectNamesInText finds names in free text', () => {
243
+ const names = (0, names_1.detectNamesInText)('Spoke with Sarah Johnson about her account');
244
+ expect(names.length).toBeGreaterThan(0);
245
+ expect(names[0].name).toBe('Sarah Johnson');
246
+ });
247
+ test('detectNamesInText finds German names', () => {
248
+ const names = (0, names_1.detectNamesInText)('Der Kunde ist Hans Mueller aus Berlin');
249
+ const hansMatch = names.find((n) => n.name.includes('Hans'));
250
+ expect(hansMatch).toBeDefined();
251
+ });
252
+ test('detectNamesInText does NOT match city names', () => {
253
+ const names = (0, names_1.detectNamesInText)('We visited New York and Los Angeles');
254
+ expect(names).toHaveLength(0);
255
+ });
256
+ test('detectNamesInText does NOT match months', () => {
257
+ const names = (0, names_1.detectNamesInText)('Meeting scheduled for January February');
258
+ expect(names).toHaveLength(0);
259
+ });
260
+ test('detectNamesInText does NOT match tech terms', () => {
261
+ const names = (0, names_1.detectNamesInText)('Data Server Client Table Field');
262
+ expect(names).toHaveLength(0);
263
+ });
264
+ test('name dictionary works in redaction engine', () => {
265
+ const vault = new vault_1.MemoryVault();
266
+ vault.getOrCreateSession('nd1', 0);
267
+ const hits = [];
268
+ const result = (0, engine_1.redactValue)({ notes: 'Spoke with Sarah Johnson about her billing issue' }, ctx(), vault, 'nd1', hits, 0);
269
+ // Sarah Johnson should be detected by name dictionary
270
+ expect(result.notes).not.toContain('Sarah Johnson');
271
+ });
272
+ test('name dictionary respects allow list', () => {
273
+ const vault = new vault_1.MemoryVault();
274
+ vault.getOrCreateSession('nd2', 0);
275
+ const hits = [];
276
+ const result = (0, engine_1.redactValue)({ notes: 'Spoke with Sarah Johnson at the office' }, ctx({ allowList: [{ value: 'Sarah Johnson', type: 'exact' }] }), vault, 'nd2', hits, 0);
277
+ expect(result.notes).toContain('Sarah Johnson');
278
+ });
279
+ });
280
+ // ═══════════════════════════════════════════════════════
281
+ // INTEGRATION: All Phase 1 features combined
282
+ // ═══════════════════════════════════════════════════════
283
+ describe('Phase 1 Integration', () => {
284
+ let vault;
285
+ beforeEach(() => { vault = new vault_1.MemoryVault(); });
286
+ test('allow + deny + confidence + names all working together', () => {
287
+ vault.getOrCreateSession('int1', 0);
288
+ const hits = [];
289
+ const result = (0, engine_1.redactValue)({
290
+ message: 'Contact Sarah Johnson at support@acme.com about Project Falcon. Her SSN is 123-45-6789.',
291
+ notes: 'Internal ref: CLASSIFIED-2024',
292
+ }, ctx({
293
+ allowList: [{ value: '@acme.com', type: 'contains' }],
294
+ denyList: [
295
+ { value: 'Project Falcon', type: 'exact' },
296
+ { value: 'CLASSIFIED', type: 'contains' },
297
+ ],
298
+ confidenceThreshold: 0,
299
+ }), vault, 'int1', hits, 0);
300
+ // Allow list: support@acme.com should NOT be redacted
301
+ expect(result.message).toContain('support@acme.com');
302
+ // Deny list: Project Falcon should be redacted
303
+ expect(result.message).not.toContain('Project Falcon');
304
+ // SSN: should be redacted (regex pattern)
305
+ expect(result.message).not.toContain('123-45-6789');
306
+ // Name: Sarah Johnson should be detected by dictionary
307
+ expect(result.message).not.toContain('Sarah Johnson');
308
+ // Deny list: CLASSIFIED should be redacted in notes
309
+ expect(result.notes).not.toContain('CLASSIFIED-2024');
310
+ });
311
+ test('full round-trip with allow/deny lists', () => {
312
+ vault.getOrCreateSession('int2', 0);
313
+ const hits = [];
314
+ const original = {
315
+ text: 'Email support@safe.com or private@gmail.com. Secret: CODENAME-X.',
316
+ };
317
+ const redacted = (0, engine_1.redactValue)(original, ctx({
318
+ allowList: [{ value: '@safe.com', type: 'contains' }],
319
+ denyList: [{ value: 'CODENAME-X', type: 'exact' }],
320
+ }), vault, 'int2', hits, 0);
321
+ // Verify redaction
322
+ expect(redacted.text).toContain('support@safe.com');
323
+ expect(redacted.text).not.toContain('private@gmail.com');
324
+ expect(redacted.text).not.toContain('CODENAME-X');
325
+ // Restore
326
+ const restored = (0, engine_1.restoreValue)(redacted, vault, 'int2');
327
+ expect(restored.text).toContain('private@gmail.com');
328
+ expect(restored.text).toContain('CODENAME-X');
329
+ expect(restored.text).toContain('support@safe.com');
330
+ });
331
+ test('confidence scores in report output', () => {
332
+ vault.getOrCreateSession('int3', 0);
333
+ const hits = [];
334
+ (0, engine_1.redactValue)({ text: 'SSN: 123-45-6789, email: test@test.com' }, ctx(), vault, 'int3', hits, 0);
335
+ // Every hit should have a confidence score
336
+ expect(hits.length).toBeGreaterThan(0);
337
+ for (const hit of hits) {
338
+ expect(typeof hit.confidence).toBe('number');
339
+ expect(hit.confidence).toBeGreaterThan(0);
340
+ expect(hit.confidence).toBeLessThanOrEqual(1.0);
341
+ }
342
+ });
343
+ });
@@ -0,0 +1,178 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ const vault_1 = require("../vault");
4
+ const engine_1 = require("../engine");
5
+ function createContext(overrides = {}) {
6
+ return {
7
+ enabledPatterns: ['email', 'phone', 'personName', 'ssn', 'creditCard', 'iban', 'ipv4'],
8
+ customPatterns: [],
9
+ mode: 'token',
10
+ dedup: true,
11
+ fieldRules: [],
12
+ fieldMode: 'all',
13
+ ...overrides,
14
+ };
15
+ }
16
+ // ═══════════════════════════════════════════════════════
17
+ // SECURITY: ReDoS Prevention
18
+ // ═══════════════════════════════════════════════════════
19
+ describe('ReDoS prevention', () => {
20
+ let vault;
21
+ beforeEach(() => { vault = new vault_1.MemoryVault(); });
22
+ test('custom regex with nested quantifiers is rejected', () => {
23
+ vault.getOrCreateSession('redos-1', 0);
24
+ const hits = [];
25
+ // (a+)+ is a classic ReDoS pattern
26
+ const result = (0, engine_1.redactValue)({ text: 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaa!' }, createContext({
27
+ enabledPatterns: [],
28
+ customPatterns: [{ label: 'EVIL', regex: '(a+)+$' }],
29
+ }), vault, 'redos-1', hits, 0);
30
+ // Should NOT hang, and should NOT redact (pattern rejected)
31
+ expect(result.text).toBe('aaaaaaaaaaaaaaaaaaaaaaaaaaaaaa!');
32
+ expect(hits).toHaveLength(0);
33
+ });
34
+ test('custom regex over 500 chars is rejected', () => {
35
+ vault.getOrCreateSession('redos-2', 0);
36
+ const hits = [];
37
+ const longRegex = 'a'.repeat(600);
38
+ const result = (0, engine_1.redactValue)({ text: 'test' }, createContext({
39
+ enabledPatterns: [],
40
+ customPatterns: [{ label: 'LONG', regex: longRegex }],
41
+ }), vault, 'redos-2', hits, 0);
42
+ expect(hits).toHaveLength(0);
43
+ });
44
+ test('connStringKV does not hang on adversarial input', () => {
45
+ vault.getOrCreateSession('redos-3', 0);
46
+ const hits = [];
47
+ const adversarial = 'Server=x;' + 'a'.repeat(10000);
48
+ const start = Date.now();
49
+ (0, engine_1.redactValue)({ text: adversarial }, createContext({ enabledPatterns: ['connStringKV'] }), vault, 'redos-3', hits, 0);
50
+ const elapsed = Date.now() - start;
51
+ // Should complete in under 5 seconds, not hang
52
+ expect(elapsed).toBeLessThan(5000);
53
+ });
54
+ });
55
+ // ═══════════════════════════════════════════════════════
56
+ // SECURITY: Information Leakage Prevention
57
+ // ═══════════════════════════════════════════════════════
58
+ describe('Information leakage prevention', () => {
59
+ let vault;
60
+ beforeEach(() => { vault = new vault_1.MemoryVault(); });
61
+ test('audit report NEVER contains original PII values', () => {
62
+ vault.getOrCreateSession('leak-1', 0);
63
+ const hits = [];
64
+ (0, engine_1.redactValue)({ email: 'secret@company.com', ssn: '123-45-6789' }, createContext({ mode: 'token' }), vault, 'leak-1', hits, 0);
65
+ const report = (0, engine_1.buildReport)('leak-1', hits);
66
+ const reportJson = JSON.stringify(report);
67
+ // Original values must NOT appear in the report
68
+ expect(reportJson).not.toContain('secret@company.com');
69
+ expect(reportJson).not.toContain('123-45-6789');
70
+ // All originals should be '***'
71
+ for (const hit of report.hits) {
72
+ expect(hit.original).toBe('***');
73
+ }
74
+ });
75
+ test('audit report in mask mode also hides originals', () => {
76
+ vault.getOrCreateSession('leak-2', 0);
77
+ const hits = [];
78
+ (0, engine_1.redactValue)({ email: 'secret@company.com' }, createContext({ mode: 'mask' }), vault, 'leak-2', hits, 0);
79
+ for (const hit of hits) {
80
+ expect(hit.original).toBe('***');
81
+ }
82
+ });
83
+ });
84
+ // ═══════════════════════════════════════════════════════
85
+ // SECURITY: Restore Infinite Loop Prevention
86
+ // ═══════════════════════════════════════════════════════
87
+ describe('Restore safety', () => {
88
+ let vault;
89
+ beforeEach(() => { vault = new vault_1.MemoryVault(); });
90
+ test('restore does not infinite loop when original contains a token', () => {
91
+ vault.getOrCreateSession('loop-1', 0);
92
+ // Manually create a vault entry where the original contains another token
93
+ vault.addEntry('loop-1', {
94
+ token: '[EMAIL_0]',
95
+ original: 'Contact [PHONE_1] for info',
96
+ patternLabel: 'EMAIL',
97
+ category: 'contact',
98
+ createdAt: new Date().toISOString(),
99
+ });
100
+ vault.addEntry('loop-1', {
101
+ token: '[PHONE_1]',
102
+ original: '555-1234',
103
+ patternLabel: 'PHONE',
104
+ category: 'contact',
105
+ createdAt: new Date().toISOString(),
106
+ });
107
+ const start = Date.now();
108
+ const result = (0, engine_1.restoreValue)({ text: 'Message: [EMAIL_0]' }, vault, 'loop-1');
109
+ const elapsed = Date.now() - start;
110
+ // Must complete quickly (no infinite loop)
111
+ expect(elapsed).toBeLessThan(1000);
112
+ // The split/join approach replaces in order, so [EMAIL_0] becomes
113
+ // "Contact [PHONE_1] for info", then [PHONE_1] becomes "555-1234"
114
+ expect(result.text).toBe('Message: Contact 555-1234 for info');
115
+ });
116
+ });
117
+ // ═══════════════════════════════════════════════════════
118
+ // SECURITY: Memory Leak Prevention
119
+ // ═══════════════════════════════════════════════════════
120
+ describe('Memory leak prevention', () => {
121
+ test('MemoryVault enforces max session limit', () => {
122
+ const vault = new vault_1.MemoryVault();
123
+ // Create many sessions
124
+ for (let i = 0; i < 1010; i++) {
125
+ vault.getOrCreateSession(`session-${i}`, 60000);
126
+ }
127
+ const sessions = vault.listSessions();
128
+ // Should not exceed max (1000) + some buffer
129
+ expect(sessions.length).toBeLessThanOrEqual(1010);
130
+ });
131
+ test('MemoryVault auto-cleans expired sessions on create', () => {
132
+ const vault = new vault_1.MemoryVault();
133
+ // Create session with 1ms TTL
134
+ vault.getOrCreateSession('expire-me', 1);
135
+ // Wait for expiry
136
+ const start = Date.now();
137
+ while (Date.now() - start < 10) { /* busy wait */ }
138
+ // Creating a new session triggers cleanup
139
+ vault.getOrCreateSession('new-session', 0);
140
+ // Expired session should be gone
141
+ expect(vault.getSession('expire-me')).toBeNull();
142
+ });
143
+ });
144
+ // ═══════════════════════════════════════════════════════
145
+ // SECURITY: Field Path Injection Prevention
146
+ // ═══════════════════════════════════════════════════════
147
+ describe('Field path injection prevention', () => {
148
+ let vault;
149
+ beforeEach(() => { vault = new vault_1.MemoryVault(); });
150
+ test('malicious field path pattern does not crash', () => {
151
+ vault.getOrCreateSession('field-inj', 0);
152
+ const hits = [];
153
+ // Attempt to inject regex via field rule
154
+ const result = (0, engine_1.redactValue)({ email: 'test@test.com' }, createContext({
155
+ fieldMode: 'allowlist',
156
+ fieldRules: [{ field: '(a+)+', mode: 'include' }],
157
+ }), vault, 'field-inj', hits, 0);
158
+ // Should not crash or hang
159
+ expect(result.email).toBeDefined();
160
+ });
161
+ });
162
+ // ═══════════════════════════════════════════════════════
163
+ // SECURITY: Token Collision Prevention
164
+ // ═══════════════════════════════════════════════════════
165
+ describe('Token collision prevention', () => {
166
+ let vault;
167
+ beforeEach(() => { vault = new vault_1.MemoryVault(); });
168
+ test('generated tokens are not re-redacted by subsequent patterns', () => {
169
+ vault.getOrCreateSession('collision-1', 0);
170
+ const hits = [];
171
+ const result = (0, engine_1.redactValue)({ text: 'Email: test@example.com and SSN: 123-45-6789' }, createContext(), vault, 'collision-1', hits, 0);
172
+ // Tokens should be clean [LABEL_N] format, not double-redacted
173
+ expect(result.text).toMatch(/\[EMAIL_\d+\]/);
174
+ expect(result.text).toMatch(/\[SSN_\d+\]/);
175
+ // Should NOT contain nested tokens like [[EMAIL_0]]
176
+ expect(result.text).not.toContain('[[');
177
+ });
178
+ });