n8n-nodes-redactor 3.0.1

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.
Files changed (61) hide show
  1. package/LICENSE +42 -0
  2. package/README.dev.md +153 -0
  3. package/README.md +443 -0
  4. package/README.npm.md +443 -0
  5. package/dist/nodes/PiiRedactor/PiiRedactor.node.d.ts +5 -0
  6. package/dist/nodes/PiiRedactor/PiiRedactor.node.js +1093 -0
  7. package/dist/nodes/PiiRedactor/__tests__/encryption.test.d.ts +1 -0
  8. package/dist/nodes/PiiRedactor/__tests__/encryption.test.js +200 -0
  9. package/dist/nodes/PiiRedactor/__tests__/engine.test.d.ts +1 -0
  10. package/dist/nodes/PiiRedactor/__tests__/engine.test.js +524 -0
  11. package/dist/nodes/PiiRedactor/__tests__/operations.test.d.ts +1 -0
  12. package/dist/nodes/PiiRedactor/__tests__/operations.test.js +316 -0
  13. package/dist/nodes/PiiRedactor/__tests__/patterns-global.test.d.ts +1 -0
  14. package/dist/nodes/PiiRedactor/__tests__/patterns-global.test.js +427 -0
  15. package/dist/nodes/PiiRedactor/__tests__/patterns.test.d.ts +1 -0
  16. package/dist/nodes/PiiRedactor/__tests__/patterns.test.js +481 -0
  17. package/dist/nodes/PiiRedactor/__tests__/phase1.test.d.ts +1 -0
  18. package/dist/nodes/PiiRedactor/__tests__/phase1.test.js +343 -0
  19. package/dist/nodes/PiiRedactor/__tests__/phase3.test.d.ts +1 -0
  20. package/dist/nodes/PiiRedactor/__tests__/phase3.test.js +275 -0
  21. package/dist/nodes/PiiRedactor/__tests__/phase4.test.d.ts +1 -0
  22. package/dist/nodes/PiiRedactor/__tests__/phase4.test.js +184 -0
  23. package/dist/nodes/PiiRedactor/__tests__/presidio.test.d.ts +1 -0
  24. package/dist/nodes/PiiRedactor/__tests__/presidio.test.js +170 -0
  25. package/dist/nodes/PiiRedactor/__tests__/security.test.d.ts +1 -0
  26. package/dist/nodes/PiiRedactor/__tests__/security.test.js +178 -0
  27. package/dist/nodes/PiiRedactor/__tests__/semantic.test.d.ts +1 -0
  28. package/dist/nodes/PiiRedactor/__tests__/semantic.test.js +319 -0
  29. package/dist/nodes/PiiRedactor/__tests__/vault.test.d.ts +1 -0
  30. package/dist/nodes/PiiRedactor/__tests__/vault.test.js +247 -0
  31. package/dist/nodes/PiiRedactor/audit.d.ts +48 -0
  32. package/dist/nodes/PiiRedactor/audit.js +192 -0
  33. package/dist/nodes/PiiRedactor/classification.d.ts +33 -0
  34. package/dist/nodes/PiiRedactor/classification.js +118 -0
  35. package/dist/nodes/PiiRedactor/context.d.ts +57 -0
  36. package/dist/nodes/PiiRedactor/context.js +260 -0
  37. package/dist/nodes/PiiRedactor/encryption.d.ts +45 -0
  38. package/dist/nodes/PiiRedactor/encryption.js +158 -0
  39. package/dist/nodes/PiiRedactor/engine.d.ts +23 -0
  40. package/dist/nodes/PiiRedactor/engine.js +888 -0
  41. package/dist/nodes/PiiRedactor/injection.d.ts +46 -0
  42. package/dist/nodes/PiiRedactor/injection.js +425 -0
  43. package/dist/nodes/PiiRedactor/names.d.ts +25 -0
  44. package/dist/nodes/PiiRedactor/names.js +188 -0
  45. package/dist/nodes/PiiRedactor/patterns.d.ts +17 -0
  46. package/dist/nodes/PiiRedactor/patterns.js +1742 -0
  47. package/dist/nodes/PiiRedactor/presidio.d.ts +77 -0
  48. package/dist/nodes/PiiRedactor/presidio.js +264 -0
  49. package/dist/nodes/PiiRedactor/profiles.d.ts +47 -0
  50. package/dist/nodes/PiiRedactor/profiles.js +139 -0
  51. package/dist/nodes/PiiRedactor/pseudonymize.d.ts +20 -0
  52. package/dist/nodes/PiiRedactor/pseudonymize.js +203 -0
  53. package/dist/nodes/PiiRedactor/redact.png +0 -0
  54. package/dist/nodes/PiiRedactor/redact.svg +3 -0
  55. package/dist/nodes/PiiRedactor/ropa.d.ts +63 -0
  56. package/dist/nodes/PiiRedactor/ropa.js +70 -0
  57. package/dist/nodes/PiiRedactor/types.d.ts +82 -0
  58. package/dist/nodes/PiiRedactor/types.js +3 -0
  59. package/dist/nodes/PiiRedactor/vault.d.ts +61 -0
  60. package/dist/nodes/PiiRedactor/vault.js +352 -0
  61. package/package.json +87 -0
@@ -0,0 +1,184 @@
1
+ "use strict";
2
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
3
+ if (k2 === undefined) k2 = k;
4
+ var desc = Object.getOwnPropertyDescriptor(m, k);
5
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
6
+ desc = { enumerable: true, get: function() { return m[k]; } };
7
+ }
8
+ Object.defineProperty(o, k2, desc);
9
+ }) : (function(o, m, k, k2) {
10
+ if (k2 === undefined) k2 = k;
11
+ o[k2] = m[k];
12
+ }));
13
+ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
14
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
15
+ }) : function(o, v) {
16
+ o["default"] = v;
17
+ });
18
+ var __importStar = (this && this.__importStar) || (function () {
19
+ var ownKeys = function(o) {
20
+ ownKeys = Object.getOwnPropertyNames || function (o) {
21
+ var ar = [];
22
+ for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
23
+ return ar;
24
+ };
25
+ return ownKeys(o);
26
+ };
27
+ return function (mod) {
28
+ if (mod && mod.__esModule) return mod;
29
+ var result = {};
30
+ if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
31
+ __setModuleDefault(result, mod);
32
+ return result;
33
+ };
34
+ })();
35
+ Object.defineProperty(exports, "__esModule", { value: true });
36
+ const fs = __importStar(require("fs"));
37
+ const os = __importStar(require("os"));
38
+ const path = __importStar(require("path"));
39
+ const profiles_1 = require("../profiles");
40
+ const ropa_1 = require("../ropa");
41
+ const classification_1 = require("../classification");
42
+ // ═══════════════════════════════════════════════════════
43
+ // 4.1 PER-TENANT PROFILES
44
+ // ═══════════════════════════════════════════════════════
45
+ describe('Per-Tenant Profiles', () => {
46
+ let testDir;
47
+ beforeEach(() => {
48
+ testDir = fs.mkdtempSync(path.join(os.tmpdir(), 'pii-profiles-test-'));
49
+ });
50
+ afterEach(() => {
51
+ try {
52
+ fs.rmSync(testDir, { recursive: true, force: true });
53
+ }
54
+ catch { /* ignore */ }
55
+ });
56
+ test('save and load profile', () => {
57
+ const profile = {
58
+ profileId: 'acme-corp',
59
+ displayName: 'Acme Corporation',
60
+ enabledPatterns: ['email', 'phone', 'ssn'],
61
+ redactionMode: 'token',
62
+ confidenceThreshold: 0.7,
63
+ allowList: [{ value: '@acme.com', type: 'contains' }],
64
+ denyList: [{ value: 'Project Falcon', type: 'exact' }],
65
+ region: 'EU',
66
+ auditLog: true,
67
+ createdAt: new Date().toISOString(),
68
+ updatedAt: new Date().toISOString(),
69
+ };
70
+ expect((0, profiles_1.saveProfile)(profile, testDir)).toBe(true);
71
+ const loaded = (0, profiles_1.loadProfile)('acme-corp', testDir);
72
+ expect(loaded).not.toBeNull();
73
+ expect(loaded.displayName).toBe('Acme Corporation');
74
+ expect(loaded.enabledPatterns).toEqual(['email', 'phone', 'ssn']);
75
+ expect(loaded.confidenceThreshold).toBe(0.7);
76
+ expect(loaded.allowList[0].value).toBe('@acme.com');
77
+ });
78
+ test('load nonexistent profile returns null', () => {
79
+ expect((0, profiles_1.loadProfile)('nonexistent', testDir)).toBeNull();
80
+ });
81
+ test('load empty profileId returns null', () => {
82
+ expect((0, profiles_1.loadProfile)('', testDir)).toBeNull();
83
+ });
84
+ test('list profiles', () => {
85
+ (0, profiles_1.saveProfile)({ profileId: 'tenant-a', displayName: 'Tenant A', createdAt: '', updatedAt: '' }, testDir);
86
+ (0, profiles_1.saveProfile)({ profileId: 'tenant-b', displayName: 'Tenant B', createdAt: '', updatedAt: '' }, testDir);
87
+ const profiles = (0, profiles_1.listProfiles)(testDir);
88
+ expect(profiles).toHaveLength(2);
89
+ expect(profiles.map((p) => p.profileId).sort()).toEqual(['tenant-a', 'tenant-b']);
90
+ });
91
+ test('delete profile', () => {
92
+ (0, profiles_1.saveProfile)({ profileId: 'delete-me', displayName: 'Delete Me', createdAt: '', updatedAt: '' }, testDir);
93
+ expect((0, profiles_1.loadProfile)('delete-me', testDir)).not.toBeNull();
94
+ expect((0, profiles_1.deleteProfile)('delete-me', testDir)).toBe(true);
95
+ expect((0, profiles_1.loadProfile)('delete-me', testDir)).toBeNull();
96
+ });
97
+ test('delete nonexistent profile returns false', () => {
98
+ expect((0, profiles_1.deleteProfile)('nonexistent', testDir)).toBe(false);
99
+ });
100
+ test('profileId is sanitized against path traversal', () => {
101
+ const result = (0, profiles_1.saveProfile)({
102
+ profileId: '../../../etc/passwd',
103
+ displayName: 'Evil',
104
+ createdAt: '',
105
+ updatedAt: '',
106
+ }, testDir);
107
+ // Should save with sanitized name, not escape the directory
108
+ const files = fs.readdirSync(testDir);
109
+ expect(files.every((f) => !f.includes('..'))).toBe(true);
110
+ });
111
+ test('list profiles from empty directory', () => {
112
+ expect((0, profiles_1.listProfiles)(testDir)).toEqual([]);
113
+ });
114
+ test('list profiles from nonexistent directory', () => {
115
+ expect((0, profiles_1.listProfiles)('/tmp/nonexistent-' + Date.now())).toEqual([]);
116
+ });
117
+ });
118
+ // ═══════════════════════════════════════════════════════
119
+ // 4.4 GDPR ART.30 ROPA REPORT
120
+ // ═══════════════════════════════════════════════════════
121
+ describe('GDPR Art.30 ROPA Report', () => {
122
+ test('generates complete ROPA record', () => {
123
+ const hits = [
124
+ { token: '[E_0]', original: '***', patternName: 'email', patternLabel: 'EMAIL', category: 'contact', field: 'email', itemIndex: 0, confidence: 0.90 },
125
+ { token: '[S_1]', original: '***', patternName: 'ssn', patternLabel: 'SSN', category: 'identity', field: 'ssn', itemIndex: 0, confidence: 0.85 },
126
+ { token: '[C_2]', original: '***', patternName: 'creditCard', patternLabel: 'CREDIT_CARD', category: 'financial', field: 'card', itemIndex: 0, confidence: 0.95 },
127
+ ];
128
+ const classification = (0, classification_1.classifyData)(hits);
129
+ const ropa = (0, ropa_1.generateRopaRecord)(hits, classification, 100, 90, 'token', 60, true, true, { name: 'Acme Corp', contactEmail: 'dpo@acme.com', dpo: 'John DPO' }, ['PII redaction before LLM processing'], 'Art.6(1)(f) Legitimate interest', ['customers', 'employees'], ['LLM provider (OpenAI)'], [{ country: 'US', organization: 'OpenAI', safeguards: 'Standard Contractual Clauses' }]);
130
+ // Art.30(1)(a) - Controller
131
+ expect(ropa.controller.name).toBe('Acme Corp');
132
+ expect(ropa.controller.dpo).toBe('John DPO');
133
+ // Art.30(1)(b) - Purposes
134
+ expect(ropa.processingPurposes).toContain('PII redaction before LLM processing');
135
+ expect(ropa.legalBasis).toBe('Art.6(1)(f) Legitimate interest');
136
+ // Art.30(1)(c) - Data subjects
137
+ expect(ropa.dataSubjectCategories).toContain('customers');
138
+ // Art.30(1)(d) - Personal data categories
139
+ expect(ropa.personalDataCategories.length).toBeGreaterThanOrEqual(3);
140
+ const contactCat = ropa.personalDataCategories.find((c) => c.category === 'contact');
141
+ expect(contactCat).toBeDefined();
142
+ expect(contactCat.patternLabels).toContain('EMAIL');
143
+ // Art.30(1)(e) - Recipients
144
+ expect(ropa.recipientCategories).toContain('LLM provider (OpenAI)');
145
+ // Art.30(1)(f) - Transfers
146
+ expect(ropa.thirdCountryTransfers).toHaveLength(1);
147
+ expect(ropa.thirdCountryTransfers[0].country).toBe('US');
148
+ // Art.30(1)(g) - Retention
149
+ expect(ropa.retentionPolicy.vaultTtlMinutes).toBe(60);
150
+ // Art.30(1)(h) - Security measures
151
+ expect(ropa.securityMeasures.encryptionAtRest).toBe(true);
152
+ expect(ropa.securityMeasures.encryptionAlgorithm).toBe('AES-256-GCM');
153
+ expect(ropa.securityMeasures.auditLogging).toBe(true);
154
+ // Processing summary
155
+ expect(ropa.processingSummary.totalItemsProcessed).toBe(100);
156
+ expect(ropa.processingSummary.totalPiiDetected).toBe(3);
157
+ expect(ropa.processingSummary.classificationLevel).toBe('RESTRICTED');
158
+ // Meta
159
+ expect(ropa.version).toBe('1.0');
160
+ expect(ropa.generatedBy).toBe('n8n-nodes-redactor');
161
+ expect(ropa.recordId).toBeDefined();
162
+ expect(ropa.generatedAt).toBeDefined();
163
+ });
164
+ test('ROPA with no PII detected', () => {
165
+ const classification = (0, classification_1.classifyData)([]);
166
+ const ropa = (0, ropa_1.generateRopaRecord)([], classification, 50, 90, 'token', 60, false, false, { name: 'Test Corp', contactEmail: 'test@test.com' }, ['Data analysis'], 'Art.6(1)(f)', ['users'], [], []);
167
+ expect(ropa.personalDataCategories).toHaveLength(0);
168
+ expect(ropa.processingSummary.totalPiiDetected).toBe(0);
169
+ expect(ropa.processingSummary.classificationLevel).toBe('PUBLIC');
170
+ expect(ropa.securityMeasures.encryptionAtRest).toBe(false);
171
+ });
172
+ test('ROPA report does not contain original PII values', () => {
173
+ const hits = [
174
+ { token: '[E_0]', original: '***', patternName: 'email', patternLabel: 'EMAIL', category: 'contact', field: 'email', itemIndex: 0, confidence: 0.90 },
175
+ ];
176
+ const classification = (0, classification_1.classifyData)(hits);
177
+ const ropa = (0, ropa_1.generateRopaRecord)(hits, classification, 1, 90, 'token', 60, false, false, { name: 'Test', contactEmail: 'test@test.com' }, ['test'], 'Art.6(1)(f)', [], [], []);
178
+ const json = JSON.stringify(ropa);
179
+ // Original values should never appear (they're already '***' in hits)
180
+ expect(json).not.toContain('original');
181
+ // Pattern labels are fine (they describe TYPES not VALUES)
182
+ expect(json).toContain('EMAIL');
183
+ });
184
+ });
@@ -0,0 +1,170 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ const presidio_1 = require("../presidio");
4
+ const vault_1 = require("../vault");
5
+ const engine_1 = require("../engine");
6
+ // ═══════════════════════════════════════════════════════
7
+ // PRESIDIO CLIENT TESTS
8
+ // ═══════════════════════════════════════════════════════
9
+ describe('Presidio Client', () => {
10
+ test('checkPresidioHealth returns false when unreachable', async () => {
11
+ const result = await (0, presidio_1.checkPresidioHealth)('http://localhost:99999');
12
+ expect(result).toBe(false);
13
+ });
14
+ test('analyzeWithPresidio returns empty array when unreachable', async () => {
15
+ const result = await (0, presidio_1.analyzeWithPresidio)('http://localhost:99999', 'John Smith called', 'en', 0.4, 2000);
16
+ expect(result).toEqual([]);
17
+ });
18
+ test('analyzeWithPresidio returns empty for empty text', async () => {
19
+ const result = await (0, presidio_1.analyzeWithPresidio)('http://localhost:5002', '', 'en');
20
+ expect(result).toEqual([]);
21
+ });
22
+ test('analyzeWithPresidio returns empty for whitespace text', async () => {
23
+ const result = await (0, presidio_1.analyzeWithPresidio)('http://localhost:5002', ' ', 'en');
24
+ expect(result).toEqual([]);
25
+ });
26
+ test('getPresidioEntities returns empty when unreachable', async () => {
27
+ const result = await (0, presidio_1.getPresidioEntities)('http://localhost:99999');
28
+ expect(result).toEqual([]);
29
+ });
30
+ });
31
+ // ═══════════════════════════════════════════════════════
32
+ // ENTITY TYPE MAPPING TESTS
33
+ // ═══════════════════════════════════════════════════════
34
+ describe('Presidio Entity Mapping', () => {
35
+ test('maps PERSON to PERSON_NAME', () => {
36
+ expect((0, presidio_1.getRedactorLabel)('PERSON')).toBe('PERSON_NAME');
37
+ });
38
+ test('maps EMAIL_ADDRESS to EMAIL', () => {
39
+ expect((0, presidio_1.getRedactorLabel)('EMAIL_ADDRESS')).toBe('EMAIL');
40
+ });
41
+ test('maps PHONE_NUMBER to PHONE', () => {
42
+ expect((0, presidio_1.getRedactorLabel)('PHONE_NUMBER')).toBe('PHONE');
43
+ });
44
+ test('maps CREDIT_CARD to CREDIT_CARD', () => {
45
+ expect((0, presidio_1.getRedactorLabel)('CREDIT_CARD')).toBe('CREDIT_CARD');
46
+ });
47
+ test('maps LOCATION to ADDRESS', () => {
48
+ expect((0, presidio_1.getRedactorLabel)('LOCATION')).toBe('ADDRESS');
49
+ });
50
+ test('maps ORGANIZATION to COMPANY', () => {
51
+ expect((0, presidio_1.getRedactorLabel)('ORGANIZATION')).toBe('COMPANY');
52
+ });
53
+ test('maps US_SSN to SSN', () => {
54
+ expect((0, presidio_1.getRedactorLabel)('US_SSN')).toBe('SSN');
55
+ });
56
+ test('maps IBAN_CODE to IBAN', () => {
57
+ expect((0, presidio_1.getRedactorLabel)('IBAN_CODE')).toBe('IBAN');
58
+ });
59
+ test('unknown entity type passes through as-is', () => {
60
+ expect((0, presidio_1.getRedactorLabel)('UNKNOWN_TYPE')).toBe('UNKNOWN_TYPE');
61
+ });
62
+ test('category mapping works for PERSON', () => {
63
+ expect((0, presidio_1.getRedactorCategory)('PERSON')).toBe('identity');
64
+ });
65
+ test('category mapping works for CREDIT_CARD', () => {
66
+ expect((0, presidio_1.getRedactorCategory)('CREDIT_CARD')).toBe('financial');
67
+ });
68
+ test('category mapping works for IP_ADDRESS', () => {
69
+ expect((0, presidio_1.getRedactorCategory)('IP_ADDRESS')).toBe('network');
70
+ });
71
+ test('unknown category defaults to identity', () => {
72
+ expect((0, presidio_1.getRedactorCategory)('UNKNOWN')).toBe('identity');
73
+ });
74
+ });
75
+ // ═══════════════════════════════════════════════════════
76
+ // ENHANCE WITH PRESIDIO TESTS (graceful fallback)
77
+ // ═══════════════════════════════════════════════════════
78
+ describe('enhanceWithPresidio', () => {
79
+ let vault;
80
+ beforeEach(() => {
81
+ vault = new vault_1.MemoryVault();
82
+ });
83
+ test('returns data unchanged when presidioUrl is not set', async () => {
84
+ vault.getOrCreateSession('p1', 0);
85
+ const hits = [];
86
+ const data = { name: 'John Smith', email: 'john@test.com' };
87
+ const result = await (0, engine_1.enhanceWithPresidio)(data, { enabledPatterns: [], customPatterns: [], mode: 'token', dedup: true, fieldRules: [], fieldMode: 'all' }, vault, 'p1', hits, 0);
88
+ expect(result).toEqual(data);
89
+ expect(hits).toHaveLength(0);
90
+ });
91
+ test('returns data unchanged when Presidio is unreachable', async () => {
92
+ vault.getOrCreateSession('p2', 0);
93
+ const hits = [];
94
+ const data = { notes: 'Spoke with Sarah Johnson about billing' };
95
+ const result = await (0, engine_1.enhanceWithPresidio)(data, {
96
+ enabledPatterns: [], customPatterns: [], mode: 'token', dedup: true,
97
+ fieldRules: [], fieldMode: 'all',
98
+ presidioUrl: 'http://localhost:99999',
99
+ presidioLanguage: 'en',
100
+ }, vault, 'p2', hits, 0);
101
+ // Should not crash, returns original data
102
+ expect(result.notes).toBe('Spoke with Sarah Johnson about billing');
103
+ expect(hits).toHaveLength(0);
104
+ });
105
+ test('skips already-redacted tokens', async () => {
106
+ vault.getOrCreateSession('p3', 0);
107
+ const hits = [];
108
+ const data = { name: '[PERSON_NAME_0]', email: '[EMAIL_1]' };
109
+ const result = await (0, engine_1.enhanceWithPresidio)(data, {
110
+ enabledPatterns: [], customPatterns: [], mode: 'token', dedup: true,
111
+ fieldRules: [], fieldMode: 'all',
112
+ presidioUrl: 'http://localhost:99999',
113
+ }, vault, 'p3', hits, 0);
114
+ // Tokens should not be modified
115
+ expect(result.name).toBe('[PERSON_NAME_0]');
116
+ expect(result.email).toBe('[EMAIL_1]');
117
+ });
118
+ test('skips empty and whitespace strings', async () => {
119
+ vault.getOrCreateSession('p4', 0);
120
+ const hits = [];
121
+ const data = { empty: '', whitespace: ' ', valid: 'some text' };
122
+ const result = await (0, engine_1.enhanceWithPresidio)(data, {
123
+ enabledPatterns: [], customPatterns: [], mode: 'token', dedup: true,
124
+ fieldRules: [], fieldMode: 'all',
125
+ presidioUrl: 'http://localhost:99999',
126
+ }, vault, 'p4', hits, 0);
127
+ expect(result.empty).toBe('');
128
+ expect(result.whitespace).toBe(' ');
129
+ });
130
+ test('respects allow list', async () => {
131
+ vault.getOrCreateSession('p5', 0);
132
+ const hits = [];
133
+ const data = { text: 'Contact Support Team at support@acme.com' };
134
+ const result = await (0, engine_1.enhanceWithPresidio)(data, {
135
+ enabledPatterns: [], customPatterns: [], mode: 'token', dedup: true,
136
+ fieldRules: [], fieldMode: 'all',
137
+ presidioUrl: 'http://localhost:99999',
138
+ allowList: [{ value: 'Support Team', type: 'exact' }],
139
+ }, vault, 'p5', hits, 0);
140
+ // Even if Presidio found "Support Team", allow list would skip it
141
+ expect(result.text).toContain('Support Team');
142
+ });
143
+ test('handles non-string values gracefully', async () => {
144
+ vault.getOrCreateSession('p6', 0);
145
+ const hits = [];
146
+ const data = { count: 42, active: true };
147
+ const result = await (0, engine_1.enhanceWithPresidio)(data, {
148
+ enabledPatterns: [], customPatterns: [], mode: 'token', dedup: true,
149
+ fieldRules: [], fieldMode: 'all',
150
+ presidioUrl: 'http://localhost:99999',
151
+ }, vault, 'p6', hits, 0);
152
+ // Non-strings should pass through unchanged
153
+ expect(result.count).toBe(42);
154
+ expect(result.active).toBe(true);
155
+ });
156
+ });
157
+ // ═══════════════════════════════════════════════════════
158
+ // DOCKER SETUP DOCUMENTATION TEST
159
+ // ═══════════════════════════════════════════════════════
160
+ describe('Presidio Docker Setup', () => {
161
+ test('docker command is documented correctly', () => {
162
+ // Verify the Docker image reference is correct
163
+ const dockerImage = 'mcr.microsoft.com/presidio-analyzer';
164
+ const defaultPort = 5002;
165
+ const internalPort = 3000;
166
+ expect(dockerImage).toContain('presidio-analyzer');
167
+ expect(defaultPort).toBe(5002);
168
+ expect(internalPort).toBe(3000);
169
+ });
170
+ });
@@ -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
+ });