@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
package/README.md
ADDED
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
# @studio-foundation/anonymizer
|
|
2
|
+
|
|
3
|
+
PII detection and anonymization library. Replaces sensitive data with consistent tokens before sending to LLMs, with a keymap to restore the original values afterward.
|
|
4
|
+
|
|
5
|
+
## Role
|
|
6
|
+
|
|
7
|
+
anonymizer sits at the bottom of the stack — a pure utility with zero `@studio/*` dependencies. The runner wraps it in `AnonymizationMiddleware` and injects it transparently into the agent execution loop.
|
|
8
|
+
|
|
9
|
+
```
|
|
10
|
+
user data → anonymize() → [PERSON_1], [EMAIL_1] → LLM → deanonymize() → original values
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
## Key exports
|
|
14
|
+
|
|
15
|
+
```typescript
|
|
16
|
+
import { anonymize, deanonymize, Tokenizer } from '@studio-foundation/anonymizer';
|
|
17
|
+
import type { PIICategory, PIIDetectionResult, AnonymizerOptions } from '@studio-foundation/anonymizer';
|
|
18
|
+
|
|
19
|
+
// Anonymize a string
|
|
20
|
+
const { text, keymap } = anonymize('Hi Marie, call me at 555-867-5309');
|
|
21
|
+
// text → "Hi [PERSON_1], call me at [PHONE_1]"
|
|
22
|
+
// keymap → { "PERSON_1": "Marie", "PHONE_1": "555-867-5309" }
|
|
23
|
+
|
|
24
|
+
// Restore originals
|
|
25
|
+
const original = deanonymize(text, keymap);
|
|
26
|
+
// → "Hi Marie, call me at 555-867-5309"
|
|
27
|
+
|
|
28
|
+
// Cross-stage consistency — pass the keymap from a previous call
|
|
29
|
+
const { text: text2, keymap: keymap2 } = anonymize(nextChunk, { seedKeymap: keymap });
|
|
30
|
+
// PERSON_1 still maps to "Marie" across calls
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
## PII categories
|
|
34
|
+
|
|
35
|
+
| Category | Token format | What it detects |
|
|
36
|
+
|----------|-------------|-----------------|
|
|
37
|
+
| `person` | `PERSON_N` | Names after salutations (Dear, Hi, Mr., Dr., etc.) — best effort |
|
|
38
|
+
| `email` | `EMAIL_N` | Email addresses |
|
|
39
|
+
| `phone` | `PHONE_N` | US phone numbers (10 digits, various formats) |
|
|
40
|
+
| `ssn` | `SSN_N` | Social security numbers (ddd-dd-dddd) |
|
|
41
|
+
| `credit_card` | `CREDIT_CARD_N` | 16-digit card numbers |
|
|
42
|
+
| `address` | `ADDRESS_N` | Reserved (detection not yet implemented) |
|
|
43
|
+
|
|
44
|
+
## Detection strategy
|
|
45
|
+
|
|
46
|
+
Two-phase detection on each call:
|
|
47
|
+
|
|
48
|
+
1. **Regex (high precision)** — email, phone, SSN, credit card. Structural patterns anchored to avoid false positives (e.g. SSN regex uses strict hyphen format to avoid matching phone fragments).
|
|
49
|
+
|
|
50
|
+
2. **Person names (best effort)** — salutation-gated pattern (`Dear X`, `Hi X`, `Mr. X`, etc.). Only catches explicitly addressed names — not bare occurrences.
|
|
51
|
+
|
|
52
|
+
Spans are non-overlapping. If two patterns match the same range, the first one wins and the position is marked occupied.
|
|
53
|
+
|
|
54
|
+
## Token consistency
|
|
55
|
+
|
|
56
|
+
Same value → same token, within and across calls:
|
|
57
|
+
|
|
58
|
+
```typescript
|
|
59
|
+
// Within one call: two mentions of the same email → same token
|
|
60
|
+
anonymize('Contact foo@bar.com or reach foo@bar.com')
|
|
61
|
+
// → "Contact [EMAIL_1] or reach [EMAIL_1]"
|
|
62
|
+
|
|
63
|
+
// Across calls: seed the next call with the previous keymap
|
|
64
|
+
const { text: t1, keymap: km1 } = anonymize(stage1Output);
|
|
65
|
+
const { text: t2, keymap: km2 } = anonymize(stage2Output, { seedKeymap: km1 });
|
|
66
|
+
// EMAIL_1 is the same person in t1 and t2
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
## Filter by category
|
|
70
|
+
|
|
71
|
+
```typescript
|
|
72
|
+
// Only anonymize emails — leave names and phones unchanged
|
|
73
|
+
const { text } = anonymize(rawText, { categories: ['email'] });
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
## How it's used in Studio
|
|
77
|
+
|
|
78
|
+
The runner wraps this in `AnonymizationMiddleware` (in `runner/src/middleware/anonymization.ts`). When `anonymize: true` is set on an agent or a run:
|
|
79
|
+
|
|
80
|
+
1. Task description is anonymized before being sent to the LLM
|
|
81
|
+
2. Tool results are anonymized before being injected back into context
|
|
82
|
+
3. The accumulated keymap is written to `.studio/runs/anonymization/<run-id>.keymap.json` for post-run inspection
|
|
83
|
+
|
|
84
|
+
The middleware is wired by the engine and passed to `runAgent()` — user code doesn't call `anonymize()` directly.
|
|
85
|
+
|
|
86
|
+
## Rules
|
|
87
|
+
|
|
88
|
+
- **Zero `@studio/*` dependencies.** This package must stay a pure utility.
|
|
89
|
+
- `anonymize()` is stateless — the `Tokenizer` is created fresh each call (or seeded via `seedKeymap`).
|
|
90
|
+
- Person detection is always best-effort and non-fatal — failures are silently skipped.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"detector.d.ts","sourceRoot":"","sources":["../src/detector.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAe,OAAO,EAAE,MAAM,YAAY,CAAC;AA0BvD;;;GAGG;AACH,wBAAgB,SAAS,CAAC,IAAI,EAAE,MAAM,GAAG,OAAO,EAAE,CAyBjD"}
|
package/dist/detector.js
ADDED
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.detectPII = detectPII;
|
|
4
|
+
// Regex patterns for structural PII (high precision)
|
|
5
|
+
const PATTERNS = [
|
|
6
|
+
{
|
|
7
|
+
category: 'email',
|
|
8
|
+
regex: /\b[A-Za-z0-9._%+\-]+@[A-Za-z0-9.\-]+\.[A-Za-z]{2,}\b/g,
|
|
9
|
+
},
|
|
10
|
+
{
|
|
11
|
+
category: 'phone',
|
|
12
|
+
// US phone: must be at least 10 digits. Require optional country code + area code.
|
|
13
|
+
// Anchored to avoid matching partial SSN-like strings.
|
|
14
|
+
regex: /\b(?:\+?1[-.\s]?)?\(?\d{3}\)?[-.\s]\d{3}[-.\s]\d{4}\b/g,
|
|
15
|
+
},
|
|
16
|
+
{
|
|
17
|
+
category: 'ssn',
|
|
18
|
+
// SSN: exactly ddd-dd-dddd with hyphens (strict format, not dots or spaces
|
|
19
|
+
// to avoid collision with phone numbers already consumed)
|
|
20
|
+
regex: /\b\d{3}-\d{2}-\d{4}\b/g,
|
|
21
|
+
},
|
|
22
|
+
{
|
|
23
|
+
category: 'credit_card',
|
|
24
|
+
regex: /\b(?:\d{4}[-\s]?){3}\d{4}\b/g,
|
|
25
|
+
},
|
|
26
|
+
];
|
|
27
|
+
/**
|
|
28
|
+
* Detect PII spans in text.
|
|
29
|
+
* Returns non-overlapping spans sorted by position.
|
|
30
|
+
*/
|
|
31
|
+
function detectPII(text) {
|
|
32
|
+
const spans = [];
|
|
33
|
+
const occupied = new Set();
|
|
34
|
+
// Phase 1: Structural PII via regex
|
|
35
|
+
for (const { category, regex } of PATTERNS) {
|
|
36
|
+
regex.lastIndex = 0;
|
|
37
|
+
let match;
|
|
38
|
+
while ((match = regex.exec(text)) !== null) {
|
|
39
|
+
const start = match.index;
|
|
40
|
+
const end = start + match[0].length;
|
|
41
|
+
if (isOccupied(occupied, start, end))
|
|
42
|
+
continue;
|
|
43
|
+
markOccupied(occupied, start, end);
|
|
44
|
+
spans.push({ start, end, category, value: match[0] });
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
// Phase 2: Person names — best-effort via @redactpii/node
|
|
48
|
+
try {
|
|
49
|
+
detectPersons(text, occupied, spans);
|
|
50
|
+
}
|
|
51
|
+
catch {
|
|
52
|
+
// Person detection is best-effort; silently skip on any error
|
|
53
|
+
}
|
|
54
|
+
return spans.sort((a, b) => a.start - b.start);
|
|
55
|
+
}
|
|
56
|
+
function isOccupied(occupied, start, end) {
|
|
57
|
+
for (let i = start; i < end; i++) {
|
|
58
|
+
if (occupied.has(i))
|
|
59
|
+
return true;
|
|
60
|
+
}
|
|
61
|
+
return false;
|
|
62
|
+
}
|
|
63
|
+
function markOccupied(occupied, start, end) {
|
|
64
|
+
for (let i = start; i < end; i++) {
|
|
65
|
+
occupied.add(i);
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
function detectPersons(text, occupied, spans) {
|
|
69
|
+
// The @redactpii/node NAME pattern only catches names after salutations
|
|
70
|
+
// (Dear, Hi, Hello, Hey, etc.) — not bare names. We replicate that pattern
|
|
71
|
+
// here and use it directly, avoiding the ESM/CJS import complexity.
|
|
72
|
+
// This gives best-effort detection for explicitly addressed names.
|
|
73
|
+
const salutationPattern = /(?:dear|hi|hello|greetings|hey(?:\s+there)?|mr\.?|mrs\.?|ms\.?|dr\.?|prof\.?)\s+([A-Z][a-zA-ZÀ-ÿ'\-]+(?:\s+[A-Z][a-zA-ZÀ-ÿ'\-]+)*)/gi;
|
|
74
|
+
let match;
|
|
75
|
+
salutationPattern.lastIndex = 0;
|
|
76
|
+
while ((match = salutationPattern.exec(text)) !== null) {
|
|
77
|
+
// match[1] is the captured name group
|
|
78
|
+
const nameValue = match[1];
|
|
79
|
+
if (!nameValue)
|
|
80
|
+
continue;
|
|
81
|
+
// Find the actual start position of the captured name within the full match
|
|
82
|
+
const fullMatchStart = match.index;
|
|
83
|
+
const nameOffset = match[0].indexOf(nameValue);
|
|
84
|
+
const start = fullMatchStart + nameOffset;
|
|
85
|
+
const end = start + nameValue.length;
|
|
86
|
+
if (isOccupied(occupied, start, end))
|
|
87
|
+
continue;
|
|
88
|
+
markOccupied(occupied, start, end);
|
|
89
|
+
spans.push({ start, end, category: 'person', value: nameValue });
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
//# sourceMappingURL=detector.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"detector.js","sourceRoot":"","sources":["../src/detector.ts"],"names":[],"mappings":"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"}
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import type { PIIDetectionResult, AnonymizerOptions } from './types.js';
|
|
2
|
+
export type { PIICategory, PIIDetectionResult, AnonymizerOptions } from './types.js';
|
|
3
|
+
export { Tokenizer } from './tokenizer.js';
|
|
4
|
+
/**
|
|
5
|
+
* Anonymize PII in text. Returns anonymized text + keymap (token → original).
|
|
6
|
+
* Same PII value always gets the same token within a call.
|
|
7
|
+
* Pass seedKeymap to maintain consistency across multiple calls.
|
|
8
|
+
*/
|
|
9
|
+
export declare function anonymize(text: string, options?: AnonymizerOptions): PIIDetectionResult;
|
|
10
|
+
/**
|
|
11
|
+
* Restore original PII values from keymap.
|
|
12
|
+
* Tokens not in the keymap are left unchanged.
|
|
13
|
+
*/
|
|
14
|
+
export declare function deanonymize(text: string, keymap: Record<string, string>): string;
|
|
15
|
+
//# sourceMappingURL=index.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAEA,OAAO,KAAK,EAAE,kBAAkB,EAAE,iBAAiB,EAAE,MAAM,YAAY,CAAC;AAExE,YAAY,EAAE,WAAW,EAAE,kBAAkB,EAAE,iBAAiB,EAAE,MAAM,YAAY,CAAC;AACrF,OAAO,EAAE,SAAS,EAAE,MAAM,gBAAgB,CAAC;AAE3C;;;;GAIG;AACH,wBAAgB,SAAS,CAAC,IAAI,EAAE,MAAM,EAAE,OAAO,CAAC,EAAE,iBAAiB,GAAG,kBAAkB,CAyBvF;AAED;;;GAGG;AACH,wBAAgB,WAAW,CAAC,IAAI,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,GAAG,MAAM,CAQhF"}
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.Tokenizer = void 0;
|
|
4
|
+
exports.anonymize = anonymize;
|
|
5
|
+
exports.deanonymize = deanonymize;
|
|
6
|
+
const detector_js_1 = require("./detector.js");
|
|
7
|
+
const tokenizer_js_1 = require("./tokenizer.js");
|
|
8
|
+
var tokenizer_js_2 = require("./tokenizer.js");
|
|
9
|
+
Object.defineProperty(exports, "Tokenizer", { enumerable: true, get: function () { return tokenizer_js_2.Tokenizer; } });
|
|
10
|
+
/**
|
|
11
|
+
* Anonymize PII in text. Returns anonymized text + keymap (token → original).
|
|
12
|
+
* Same PII value always gets the same token within a call.
|
|
13
|
+
* Pass seedKeymap to maintain consistency across multiple calls.
|
|
14
|
+
*/
|
|
15
|
+
function anonymize(text, options) {
|
|
16
|
+
const spans = (0, detector_js_1.detectPII)(text);
|
|
17
|
+
const filtered = options?.categories
|
|
18
|
+
? spans.filter(s => options.categories.includes(s.category))
|
|
19
|
+
: spans;
|
|
20
|
+
const tokenizer = new tokenizer_js_1.Tokenizer();
|
|
21
|
+
// Seed from existing keymap for cross-call consistency
|
|
22
|
+
if (options?.seedKeymap && Object.keys(options.seedKeymap).length > 0) {
|
|
23
|
+
tokenizer.loadKeymap(options.seedKeymap);
|
|
24
|
+
}
|
|
25
|
+
if (filtered.length === 0) {
|
|
26
|
+
return { text, keymap: tokenizer.getKeymap() };
|
|
27
|
+
}
|
|
28
|
+
// Replace spans from right to left to preserve character positions
|
|
29
|
+
const sortedDesc = [...filtered].sort((a, b) => b.start - a.start);
|
|
30
|
+
let result = text;
|
|
31
|
+
for (const span of sortedDesc) {
|
|
32
|
+
const token = tokenizer.tokenize(span.value, span.category);
|
|
33
|
+
result = result.slice(0, span.start) + token + result.slice(span.end);
|
|
34
|
+
}
|
|
35
|
+
return { text: result, keymap: tokenizer.getKeymap() };
|
|
36
|
+
}
|
|
37
|
+
/**
|
|
38
|
+
* Restore original PII values from keymap.
|
|
39
|
+
* Tokens not in the keymap are left unchanged.
|
|
40
|
+
*/
|
|
41
|
+
function deanonymize(text, keymap) {
|
|
42
|
+
let result = text;
|
|
43
|
+
// Sort by token length descending so EMAIL_10 is replaced before EMAIL_1
|
|
44
|
+
const sorted = Object.entries(keymap).sort((a, b) => b[0].length - a[0].length);
|
|
45
|
+
for (const [token, original] of sorted) {
|
|
46
|
+
result = result.replaceAll(token, original);
|
|
47
|
+
}
|
|
48
|
+
return result;
|
|
49
|
+
}
|
|
50
|
+
//# sourceMappingURL=index.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":";;;AAYA,8BAyBC;AAMD,kCAQC;AAnDD,+CAA0C;AAC1C,iDAA2C;AAI3C,+CAA2C;AAAlC,yGAAA,SAAS,OAAA;AAElB;;;;GAIG;AACH,SAAgB,SAAS,CAAC,IAAY,EAAE,OAA2B;IACjE,MAAM,KAAK,GAAG,IAAA,uBAAS,EAAC,IAAI,CAAC,CAAC;IAC9B,MAAM,QAAQ,GAAG,OAAO,EAAE,UAAU;QAClC,CAAC,CAAC,KAAK,CAAC,MAAM,CAAC,CAAC,CAAC,EAAE,CAAC,OAAO,CAAC,UAAW,CAAC,QAAQ,CAAC,CAAC,CAAC,QAAQ,CAAC,CAAC;QAC7D,CAAC,CAAC,KAAK,CAAC;IAEV,MAAM,SAAS,GAAG,IAAI,wBAAS,EAAE,CAAC;IAClC,uDAAuD;IACvD,IAAI,OAAO,EAAE,UAAU,IAAI,MAAM,CAAC,IAAI,CAAC,OAAO,CAAC,UAAU,CAAC,CAAC,MAAM,GAAG,CAAC,EAAE,CAAC;QACtE,SAAS,CAAC,UAAU,CAAC,OAAO,CAAC,UAAU,CAAC,CAAC;IAC3C,CAAC;IAED,IAAI,QAAQ,CAAC,MAAM,KAAK,CAAC,EAAE,CAAC;QAC1B,OAAO,EAAE,IAAI,EAAE,MAAM,EAAE,SAAS,CAAC,SAAS,EAAE,EAAE,CAAC;IACjD,CAAC;IAED,mEAAmE;IACnE,MAAM,UAAU,GAAG,CAAC,GAAG,QAAQ,CAAC,CAAC,IAAI,CAAC,CAAC,CAAC,EAAE,CAAC,EAAE,EAAE,CAAC,CAAC,CAAC,KAAK,GAAG,CAAC,CAAC,KAAK,CAAC,CAAC;IACnE,IAAI,MAAM,GAAG,IAAI,CAAC;IAClB,KAAK,MAAM,IAAI,IAAI,UAAU,EAAE,CAAC;QAC9B,MAAM,KAAK,GAAG,SAAS,CAAC,QAAQ,CAAC,IAAI,CAAC,KAAK,EAAE,IAAI,CAAC,QAAQ,CAAC,CAAC;QAC5D,MAAM,GAAG,MAAM,CAAC,KAAK,CAAC,CAAC,EAAE,IAAI,CAAC,KAAK,CAAC,GAAG,KAAK,GAAG,MAAM,CAAC,KAAK,CAAC,IAAI,CAAC,GAAG,CAAC,CAAC;IACxE,CAAC;IAED,OAAO,EAAE,IAAI,EAAE,MAAM,EAAE,MAAM,EAAE,SAAS,CAAC,SAAS,EAAE,EAAE,CAAC;AACzD,CAAC;AAED;;;GAGG;AACH,SAAgB,WAAW,CAAC,IAAY,EAAE,MAA8B;IACtE,IAAI,MAAM,GAAG,IAAI,CAAC;IAClB,yEAAyE;IACzE,MAAM,MAAM,GAAG,MAAM,CAAC,OAAO,CAAC,MAAM,CAAC,CAAC,IAAI,CAAC,CAAC,CAAC,EAAE,CAAC,EAAE,EAAE,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,MAAM,GAAG,CAAC,CAAC,CAAC,CAAC,CAAC,MAAM,CAAC,CAAC;IAChF,KAAK,MAAM,CAAC,KAAK,EAAE,QAAQ,CAAC,IAAI,MAAM,EAAE,CAAC;QACvC,MAAM,GAAG,MAAM,CAAC,UAAU,CAAC,KAAK,EAAE,QAAQ,CAAC,CAAC;IAC9C,CAAC;IACD,OAAO,MAAM,CAAC;AAChB,CAAC"}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import type { PIICategory } from './types.js';
|
|
2
|
+
export declare class Tokenizer {
|
|
3
|
+
private inverse;
|
|
4
|
+
private keymap;
|
|
5
|
+
private counters;
|
|
6
|
+
/**
|
|
7
|
+
* Get or create a consistent sequential token for a PII value.
|
|
8
|
+
*/
|
|
9
|
+
tokenize(value: string, category: PIICategory): string;
|
|
10
|
+
getKeymap(): Record<string, string>;
|
|
11
|
+
/**
|
|
12
|
+
* Load an existing keymap (for cross-stage continuity).
|
|
13
|
+
* Populates inverse map so existing tokens are reused.
|
|
14
|
+
*/
|
|
15
|
+
loadKeymap(keymap: Record<string, string>): void;
|
|
16
|
+
}
|
|
17
|
+
//# sourceMappingURL=tokenizer.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"tokenizer.d.ts","sourceRoot":"","sources":["../src/tokenizer.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,YAAY,CAAC;AAW9C,qBAAa,SAAS;IAEpB,OAAO,CAAC,OAAO,CAA6B;IAE5C,OAAO,CAAC,MAAM,CAA6B;IAE3C,OAAO,CAAC,QAAQ,CAAkC;IAElD;;OAEG;IACH,QAAQ,CAAC,KAAK,EAAE,MAAM,EAAE,QAAQ,EAAE,WAAW,GAAG,MAAM;IAetD,SAAS,IAAI,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC;IAInC;;;OAGG;IACH,UAAU,CAAC,MAAM,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,GAAG,IAAI;CAiBjD"}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.Tokenizer = void 0;
|
|
4
|
+
const CATEGORY_PREFIX = {
|
|
5
|
+
person: 'PERSON',
|
|
6
|
+
email: 'EMAIL',
|
|
7
|
+
phone: 'PHONE',
|
|
8
|
+
address: 'ADDRESS',
|
|
9
|
+
ssn: 'SSN',
|
|
10
|
+
credit_card: 'CREDIT_CARD',
|
|
11
|
+
};
|
|
12
|
+
class Tokenizer {
|
|
13
|
+
// original value → token
|
|
14
|
+
inverse = new Map();
|
|
15
|
+
// token → original value
|
|
16
|
+
keymap = new Map();
|
|
17
|
+
// category → counter
|
|
18
|
+
counters = new Map();
|
|
19
|
+
/**
|
|
20
|
+
* Get or create a consistent sequential token for a PII value.
|
|
21
|
+
*/
|
|
22
|
+
tokenize(value, category) {
|
|
23
|
+
const existing = this.inverse.get(value);
|
|
24
|
+
if (existing)
|
|
25
|
+
return existing;
|
|
26
|
+
const counter = (this.counters.get(category) ?? 0) + 1;
|
|
27
|
+
this.counters.set(category, counter);
|
|
28
|
+
const prefix = CATEGORY_PREFIX[category];
|
|
29
|
+
const token = `${prefix}_${counter}`;
|
|
30
|
+
this.inverse.set(value, token);
|
|
31
|
+
this.keymap.set(token, value);
|
|
32
|
+
return token;
|
|
33
|
+
}
|
|
34
|
+
getKeymap() {
|
|
35
|
+
return Object.fromEntries(this.keymap);
|
|
36
|
+
}
|
|
37
|
+
/**
|
|
38
|
+
* Load an existing keymap (for cross-stage continuity).
|
|
39
|
+
* Populates inverse map so existing tokens are reused.
|
|
40
|
+
*/
|
|
41
|
+
loadKeymap(keymap) {
|
|
42
|
+
for (const [token, value] of Object.entries(keymap)) {
|
|
43
|
+
this.keymap.set(token, value);
|
|
44
|
+
this.inverse.set(value, token);
|
|
45
|
+
// Restore counter state from token name (e.g. PERSON_3 → counter = 3)
|
|
46
|
+
const match = token.match(/^([A-Z_]+)_(\d+)$/);
|
|
47
|
+
if (match) {
|
|
48
|
+
const cat = Object.entries(CATEGORY_PREFIX).find(([, p]) => p === match[1])?.[0];
|
|
49
|
+
if (cat) {
|
|
50
|
+
const n = parseInt(match[2], 10);
|
|
51
|
+
if ((this.counters.get(cat) ?? 0) < n) {
|
|
52
|
+
this.counters.set(cat, n);
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
exports.Tokenizer = Tokenizer;
|
|
60
|
+
//# sourceMappingURL=tokenizer.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"tokenizer.js","sourceRoot":"","sources":["../src/tokenizer.ts"],"names":[],"mappings":";;;AAEA,MAAM,eAAe,GAAgC;IACnD,MAAM,EAAE,QAAQ;IAChB,KAAK,EAAE,OAAO;IACd,KAAK,EAAE,OAAO;IACd,OAAO,EAAE,SAAS;IAClB,GAAG,EAAE,KAAK;IACV,WAAW,EAAE,aAAa;CAC3B,CAAC;AAEF,MAAa,SAAS;IACpB,yBAAyB;IACjB,OAAO,GAAG,IAAI,GAAG,EAAkB,CAAC;IAC5C,yBAAyB;IACjB,MAAM,GAAG,IAAI,GAAG,EAAkB,CAAC;IAC3C,qBAAqB;IACb,QAAQ,GAAG,IAAI,GAAG,EAAuB,CAAC;IAElD;;OAEG;IACH,QAAQ,CAAC,KAAa,EAAE,QAAqB;QAC3C,MAAM,QAAQ,GAAG,IAAI,CAAC,OAAO,CAAC,GAAG,CAAC,KAAK,CAAC,CAAC;QACzC,IAAI,QAAQ;YAAE,OAAO,QAAQ,CAAC;QAE9B,MAAM,OAAO,GAAG,CAAC,IAAI,CAAC,QAAQ,CAAC,GAAG,CAAC,QAAQ,CAAC,IAAI,CAAC,CAAC,GAAG,CAAC,CAAC;QACvD,IAAI,CAAC,QAAQ,CAAC,GAAG,CAAC,QAAQ,EAAE,OAAO,CAAC,CAAC;QAErC,MAAM,MAAM,GAAG,eAAe,CAAC,QAAQ,CAAC,CAAC;QACzC,MAAM,KAAK,GAAG,GAAG,MAAM,IAAI,OAAO,EAAE,CAAC;QAErC,IAAI,CAAC,OAAO,CAAC,GAAG,CAAC,KAAK,EAAE,KAAK,CAAC,CAAC;QAC/B,IAAI,CAAC,MAAM,CAAC,GAAG,CAAC,KAAK,EAAE,KAAK,CAAC,CAAC;QAC9B,OAAO,KAAK,CAAC;IACf,CAAC;IAED,SAAS;QACP,OAAO,MAAM,CAAC,WAAW,CAAC,IAAI,CAAC,MAAM,CAAC,CAAC;IACzC,CAAC;IAED;;;OAGG;IACH,UAAU,CAAC,MAA8B;QACvC,KAAK,MAAM,CAAC,KAAK,EAAE,KAAK,CAAC,IAAI,MAAM,CAAC,OAAO,CAAC,MAAM,CAAC,EAAE,CAAC;YACpD,IAAI,CAAC,MAAM,CAAC,GAAG,CAAC,KAAK,EAAE,KAAK,CAAC,CAAC;YAC9B,IAAI,CAAC,OAAO,CAAC,GAAG,CAAC,KAAK,EAAE,KAAK,CAAC,CAAC;YAC/B,sEAAsE;YACtE,MAAM,KAAK,GAAG,KAAK,CAAC,KAAK,CAAC,mBAAmB,CAAC,CAAC;YAC/C,IAAI,KAAK,EAAE,CAAC;gBACV,MAAM,GAAG,GAAG,MAAM,CAAC,OAAO,CAAC,eAAe,CAAC,CAAC,IAAI,CAAC,CAAC,CAAC,EAAE,CAAC,CAAC,EAAE,EAAE,CAAC,CAAC,KAAK,KAAK,CAAC,CAAC,CAAC,CAAC,EAAE,CAAC,CAAC,CAA4B,CAAC;gBAC5G,IAAI,GAAG,EAAE,CAAC;oBACR,MAAM,CAAC,GAAG,QAAQ,CAAC,KAAK,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,CAAC;oBACjC,IAAI,CAAC,IAAI,CAAC,QAAQ,CAAC,GAAG,CAAC,GAAG,CAAC,IAAI,CAAC,CAAC,GAAG,CAAC,EAAE,CAAC;wBACtC,IAAI,CAAC,QAAQ,CAAC,GAAG,CAAC,GAAG,EAAE,CAAC,CAAC,CAAC;oBAC5B,CAAC;gBACH,CAAC;YACH,CAAC;QACH,CAAC;IACH,CAAC;CACF;AAnDD,8BAmDC"}
|
package/dist/types.d.ts
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
export type PIICategory = 'person' | 'email' | 'phone' | 'address' | 'ssn' | 'credit_card';
|
|
2
|
+
export interface PIISpan {
|
|
3
|
+
start: number;
|
|
4
|
+
end: number;
|
|
5
|
+
category: PIICategory;
|
|
6
|
+
value: string;
|
|
7
|
+
}
|
|
8
|
+
export interface PIIDetectionResult {
|
|
9
|
+
text: string;
|
|
10
|
+
keymap: Record<string, string>;
|
|
11
|
+
}
|
|
12
|
+
export interface AnonymizerOptions {
|
|
13
|
+
categories?: PIICategory[];
|
|
14
|
+
seedKeymap?: Record<string, string>;
|
|
15
|
+
}
|
|
16
|
+
//# sourceMappingURL=types.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"types.d.ts","sourceRoot":"","sources":["../src/types.ts"],"names":[],"mappings":"AAAA,MAAM,MAAM,WAAW,GACnB,QAAQ,GACR,OAAO,GACP,OAAO,GACP,SAAS,GACT,KAAK,GACL,aAAa,CAAC;AAElB,MAAM,WAAW,OAAO;IACtB,KAAK,EAAE,MAAM,CAAC;IACd,GAAG,EAAE,MAAM,CAAC;IACZ,QAAQ,EAAE,WAAW,CAAC;IACtB,KAAK,EAAE,MAAM,CAAC;CACf;AAED,MAAM,WAAW,kBAAkB;IACjC,IAAI,EAAE,MAAM,CAAC;IACb,MAAM,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;CAChC;AAED,MAAM,WAAW,iBAAiB;IAChC,UAAU,CAAC,EAAE,WAAW,EAAE,CAAC;IAC3B,UAAU,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;CACrC"}
|
package/dist/types.js
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"types.js","sourceRoot":"","sources":["../src/types.ts"],"names":[],"mappings":""}
|
package/package.json
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@studio-foundation/anonymizer",
|
|
3
|
+
"version": "0.3.0-beta.1",
|
|
4
|
+
"description": "PII detection and anonymization with consistent token mapping",
|
|
5
|
+
"main": "dist/index.js",
|
|
6
|
+
"types": "dist/index.d.ts",
|
|
7
|
+
"keywords": [
|
|
8
|
+
"pii",
|
|
9
|
+
"anonymization",
|
|
10
|
+
"privacy"
|
|
11
|
+
],
|
|
12
|
+
"author": "Ariane Guay",
|
|
13
|
+
"license": "AGPL-3.0-only",
|
|
14
|
+
"dependencies": {
|
|
15
|
+
"@redactpii/node": "^1.0.16"
|
|
16
|
+
},
|
|
17
|
+
"devDependencies": {
|
|
18
|
+
"typescript": "^5.7.2",
|
|
19
|
+
"@types/node": "^25.2.3",
|
|
20
|
+
"vitest": "^4.0.18"
|
|
21
|
+
},
|
|
22
|
+
"scripts": {
|
|
23
|
+
"build": "tsc",
|
|
24
|
+
"test": "vitest run",
|
|
25
|
+
"test:watch": "vitest",
|
|
26
|
+
"clean": "rm -rf dist"
|
|
27
|
+
}
|
|
28
|
+
}
|
package/src/detector.ts
ADDED
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
import type { PIICategory, PIISpan } from './types.js';
|
|
2
|
+
|
|
3
|
+
// Regex patterns for structural PII (high precision)
|
|
4
|
+
const PATTERNS: Array<{ category: PIICategory; regex: RegExp }> = [
|
|
5
|
+
{
|
|
6
|
+
category: 'email',
|
|
7
|
+
regex: /\b[A-Za-z0-9._%+\-]+@[A-Za-z0-9.\-]+\.[A-Za-z]{2,}\b/g,
|
|
8
|
+
},
|
|
9
|
+
{
|
|
10
|
+
category: 'phone',
|
|
11
|
+
// US phone: must be at least 10 digits. Require optional country code + area code.
|
|
12
|
+
// Anchored to avoid matching partial SSN-like strings.
|
|
13
|
+
regex: /\b(?:\+?1[-.\s]?)?\(?\d{3}\)?[-.\s]\d{3}[-.\s]\d{4}\b/g,
|
|
14
|
+
},
|
|
15
|
+
{
|
|
16
|
+
category: 'ssn',
|
|
17
|
+
// SSN: exactly ddd-dd-dddd with hyphens (strict format, not dots or spaces
|
|
18
|
+
// to avoid collision with phone numbers already consumed)
|
|
19
|
+
regex: /\b\d{3}-\d{2}-\d{4}\b/g,
|
|
20
|
+
},
|
|
21
|
+
{
|
|
22
|
+
category: 'credit_card',
|
|
23
|
+
regex: /\b(?:\d{4}[-\s]?){3}\d{4}\b/g,
|
|
24
|
+
},
|
|
25
|
+
];
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Detect PII spans in text.
|
|
29
|
+
* Returns non-overlapping spans sorted by position.
|
|
30
|
+
*/
|
|
31
|
+
export function detectPII(text: string): PIISpan[] {
|
|
32
|
+
const spans: PIISpan[] = [];
|
|
33
|
+
const occupied = new Set<number>();
|
|
34
|
+
|
|
35
|
+
// Phase 1: Structural PII via regex
|
|
36
|
+
for (const { category, regex } of PATTERNS) {
|
|
37
|
+
regex.lastIndex = 0;
|
|
38
|
+
let match: RegExpExecArray | null;
|
|
39
|
+
while ((match = regex.exec(text)) !== null) {
|
|
40
|
+
const start = match.index;
|
|
41
|
+
const end = start + match[0].length;
|
|
42
|
+
if (isOccupied(occupied, start, end)) continue;
|
|
43
|
+
markOccupied(occupied, start, end);
|
|
44
|
+
spans.push({ start, end, category, value: match[0] });
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// Phase 2: Person names — best-effort via @redactpii/node
|
|
49
|
+
try {
|
|
50
|
+
detectPersons(text, occupied, spans);
|
|
51
|
+
} catch {
|
|
52
|
+
// Person detection is best-effort; silently skip on any error
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
return spans.sort((a, b) => a.start - b.start);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function isOccupied(occupied: Set<number>, start: number, end: number): boolean {
|
|
59
|
+
for (let i = start; i < end; i++) {
|
|
60
|
+
if (occupied.has(i)) return true;
|
|
61
|
+
}
|
|
62
|
+
return false;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function markOccupied(occupied: Set<number>, start: number, end: number): void {
|
|
66
|
+
for (let i = start; i < end; i++) {
|
|
67
|
+
occupied.add(i);
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function detectPersons(text: string, occupied: Set<number>, spans: PIISpan[]): void {
|
|
72
|
+
// The @redactpii/node NAME pattern only catches names after salutations
|
|
73
|
+
// (Dear, Hi, Hello, Hey, etc.) — not bare names. We replicate that pattern
|
|
74
|
+
// here and use it directly, avoiding the ESM/CJS import complexity.
|
|
75
|
+
// This gives best-effort detection for explicitly addressed names.
|
|
76
|
+
const salutationPattern =
|
|
77
|
+
/(?:dear|hi|hello|greetings|hey(?:\s+there)?|mr\.?|mrs\.?|ms\.?|dr\.?|prof\.?)\s+([A-Z][a-zA-ZÀ-ÿ'\-]+(?:\s+[A-Z][a-zA-ZÀ-ÿ'\-]+)*)/gi;
|
|
78
|
+
|
|
79
|
+
let match: RegExpExecArray | null;
|
|
80
|
+
salutationPattern.lastIndex = 0;
|
|
81
|
+
|
|
82
|
+
while ((match = salutationPattern.exec(text)) !== null) {
|
|
83
|
+
// match[1] is the captured name group
|
|
84
|
+
const nameValue = match[1];
|
|
85
|
+
if (!nameValue) continue;
|
|
86
|
+
|
|
87
|
+
// Find the actual start position of the captured name within the full match
|
|
88
|
+
const fullMatchStart = match.index;
|
|
89
|
+
const nameOffset = match[0].indexOf(nameValue);
|
|
90
|
+
const start = fullMatchStart + nameOffset;
|
|
91
|
+
const end = start + nameValue.length;
|
|
92
|
+
|
|
93
|
+
if (isOccupied(occupied, start, end)) continue;
|
|
94
|
+
markOccupied(occupied, start, end);
|
|
95
|
+
spans.push({ start, end, category: 'person', value: nameValue });
|
|
96
|
+
}
|
|
97
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import { detectPII } from './detector.js';
|
|
2
|
+
import { Tokenizer } from './tokenizer.js';
|
|
3
|
+
import type { PIIDetectionResult, AnonymizerOptions } from './types.js';
|
|
4
|
+
|
|
5
|
+
export type { PIICategory, PIIDetectionResult, AnonymizerOptions } from './types.js';
|
|
6
|
+
export { Tokenizer } from './tokenizer.js';
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Anonymize PII in text. Returns anonymized text + keymap (token → original).
|
|
10
|
+
* Same PII value always gets the same token within a call.
|
|
11
|
+
* Pass seedKeymap to maintain consistency across multiple calls.
|
|
12
|
+
*/
|
|
13
|
+
export function anonymize(text: string, options?: AnonymizerOptions): PIIDetectionResult {
|
|
14
|
+
const spans = detectPII(text);
|
|
15
|
+
const filtered = options?.categories
|
|
16
|
+
? spans.filter(s => options.categories!.includes(s.category))
|
|
17
|
+
: spans;
|
|
18
|
+
|
|
19
|
+
const tokenizer = new Tokenizer();
|
|
20
|
+
// Seed from existing keymap for cross-call consistency
|
|
21
|
+
if (options?.seedKeymap && Object.keys(options.seedKeymap).length > 0) {
|
|
22
|
+
tokenizer.loadKeymap(options.seedKeymap);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
if (filtered.length === 0) {
|
|
26
|
+
return { text, keymap: tokenizer.getKeymap() };
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// Replace spans from right to left to preserve character positions
|
|
30
|
+
const sortedDesc = [...filtered].sort((a, b) => b.start - a.start);
|
|
31
|
+
let result = text;
|
|
32
|
+
for (const span of sortedDesc) {
|
|
33
|
+
const token = tokenizer.tokenize(span.value, span.category);
|
|
34
|
+
result = result.slice(0, span.start) + token + result.slice(span.end);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
return { text: result, keymap: tokenizer.getKeymap() };
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Restore original PII values from keymap.
|
|
42
|
+
* Tokens not in the keymap are left unchanged.
|
|
43
|
+
*/
|
|
44
|
+
export function deanonymize(text: string, keymap: Record<string, string>): string {
|
|
45
|
+
let result = text;
|
|
46
|
+
// Sort by token length descending so EMAIL_10 is replaced before EMAIL_1
|
|
47
|
+
const sorted = Object.entries(keymap).sort((a, b) => b[0].length - a[0].length);
|
|
48
|
+
for (const [token, original] of sorted) {
|
|
49
|
+
result = result.replaceAll(token, original);
|
|
50
|
+
}
|
|
51
|
+
return result;
|
|
52
|
+
}
|
package/src/tokenizer.ts
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import type { PIICategory } from './types.js';
|
|
2
|
+
|
|
3
|
+
const CATEGORY_PREFIX: Record<PIICategory, string> = {
|
|
4
|
+
person: 'PERSON',
|
|
5
|
+
email: 'EMAIL',
|
|
6
|
+
phone: 'PHONE',
|
|
7
|
+
address: 'ADDRESS',
|
|
8
|
+
ssn: 'SSN',
|
|
9
|
+
credit_card: 'CREDIT_CARD',
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
export class Tokenizer {
|
|
13
|
+
// original value → token
|
|
14
|
+
private inverse = new Map<string, string>();
|
|
15
|
+
// token → original value
|
|
16
|
+
private keymap = new Map<string, string>();
|
|
17
|
+
// category → counter
|
|
18
|
+
private counters = new Map<PIICategory, number>();
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Get or create a consistent sequential token for a PII value.
|
|
22
|
+
*/
|
|
23
|
+
tokenize(value: string, category: PIICategory): string {
|
|
24
|
+
const existing = this.inverse.get(value);
|
|
25
|
+
if (existing) return existing;
|
|
26
|
+
|
|
27
|
+
const counter = (this.counters.get(category) ?? 0) + 1;
|
|
28
|
+
this.counters.set(category, counter);
|
|
29
|
+
|
|
30
|
+
const prefix = CATEGORY_PREFIX[category];
|
|
31
|
+
const token = `${prefix}_${counter}`;
|
|
32
|
+
|
|
33
|
+
this.inverse.set(value, token);
|
|
34
|
+
this.keymap.set(token, value);
|
|
35
|
+
return token;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
getKeymap(): Record<string, string> {
|
|
39
|
+
return Object.fromEntries(this.keymap);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Load an existing keymap (for cross-stage continuity).
|
|
44
|
+
* Populates inverse map so existing tokens are reused.
|
|
45
|
+
*/
|
|
46
|
+
loadKeymap(keymap: Record<string, string>): void {
|
|
47
|
+
for (const [token, value] of Object.entries(keymap)) {
|
|
48
|
+
this.keymap.set(token, value);
|
|
49
|
+
this.inverse.set(value, token);
|
|
50
|
+
// Restore counter state from token name (e.g. PERSON_3 → counter = 3)
|
|
51
|
+
const match = token.match(/^([A-Z_]+)_(\d+)$/);
|
|
52
|
+
if (match) {
|
|
53
|
+
const cat = Object.entries(CATEGORY_PREFIX).find(([, p]) => p === match[1])?.[0] as PIICategory | undefined;
|
|
54
|
+
if (cat) {
|
|
55
|
+
const n = parseInt(match[2], 10);
|
|
56
|
+
if ((this.counters.get(cat) ?? 0) < n) {
|
|
57
|
+
this.counters.set(cat, n);
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
}
|
package/src/types.ts
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
export type PIICategory =
|
|
2
|
+
| 'person'
|
|
3
|
+
| 'email'
|
|
4
|
+
| 'phone'
|
|
5
|
+
| 'address'
|
|
6
|
+
| 'ssn'
|
|
7
|
+
| 'credit_card';
|
|
8
|
+
|
|
9
|
+
export interface PIISpan {
|
|
10
|
+
start: number;
|
|
11
|
+
end: number;
|
|
12
|
+
category: PIICategory;
|
|
13
|
+
value: string;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export interface PIIDetectionResult {
|
|
17
|
+
text: string;
|
|
18
|
+
keymap: Record<string, string>; // "PERSON_1" → "Marie-Claire"
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export interface AnonymizerOptions {
|
|
22
|
+
categories?: PIICategory[];
|
|
23
|
+
seedKeymap?: Record<string, string>;
|
|
24
|
+
}
|