@studio-foundation/anonymizer 0.3.0-beta.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.
- package/ARCHITECTURE.md +40 -0
- package/LICENSE +663 -0
- package/README.md +90 -0
- package/dist/detector.d.ts +7 -0
- package/dist/detector.d.ts.map +1 -0
- package/dist/detector.js +92 -0
- package/dist/detector.js.map +1 -0
- package/dist/index.d.ts +15 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +50 -0
- package/dist/index.js.map +1 -0
- package/dist/tokenizer.d.ts +17 -0
- package/dist/tokenizer.d.ts.map +1 -0
- package/dist/tokenizer.js +60 -0
- package/dist/tokenizer.js.map +1 -0
- package/dist/types.d.ts +16 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +3 -0
- package/dist/types.js.map +1 -0
- package/package.json +28 -0
- package/src/detector.ts +97 -0
- package/src/index.ts +52 -0
- package/src/tokenizer.ts +63 -0
- package/src/types.ts +24 -0
- package/tests/anonymizer.test.ts +88 -0
- package/tests/detector.test.ts +47 -0
- package/tests/tokenizer.test.ts +55 -0
- package/tsconfig.json +20 -0
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import { anonymize, deanonymize } from '../src/index.js';
|
|
3
|
+
|
|
4
|
+
describe('anonymize', () => {
|
|
5
|
+
it('replaces emails with tokens', () => {
|
|
6
|
+
const result = anonymize('Send to mc@acme.com please');
|
|
7
|
+
expect(result.text).not.toContain('mc@acme.com');
|
|
8
|
+
expect(result.text).toContain('EMAIL_1');
|
|
9
|
+
expect(result.keymap['EMAIL_1']).toBe('mc@acme.com');
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
it('replaces the same PII twice in one call with the same token', () => {
|
|
13
|
+
const result = anonymize('Email mc@acme.com then mc@acme.com again');
|
|
14
|
+
const emails = result.text.match(/EMAIL_\d+/g) ?? [];
|
|
15
|
+
expect(emails.every(e => e === 'EMAIL_1')).toBe(true);
|
|
16
|
+
expect(result.text.split('EMAIL_1').length - 1).toBe(2);
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
it('assigns different tokens to different PII of same category', () => {
|
|
20
|
+
const result = anonymize('Email mc@acme.com and other@example.com');
|
|
21
|
+
expect(result.keymap['EMAIL_1']).toBeDefined();
|
|
22
|
+
expect(result.keymap['EMAIL_2']).toBeDefined();
|
|
23
|
+
expect(result.keymap['EMAIL_1']).not.toBe(result.keymap['EMAIL_2']);
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
it('handles multiple categories', () => {
|
|
27
|
+
const result = anonymize('Email: mc@acme.com, Phone: 514-555-1234');
|
|
28
|
+
expect(Object.keys(result.keymap).length).toBeGreaterThanOrEqual(2);
|
|
29
|
+
expect(result.text).not.toContain('mc@acme.com');
|
|
30
|
+
expect(result.text).not.toContain('514-555-1234');
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
it('returns unchanged text when no PII found', () => {
|
|
34
|
+
const result = anonymize('This text has no sensitive data');
|
|
35
|
+
expect(result.text).toBe('This text has no sensitive data');
|
|
36
|
+
expect(result.keymap).toEqual({});
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it('seedKeymap ensures cross-call consistency', () => {
|
|
40
|
+
// First call establishes EMAIL_1 for mc@acme.com
|
|
41
|
+
const first = anonymize('mc@acme.com');
|
|
42
|
+
// Second call with the keymap as seed
|
|
43
|
+
const second = anonymize('other@example.com and mc@acme.com', {
|
|
44
|
+
seedKeymap: first.keymap,
|
|
45
|
+
});
|
|
46
|
+
// mc@acme.com should still be EMAIL_1 (from seed)
|
|
47
|
+
expect(second.keymap['EMAIL_1']).toBe('mc@acme.com');
|
|
48
|
+
// other@example.com gets EMAIL_2
|
|
49
|
+
expect(second.keymap['EMAIL_2']).toBe('other@example.com');
|
|
50
|
+
});
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
describe('deanonymize', () => {
|
|
54
|
+
it('restores original values from keymap', () => {
|
|
55
|
+
const { text, keymap } = anonymize('Send to mc@acme.com please');
|
|
56
|
+
const restored = deanonymize(text, keymap);
|
|
57
|
+
expect(restored).toBe('Send to mc@acme.com please');
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it('leaves unknown tokens unchanged', () => {
|
|
61
|
+
const result = deanonymize('Hello PERSON_99', {});
|
|
62
|
+
expect(result).toBe('Hello PERSON_99');
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it('restores multiple tokens', () => {
|
|
66
|
+
const keymap = { 'EMAIL_1': 'a@b.com', 'PERSON_1': 'Alice' };
|
|
67
|
+
const restored = deanonymize('EMAIL_1 sent by PERSON_1', keymap);
|
|
68
|
+
expect(restored).toBe('a@b.com sent by Alice');
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
it('round-trip: anonymize then deanonymize restores original', () => {
|
|
72
|
+
const original = 'Email: mc@acme.com, Phone: 514-555-1234';
|
|
73
|
+
const { text, keymap } = anonymize(original);
|
|
74
|
+
const restored = deanonymize(text, keymap);
|
|
75
|
+
expect(restored).toBe(original);
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
it('deanonymizes correctly when there are more than 9 PII values of same category', () => {
|
|
79
|
+
// Build a keymap with EMAIL_1 through EMAIL_11
|
|
80
|
+
const keymap: Record<string, string> = {};
|
|
81
|
+
for (let i = 1; i <= 11; i++) {
|
|
82
|
+
keymap[`EMAIL_${i}`] = `user${i}@example.com`;
|
|
83
|
+
}
|
|
84
|
+
const text = 'EMAIL_1 EMAIL_10 EMAIL_11 EMAIL_2';
|
|
85
|
+
const restored = deanonymize(text, keymap);
|
|
86
|
+
expect(restored).toBe('user1@example.com user10@example.com user11@example.com user2@example.com');
|
|
87
|
+
});
|
|
88
|
+
});
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import { detectPII } from '../src/detector.js';
|
|
3
|
+
|
|
4
|
+
describe('detectPII', () => {
|
|
5
|
+
it('detects email addresses', () => {
|
|
6
|
+
const spans = detectPII('Contact mc@acme.com for info');
|
|
7
|
+
const emails = spans.filter(s => s.category === 'email');
|
|
8
|
+
expect(emails.length).toBeGreaterThan(0);
|
|
9
|
+
expect(emails[0].value).toBe('mc@acme.com');
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
it('detects phone numbers', () => {
|
|
13
|
+
const spans = detectPII('Call 514-555-1234 now');
|
|
14
|
+
const phones = spans.filter(s => s.category === 'phone');
|
|
15
|
+
expect(phones.length).toBeGreaterThan(0);
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
it('detects credit card numbers', () => {
|
|
19
|
+
const spans = detectPII('Card number: 4111111111111111');
|
|
20
|
+
const cards = spans.filter(s => s.category === 'credit_card');
|
|
21
|
+
expect(cards.length).toBeGreaterThan(0);
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
it('returns empty array for clean text', () => {
|
|
25
|
+
const spans = detectPII('This is a normal sentence without PII');
|
|
26
|
+
expect(spans).toEqual([]);
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
it('returns spans with correct position bounds', () => {
|
|
30
|
+
const text = 'Email: mc@acme.com here';
|
|
31
|
+
const spans = detectPII(text);
|
|
32
|
+
const email = spans.find(s => s.category === 'email');
|
|
33
|
+
expect(email).toBeDefined();
|
|
34
|
+
expect(text.slice(email!.start, email!.end)).toBe(email!.value);
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it('does not return overlapping spans', () => {
|
|
38
|
+
const text = 'Email mc@acme.com and phone 514-555-1234';
|
|
39
|
+
const spans = detectPII(text);
|
|
40
|
+
// Verify no two spans overlap
|
|
41
|
+
for (let i = 0; i < spans.length; i++) {
|
|
42
|
+
for (let j = i + 1; j < spans.length; j++) {
|
|
43
|
+
expect(spans[i].end <= spans[j].start || spans[j].end <= spans[i].start).toBe(true);
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
});
|
|
47
|
+
});
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach } from 'vitest';
|
|
2
|
+
import { Tokenizer } from '../src/tokenizer.js';
|
|
3
|
+
|
|
4
|
+
describe('Tokenizer', () => {
|
|
5
|
+
let tokenizer: Tokenizer;
|
|
6
|
+
|
|
7
|
+
beforeEach(() => {
|
|
8
|
+
tokenizer = new Tokenizer();
|
|
9
|
+
});
|
|
10
|
+
|
|
11
|
+
it('assigns sequential tokens per category', () => {
|
|
12
|
+
const t1 = tokenizer.tokenize('Marie-Claire', 'person');
|
|
13
|
+
const t2 = tokenizer.tokenize('Jean-François', 'person');
|
|
14
|
+
const t3 = tokenizer.tokenize('mc@acme.com', 'email');
|
|
15
|
+
expect(t1).toBe('PERSON_1');
|
|
16
|
+
expect(t2).toBe('PERSON_2');
|
|
17
|
+
expect(t3).toBe('EMAIL_1');
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
it('returns the same token for the same value', () => {
|
|
21
|
+
const t1 = tokenizer.tokenize('Marie-Claire', 'person');
|
|
22
|
+
const t2 = tokenizer.tokenize('Marie-Claire', 'person');
|
|
23
|
+
expect(t1).toBe(t2);
|
|
24
|
+
expect(t1).toBe('PERSON_1');
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
it('builds keymap correctly', () => {
|
|
28
|
+
tokenizer.tokenize('Marie-Claire', 'person');
|
|
29
|
+
tokenizer.tokenize('mc@acme.com', 'email');
|
|
30
|
+
const keymap = tokenizer.getKeymap();
|
|
31
|
+
expect(keymap).toEqual({
|
|
32
|
+
'PERSON_1': 'Marie-Claire',
|
|
33
|
+
'EMAIL_1': 'mc@acme.com',
|
|
34
|
+
});
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it('handles all supported categories', () => {
|
|
38
|
+
const categories = ['person', 'email', 'phone', 'address', 'ssn', 'credit_card'] as const;
|
|
39
|
+
for (const cat of categories) {
|
|
40
|
+
const token = tokenizer.tokenize('test-value', cat);
|
|
41
|
+
expect(token).toMatch(/^[A-Z_]+_1$/);
|
|
42
|
+
}
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it('loadKeymap restores state for cross-call consistency', () => {
|
|
46
|
+
// Simulate loading a pre-existing keymap
|
|
47
|
+
tokenizer.loadKeymap({ 'PERSON_1': 'Alice', 'EMAIL_1': 'alice@example.com' });
|
|
48
|
+
// New tokenization should continue from counter 1
|
|
49
|
+
const t = tokenizer.tokenize('Bob', 'person');
|
|
50
|
+
expect(t).toBe('PERSON_2');
|
|
51
|
+
// Existing value should reuse its token
|
|
52
|
+
const tAlice = tokenizer.tokenize('Alice', 'person');
|
|
53
|
+
expect(tAlice).toBe('PERSON_1');
|
|
54
|
+
});
|
|
55
|
+
});
|
package/tsconfig.json
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
{
|
|
2
|
+
"compilerOptions": {
|
|
3
|
+
"target": "ES2022",
|
|
4
|
+
"module": "commonjs",
|
|
5
|
+
"lib": ["ES2022"],
|
|
6
|
+
"outDir": "./dist",
|
|
7
|
+
"rootDir": "./src",
|
|
8
|
+
"declaration": true,
|
|
9
|
+
"declarationMap": true,
|
|
10
|
+
"sourceMap": true,
|
|
11
|
+
"strict": true,
|
|
12
|
+
"esModuleInterop": true,
|
|
13
|
+
"skipLibCheck": true,
|
|
14
|
+
"forceConsistentCasingInFileNames": true,
|
|
15
|
+
"resolveJsonModule": true,
|
|
16
|
+
"moduleResolution": "node"
|
|
17
|
+
},
|
|
18
|
+
"include": ["src/**/*"],
|
|
19
|
+
"exclude": ["node_modules", "dist", "tests"]
|
|
20
|
+
}
|