euredact 0.3.9 → 0.5.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.
- package/README.md +313 -25
- package/dist/cjs/cache.js +5 -0
- package/dist/cjs/cloud/client.js +243 -0
- package/dist/cjs/cloud/config.js +57 -0
- package/dist/cjs/cloud/errors.js +55 -0
- package/dist/cjs/cloud/index.js +31 -0
- package/dist/cjs/index.js +23 -3
- package/dist/cjs/rules/countries/index.js +24 -5
- package/dist/cjs/sdk.js +442 -29
- package/dist/cjs/types.js +45 -1
- package/dist/esm/cache.d.ts +2 -0
- package/dist/esm/cache.js +5 -1
- package/dist/esm/cloud/client.d.ts +42 -0
- package/dist/esm/cloud/client.js +239 -0
- package/dist/esm/cloud/config.d.ts +43 -0
- package/dist/esm/cloud/config.js +51 -0
- package/dist/esm/cloud/errors.d.ts +32 -0
- package/dist/esm/cloud/errors.js +48 -0
- package/dist/esm/cloud/index.d.ts +19 -0
- package/dist/esm/cloud/index.js +19 -0
- package/dist/esm/index.d.ts +11 -3
- package/dist/esm/index.js +12 -2
- package/dist/esm/rules/countries/index.js +24 -5
- package/dist/esm/sdk.d.ts +128 -1
- package/dist/esm/sdk.js +405 -28
- package/dist/esm/types.d.ts +63 -1
- package/dist/esm/types.js +44 -1
- package/package.json +7 -3
package/README.md
CHANGED
|
@@ -34,13 +34,19 @@ console.log(result.detections);
|
|
|
34
34
|
## Features
|
|
35
35
|
|
|
36
36
|
- **31 European countries** with country-specific patterns
|
|
37
|
-
- **
|
|
37
|
+
- **41 PII entity types** (30 from the rules engine, 11 from the
|
|
38
|
+
[cloud tier](#cloud-tier)): national IDs, IBANs, phone numbers, email, VAT
|
|
38
39
|
numbers, license plates, credit cards, BIC/SWIFT, VIN, IMEI, GPS coordinates,
|
|
39
40
|
UUIDs, social handles, IP/IPv6, MAC addresses, secrets/API keys, and more
|
|
40
41
|
- **Secret/API key detection:** known-prefix patterns for AWS, GitHub, Stripe,
|
|
41
42
|
OpenAI, Slack, JWT, SendGrid, plus Shannon entropy-based detection for generic
|
|
42
43
|
high-entropy tokens near context keywords
|
|
43
44
|
- **Custom patterns:** register your own regex patterns for domain-specific PII
|
|
45
|
+
- **Reversible tokenization:** `tokenize: true` swaps values for `EMAIL_K7Q2`-style
|
|
46
|
+
tokens and `restore()` puts them back — for prompts that go to an LLM and
|
|
47
|
+
come back
|
|
48
|
+
- **Allowlist:** values that are never redacted, such as your own organisation's
|
|
49
|
+
name and addresses, per call or per instance
|
|
44
50
|
- **Checksum validation:** IBAN mod-97, Luhn (credit cards/IMEI), and 30+
|
|
45
51
|
country-specific validators
|
|
46
52
|
- **Priority-aware deduplication:** validated (checksum, corroborated by the
|
|
@@ -65,6 +71,26 @@ console.log(result.detections);
|
|
|
65
71
|
|
|
66
72
|
## API Reference
|
|
67
73
|
|
|
74
|
+
### Which option do I need?
|
|
75
|
+
|
|
76
|
+
| I want to… | Use |
|
|
77
|
+
|---|---|
|
|
78
|
+
| redact a document with no further setup | `redact(text)` — all 31 countries, `[ENTITY_TYPE]` output |
|
|
79
|
+
| restrict scope to the countries I operate in | `{ countries: ["NL", "BE"] }` |
|
|
80
|
+
| keep detection wide but resolve ambiguity | `{ countryHint: ["DE"] }` |
|
|
81
|
+
| send a prompt to an LLM and restore the reply | `{ tokenize: true }`, then [`restore()`](#restoretext-tokens) |
|
|
82
|
+
| keep relationships visible across a whole session | `{ referentialIntegrity: true }` |
|
|
83
|
+
| never redact my own company name or addresses | `{ allowlist: [...] }`, or `new EuRedact({ allowlist })` |
|
|
84
|
+
| never redact anything at my own domain | `{ allowlistDomains: ["acme.be"] }` |
|
|
85
|
+
| audit what the allowlist kept in the document | `result.exempted` |
|
|
86
|
+
| catch person names, employers, job titles, diagnoses | `redactAsync(text, { mode: "cloud" })` — see [Cloud tier](#cloud-tier) |
|
|
87
|
+
| include dates of birth | `{ detectDates: true }` |
|
|
88
|
+
| redact a document too large for one call | `context` + `chunkOffset` — see [Chunked documents](#chunked-documents) |
|
|
89
|
+
| detect an identifier the engine does not know | [`addCustomPattern()`](#addcustompatternname-pattern) |
|
|
90
|
+
| isolate tenants from each other | one `new EuRedact()` each |
|
|
91
|
+
| free PII held in memory | `clear()` |
|
|
92
|
+
|
|
93
|
+
|
|
68
94
|
### Module-Level Functions
|
|
69
95
|
|
|
70
96
|
#### `redact(text, options?)`
|
|
@@ -77,25 +103,68 @@ Main entry point. Detects and redacts PII in the given text.
|
|
|
77
103
|
|
|
78
104
|
```ts
|
|
79
105
|
interface RedactOptions {
|
|
80
|
-
countries?: string[] | null;
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
106
|
+
countries?: string[] | null; // scope (see below); null = all 31
|
|
107
|
+
countryHint?: string[] | null; // a prior only; does not narrow scope
|
|
108
|
+
context?: DocumentContext | null; // share evidence across chunks
|
|
109
|
+
chunkOffset?: number; // where this chunk starts
|
|
110
|
+
mode?: string; // "rules" (default) | "cloud"
|
|
111
|
+
referentialIntegrity?: boolean; // consistent labels, EMAIL_1
|
|
112
|
+
tokenize?: boolean; // reversible tokens, EMAIL_K7Q2
|
|
113
|
+
allowlist?: string[] | null; // values never redacted
|
|
114
|
+
allowlistDomains?: string[] | null; // domains never redacted (EMAIL/URL)
|
|
115
|
+
detectDates?: boolean; // include DOB / date of death
|
|
116
|
+
cache?: boolean; // reuse results for identical input
|
|
90
117
|
}
|
|
91
118
|
```
|
|
92
119
|
|
|
120
|
+
**What is looked for**
|
|
121
|
+
|
|
122
|
+
| Parameter | Default | Description |
|
|
123
|
+
|---|---|---|
|
|
124
|
+
| `text` | — | Input text to scan. Longer than `maxInputLength` throws. |
|
|
125
|
+
| `countries` | `null` | ISO 3166-1 alpha-2 codes that define **scope**. `null` loads all 31. This never gates what is looked for: a detection attributed elsewhere is flagged `outOfScope`, never dropped. A bare string throws `TypeError`. See [Country codes](#country-codes). |
|
|
126
|
+
| `countryHint` | `null` | A **prior only**. Resolves an ambiguous value without narrowing scope. See [Country Hints](#country-hints). |
|
|
127
|
+
| `detectDates` | `false` | Include `DOB` and `DATE_OF_DEATH`. Off by default: a bare date without keyword or structural context is deferred to the cloud tier. |
|
|
128
|
+
|
|
129
|
+
**How the output looks** — pick at most one of the first three.
|
|
130
|
+
|
|
131
|
+
| Parameter | Default | Description |
|
|
132
|
+
|---|---|---|
|
|
133
|
+
| *(none)* | — | Default: each span becomes `[ENTITY_TYPE]`. |
|
|
134
|
+
| `referentialIntegrity` | `false` | Consistent label per distinct value (`EMAIL_1`), persisting **on the instance** across calls. See [Referential Integrity](#referential-integrity). |
|
|
135
|
+
| `tokenize` | `false` | Reversible token per value (`EMAIL_K7Q2`), with the mapping in `result.tokens` for [`restore()`](#restoretext-tokens). Unique to the **call**. Cannot be combined with `referentialIntegrity`. See [Reversible tokenization](#reversible-tokenization). |
|
|
136
|
+
| `allowlist` | `null` | Values never to redact, whole-span and case-insensitive, merged with the instance's list. Structured identifiers (IBAN, phone, VAT, …) also match across spacing and hyphenation. Applies to cloud-tier types too. See [Allowlist](#allowlist). |
|
|
137
|
+
| `allowlistDomains` | `null` | Domains whose addresses are never redacted, e.g. `["acme.be"]`. Applies to `EMAIL` and `URL` only, and covers subdomains. See [Allowlist](#allowlist). |
|
|
138
|
+
|
|
139
|
+
**Long documents and tiers**
|
|
140
|
+
|
|
93
141
|
| Parameter | Default | Description |
|
|
94
142
|
|---|---|---|
|
|
95
|
-
| `
|
|
96
|
-
| `
|
|
97
|
-
| `
|
|
98
|
-
| `cache` | `true` |
|
|
143
|
+
| `mode` | `"rules"` | `"rules"` runs locally and synchronously. `"cloud"` must go through [`redactAsync()`](#redactasynctext-options) — `redact()` is synchronous and a network call cannot be. See [Cloud tier](#cloud-tier). |
|
|
144
|
+
| `context` | `null` | Share country evidence across the chunks of one document. Pass the same `DocumentContext` to every chunk. Disables the cache. See [Chunked documents](#chunked-documents). |
|
|
145
|
+
| `chunkOffset` | `0` | Where this chunk starts in the whole document. Only rebases spans recorded in `context`; returned detections stay relative to `text`. |
|
|
146
|
+
| `cache` | `true` | Reuse the result for an identical input and configuration. |
|
|
147
|
+
|
|
148
|
+
TypeScript has no `coref` option; the Python SDK accepts one as a reserved
|
|
149
|
+
no-op.
|
|
150
|
+
|
|
151
|
+
#### `redactAsync(text, options?)`
|
|
152
|
+
|
|
153
|
+
```ts
|
|
154
|
+
function redactAsync(text: string, options?: RedactOptions): Promise<RedactResult>;
|
|
155
|
+
```
|
|
156
|
+
|
|
157
|
+
The asynchronous entry point, and the only way to reach the [cloud
|
|
158
|
+
tier](#cloud-tier). With `mode: "rules"` (the default) it resolves immediately
|
|
159
|
+
with exactly what `redact()` returns, so a caller that may or may not use the
|
|
160
|
+
cloud tier can keep one code path:
|
|
161
|
+
|
|
162
|
+
```ts
|
|
163
|
+
const result = await redactAsync(text, { countries: ["BE"], mode: "cloud" });
|
|
164
|
+
```
|
|
165
|
+
|
|
166
|
+
Takes the same `RedactOptions` as `redact()`. Options the service cannot honour
|
|
167
|
+
throw rather than being ignored — see [Cloud tier](#cloud-tier).
|
|
99
168
|
|
|
100
169
|
#### `redactBatch(texts, options?)`
|
|
101
170
|
|
|
@@ -115,6 +184,16 @@ function addCustomPattern(name: string, pattern: string): void;
|
|
|
115
184
|
Register a custom regex pattern. Matches are reported with `name` as the entity
|
|
116
185
|
type. See [Custom Patterns](#custom-patterns) below.
|
|
117
186
|
|
|
187
|
+
#### `restore(text, tokens)`
|
|
188
|
+
|
|
189
|
+
```ts
|
|
190
|
+
function restore(text: string, tokens: Record<string, string>): string;
|
|
191
|
+
```
|
|
192
|
+
|
|
193
|
+
Put the original values back into text that derives from a `tokenize: true`
|
|
194
|
+
result — typically an LLM's reply to the tokenized prompt. `tokens` is
|
|
195
|
+
`result.tokens`. See [Reversible tokenization](#reversible-tokenization).
|
|
196
|
+
|
|
118
197
|
#### `availableCountries()`
|
|
119
198
|
|
|
120
199
|
```ts
|
|
@@ -141,8 +220,25 @@ console.log(result.redactedText);
|
|
|
141
220
|
// "See [CASE_REF] for details"
|
|
142
221
|
```
|
|
143
222
|
|
|
144
|
-
The `EuRedact` class exposes
|
|
145
|
-
`addCustomPattern()`.
|
|
223
|
+
The `EuRedact` class exposes `redact()`, `redactAsync()`, `redactBatch()`,
|
|
224
|
+
`clear()` and `addCustomPattern()`.
|
|
225
|
+
|
|
226
|
+
```ts
|
|
227
|
+
new EuRedact({
|
|
228
|
+
maxInputLength?: number, // default 10_485_760 (~10 MB)
|
|
229
|
+
allowlist?: string[] | null,
|
|
230
|
+
})
|
|
231
|
+
```
|
|
232
|
+
|
|
233
|
+
| Parameter | Default | Description |
|
|
234
|
+
|---|---|---|
|
|
235
|
+
| `maxInputLength` | `10_485_760` | Longest document `redact()` accepts, in characters. Above it, it throws — split the input or raise the ceiling. |
|
|
236
|
+
| `allowlist` | `null` | Values never to redact, for every call on this instance. Merged with the per-call `allowlist`. See [Allowlist](#allowlist). |
|
|
237
|
+
| `allowlistDomains` | `null` | Domains never to redact, for every call on this instance. |
|
|
238
|
+
|
|
239
|
+
The result cache, referential-integrity labels and custom patterns are all per
|
|
240
|
+
instance, which is what makes one instance per tenant the right default.
|
|
241
|
+
`clear()` releases the cache and the label mapping.
|
|
146
242
|
|
|
147
243
|
### Return Types
|
|
148
244
|
|
|
@@ -154,6 +250,8 @@ interface RedactResult {
|
|
|
154
250
|
detections: Detection[]; // All PII spans found
|
|
155
251
|
source: string; // Detection backend ("rules")
|
|
156
252
|
degraded: boolean; // True if the engine fell back to a simpler mode
|
|
253
|
+
tokens: Record<string, string>; // token -> original value; only with tokenize: true
|
|
254
|
+
exempted: Exemption[]; // spans the allowlist kept, with the rule that matched
|
|
157
255
|
}
|
|
158
256
|
```
|
|
159
257
|
|
|
@@ -210,6 +308,7 @@ interface RedactResult {
|
|
|
210
308
|
evidence: CountryEvidence[]; // every signal, with the span behind it
|
|
211
309
|
detectionMode: string; // "declared" if countries was passed,
|
|
212
310
|
// "inferred" otherwise
|
|
311
|
+
tokens: Record<string, string>; // token -> original value; only with tokenize: true
|
|
213
312
|
}
|
|
214
313
|
```
|
|
215
314
|
|
|
@@ -218,7 +317,7 @@ interface RedactResult {
|
|
|
218
317
|
String enum with all supported PII categories:
|
|
219
318
|
|
|
220
319
|
```
|
|
221
|
-
|
|
320
|
+
PERSON_NAME ADDRESS BANK_ACCOUNT BIC
|
|
222
321
|
CREDIT_CARD PHONE EMAIL DOB
|
|
223
322
|
DATE_OF_DEATH NATIONAL_ID SSN TAX_ID
|
|
224
323
|
PASSPORT DRIVERS_LICENSE RESIDENCE_PERMIT LICENSE_PLATE
|
|
@@ -226,6 +325,13 @@ VIN VAT POSTAL_CODE IP_ADDRESS
|
|
|
226
325
|
IPV6_ADDRESS MAC_ADDRESS HEALTH_INSURANCE HEALTHCARE_PROVIDER
|
|
227
326
|
CHAMBER_OF_COMMERCE IMEI GPS_COORDINATES UUID
|
|
228
327
|
SOCIAL_HANDLE SECRET INTERNAL_ID OTHER
|
|
328
|
+
|
|
329
|
+
Cloud tier only — the rule engine never emits these, because there is no shape
|
|
330
|
+
to match on. That is precisely why the model exists:
|
|
331
|
+
|
|
332
|
+
ORGANISATION_NAME JOB_TITLE MEDICAL_CONDITION SENSITIVE_ATTRIBUTE
|
|
333
|
+
BIOMETRIC_REF FINANCIAL_AMOUNT QUASI_IDENTIFIER CREDENTIAL
|
|
334
|
+
URL
|
|
229
335
|
```
|
|
230
336
|
|
|
231
337
|
`INTERNAL_ID` — an employee, badge or customer number tied to a person — is
|
|
@@ -264,6 +370,67 @@ masked is unchanged either way.
|
|
|
264
370
|
|
|
265
371
|
For custom patterns, `entityType` is a plain string (e.g. `"EMPLOYEE_ID"`).
|
|
266
372
|
|
|
373
|
+
## Cloud tier
|
|
374
|
+
|
|
375
|
+
> **Status: private alpha.** The cloud tier is in closed testing — it is **not**
|
|
376
|
+
> in public beta and is not generally available. Keys are issued to alpha
|
|
377
|
+
> participants only, and the request/response surface may still change between
|
|
378
|
+
> releases. The rules engine below is unaffected and is the supported path:
|
|
379
|
+
> `mode="rules"` is the default and carries no alpha caveat.
|
|
380
|
+
|
|
381
|
+
The rule engine catches what has a shape: IBANs, national IDs, phone numbers,
|
|
382
|
+
anything with a checksum. It cannot catch what does not — a person's name, an
|
|
383
|
+
employer, a diagnosis, a job title. The cloud tier adds a fine-tuned model
|
|
384
|
+
asked only *what did the rules miss?*
|
|
385
|
+
|
|
386
|
+
```ts
|
|
387
|
+
import { configure, redactAsync } from "euredact";
|
|
388
|
+
|
|
389
|
+
configure({ apiKey: "erk_..." }); // or set EUREDACT_API_KEY
|
|
390
|
+
const result = await redactAsync(text, { countries: ["BE"], mode: "cloud" });
|
|
391
|
+
|
|
392
|
+
result.source; // "cloud"
|
|
393
|
+
result.detections.map(d => [d.entityType, d.text]);
|
|
394
|
+
// [["PERSON_NAME", "Bas Verhoeven"], ["PHONE", "+32 ..."]]
|
|
395
|
+
```
|
|
396
|
+
|
|
397
|
+
**`redact()` is synchronous and cannot serve the cloud tier**, so
|
|
398
|
+
`redact(text, { mode: "cloud" })` throws and names `redactAsync`. It never
|
|
399
|
+
falls back to rules-only output: a caller who believes names and diagnoses were
|
|
400
|
+
checked, and ships a document that only had its phone numbers masked, is the
|
|
401
|
+
one failure this library must not have.
|
|
402
|
+
|
|
403
|
+
`redactAsync` with `mode: "rules"` resolves immediately with exactly what
|
|
404
|
+
`redact()` returns, so a caller that may or may not use the cloud tier can hold
|
|
405
|
+
one code path.
|
|
406
|
+
|
|
407
|
+
Retries carry an `Idempotency-Key`, so a retry after a timeout cannot bill
|
|
408
|
+
twice. `Retry-After` is obeyed. A document that outlives the service's sync
|
|
409
|
+
window is polled transparently. Oversized input rejects with `TooLargeError`
|
|
410
|
+
(413): the service refuses it rather than chunking, because the model has never
|
|
411
|
+
seen a chunk boundary.
|
|
412
|
+
|
|
413
|
+
Options the service cannot honour reject rather than being ignored: multiple
|
|
414
|
+
`countries`, `countryHint`, `context`/`chunkOffset` and `referentialIntegrity`.
|
|
415
|
+
`tokenize`, `allowlist` and `allowlistDomains` are honoured: the SDK applies them to the spans the
|
|
416
|
+
service returns and rebuilds the text from those.
|
|
417
|
+
|
|
418
|
+
The package stays **zero-dependency** — the client uses the platform's own
|
|
419
|
+
`fetch`. Node 18+ provides one; on Node 16 the rules engine is unaffected and a
|
|
420
|
+
`fetchImpl` can be supplied.
|
|
421
|
+
|
|
422
|
+
## `NAME` is now `PERSON_NAME`
|
|
423
|
+
|
|
424
|
+
The canonical type name is `PERSON_NAME`; `NAME` is a legacy alias, exactly as
|
|
425
|
+
`IBAN` aliases `BANK_ACCOUNT`. The placeholder written into redacted text is
|
|
426
|
+
`[PERSON_NAME]`.
|
|
427
|
+
|
|
428
|
+
Nothing could have depended on the old value: the type is cloud-only and the
|
|
429
|
+
cloud tier was stubbed until this release, so it was never emitted. Code
|
|
430
|
+
matching the *string* `"NAME"` should be updated; `LEGACY_TYPE_ALIASES`
|
|
431
|
+
publishes the mapping, and `STREET_ADDRESS` → `ADDRESS` and
|
|
432
|
+
`NATIONALITY_ETHNICITY` → `SENSITIVE_ATTRIBUTE` are recognised the same way.
|
|
433
|
+
|
|
267
434
|
## Country codes
|
|
268
435
|
|
|
269
436
|
`countries` accepts **ISO 3166-1 alpha-2** codes. The two EU/VAT spellings are
|
|
@@ -314,8 +481,9 @@ redact("Rekening: BE68 5390 0754 7034", { countries: ["AT"] });
|
|
|
314
481
|
### Which country a value belongs to
|
|
315
482
|
|
|
316
483
|
Because every pattern runs, the same digits often match several countries'
|
|
317
|
-
schemes.
|
|
318
|
-
country's
|
|
484
|
+
schemes. Of the national-ID values in our corpus that pass any country's checksum,
|
|
485
|
+
34.7% pass more than one country's (32,827 of 94,528), so the digits alone
|
|
486
|
+
cannot decide it — the *document* does.
|
|
319
487
|
|
|
320
488
|
The engine infers the document's countries from entities that carry their
|
|
321
489
|
country in the string, then uses that to resolve the ambiguity:
|
|
@@ -549,6 +717,120 @@ console.log(result.redactedText);
|
|
|
549
717
|
// "BSN NATIONAL_ID_1 en later weer NATIONAL_ID_1"
|
|
550
718
|
```
|
|
551
719
|
|
|
720
|
+
## Reversible tokenization
|
|
721
|
+
|
|
722
|
+
`tokenize: true` is for text that has to come back. Each value is replaced by a
|
|
723
|
+
token that names its type and nothing else, and the result carries the mapping
|
|
724
|
+
that turns tokens back into values:
|
|
725
|
+
|
|
726
|
+
```ts
|
|
727
|
+
import { redact, restore } from "euredact";
|
|
728
|
+
|
|
729
|
+
const prompt = "Write an email to Joren at joren.janssens@euredact.be or call +32 475 12 34 56 about the invoice.";
|
|
730
|
+
const result = redact(prompt, { countries: ["BE"], tokenize: true });
|
|
731
|
+
console.log(result.redactedText);
|
|
732
|
+
// "Write an email to Joren at EMAIL_K7Q2 or call PHONE_P4RT about the invoice."
|
|
733
|
+
console.log(result.tokens);
|
|
734
|
+
// { EMAIL_K7Q2: "joren.janssens@euredact.be", PHONE_P4RT: "+32 475 12 34 56" }
|
|
735
|
+
|
|
736
|
+
const reply = await callYourLlm(result.redactedText); // sees tokens, never the values
|
|
737
|
+
console.log(restore(reply, result.tokens));
|
|
738
|
+
// the reply, with the real address and number back in it
|
|
739
|
+
```
|
|
740
|
+
|
|
741
|
+
The suffixes are random; yours will differ. With the [cloud tier](#cloud-tier)
|
|
742
|
+
the name is tokenized too (`PERSON_NAME_W3NB`), since person names have no
|
|
743
|
+
shape for the rules tier to match on.
|
|
744
|
+
|
|
745
|
+
Within one call the same value gets the same token, so a prompt that names
|
|
746
|
+
someone twice still reads as one person. Across calls it gets a different
|
|
747
|
+
token: nothing is retained on the instance, `result.tokens` is the only copy,
|
|
748
|
+
and two tokenized documents never reveal that they share a value. That is the
|
|
749
|
+
opposite retention model from `referentialIntegrity`, which is why the two
|
|
750
|
+
cannot be combined. `redactBatch` tokenizes each text on its own.
|
|
751
|
+
|
|
752
|
+
A token is `TYPE_` plus four characters from `ABCDEFGHJKLMNPQRSTUVWXYZ23456789`
|
|
753
|
+
(no vowels, no `0`/`1`/`I`/`O`). Tokens are kept clear of any token-shaped
|
|
754
|
+
string already in the document, so an LLM's reply to a tokenized prompt can
|
|
755
|
+
itself be redacted without `restore()` putting the wrong value back.
|
|
756
|
+
`restore()` replaces every occurrence, including a token an LLM glued to other
|
|
757
|
+
characters (`EMAIL_P4RTs`) — leaving a token behind is the worse failure.
|
|
758
|
+
|
|
759
|
+
Works in cloud mode via `redactAsync`: the SDK rebuilds the text from the spans
|
|
760
|
+
the service returns, which is exactly what the service built its own output
|
|
761
|
+
from.
|
|
762
|
+
|
|
763
|
+
## Allowlist
|
|
764
|
+
|
|
765
|
+
Values a caller declares are not PII to them — their own organisation's name,
|
|
766
|
+
their own addresses. Set it per call, on the instance, or both; the two merge:
|
|
767
|
+
|
|
768
|
+
```ts
|
|
769
|
+
import { EuRedact } from "euredact";
|
|
770
|
+
|
|
771
|
+
const sdk = new EuRedact({ allowlist: ["ACME NV", "info@acme.be"] });
|
|
772
|
+
|
|
773
|
+
const text = "ACME NV: mail info@acme.be or jan@acme.be about IBAN NL91 ABNA 0417 1643 00.";
|
|
774
|
+
console.log(sdk.redact(text, { countries: ["NL"] }).redactedText);
|
|
775
|
+
// "ACME NV: mail info@acme.be or [EMAIL] about IBAN [BANK_ACCOUNT]."
|
|
776
|
+
|
|
777
|
+
console.log(sdk.redact(text, { countries: ["NL"], allowlist: ["jan@acme.be"] }).redactedText);
|
|
778
|
+
// "ACME NV: mail info@acme.be or jan@acme.be about IBAN [BANK_ACCOUNT]."
|
|
779
|
+
```
|
|
780
|
+
|
|
781
|
+
Matching is whole-span and case-insensitive. A bare string
|
|
782
|
+
(`allowlist: "ACME NV"`) throws `TypeError` rather than being iterated into
|
|
783
|
+
single letters that exempt nothing. Works in cloud mode via `redactAsync` —
|
|
784
|
+
including on types only the model finds, such as `ORGANISATION_NAME` — and
|
|
785
|
+
together with `tokenize`.
|
|
786
|
+
|
|
787
|
+
### Spacing and punctuation
|
|
788
|
+
|
|
789
|
+
For **structured identifiers** separators are presentational, so an allowlisted
|
|
790
|
+
value matches however the document writes it:
|
|
791
|
+
|
|
792
|
+
```ts
|
|
793
|
+
const sdk = new EuRedact({ allowlist: ["NL91ABNA0417164300"] });
|
|
794
|
+
sdk.redact("Pay to NL91 ABNA 0417 1643 00.", { countries: ["NL"] }).redactedText;
|
|
795
|
+
// 'Pay to NL91 ABNA 0417 1643 00.' — exempt, despite the spacing
|
|
796
|
+
```
|
|
797
|
+
|
|
798
|
+
Applies to `BANK_ACCOUNT`, `BIC`, `CREDIT_CARD`, `PHONE`, `VAT`, `NATIONAL_ID`,
|
|
799
|
+
`SSN`, `TAX_ID`, `PASSPORT`, `DRIVERS_LICENSE`, `RESIDENCE_PERMIT`,
|
|
800
|
+
`HEALTH_INSURANCE`, `CHAMBER_OF_COMMERCE`, `IMEI` and `VIN`. It widens the
|
|
801
|
+
*spelling*, never the *scope*: a different account is still redacted. Free-text
|
|
802
|
+
types stay literal, because `jan.devries@acme.be` and `jandevries@acme.be` are
|
|
803
|
+
different mailboxes at most providers.
|
|
804
|
+
|
|
805
|
+
### Exempting a whole domain
|
|
806
|
+
|
|
807
|
+
```ts
|
|
808
|
+
const sdk = new EuRedact({ allowlistDomains: ["acme.be"] });
|
|
809
|
+
sdk.redact("Mail jan@acme.be or piet@acme.be", { countries: ["NL"] }).redactedText;
|
|
810
|
+
// 'Mail jan@acme.be or piet@acme.be'
|
|
811
|
+
```
|
|
812
|
+
|
|
813
|
+
`EMAIL` and `URL` only, covering subdomains (`mail.acme.be`). The match is on a
|
|
814
|
+
label boundary, so `acme.be` does **not** exempt `evilacme.be`. Entries may be
|
|
815
|
+
written `acme.be`, `@acme.be` or `.acme.be`.
|
|
816
|
+
|
|
817
|
+
There are deliberately **no wildcards**. The allowlist is the only option that
|
|
818
|
+
turns redaction *off*, so an over-broad entry fails toward under-redaction and
|
|
819
|
+
does so silently.
|
|
820
|
+
|
|
821
|
+
### What was exempted
|
|
822
|
+
|
|
823
|
+
```ts
|
|
824
|
+
const r = redact("Mail jan@acme.be", { countries: ["NL"], allowlistDomains: ["acme.be"] });
|
|
825
|
+
for (const e of r.exempted) console.log(e.entityType, e.text, e.rule, e.ruleKind);
|
|
826
|
+
// EMAIL jan@acme.be acme.be domain
|
|
827
|
+
```
|
|
828
|
+
|
|
829
|
+
`Exemption` carries `entityType`, `start`, `end`, `text`, the `rule` that
|
|
830
|
+
matched as you wrote it, and `ruleKind` (`"value"` or `"domain"`). An exempted
|
|
831
|
+
span is absent from `detections`, so `exempted` is the only record that it was
|
|
832
|
+
found at all.
|
|
833
|
+
|
|
552
834
|
## Architecture
|
|
553
835
|
|
|
554
836
|
```
|
|
@@ -623,18 +905,24 @@ Two tiers are less obvious than they look:
|
|
|
623
905
|
|
|
624
906
|
## Performance
|
|
625
907
|
|
|
626
|
-
Measured on one core
|
|
627
|
-
|
|
908
|
+
Measured on one core (Apple Silicon M3 Pro, Node 22.12), all 31 countries
|
|
909
|
+
loaded, `detectDates: true`, cache off, over two cohorts sampled evenly from the
|
|
910
|
+
corpus: 3,000 distinct short records and 611 distinct real documents. Median per
|
|
911
|
+
document, 10th–90th percentile in brackets:
|
|
628
912
|
|
|
629
913
|
| Input | Latency | Throughput |
|
|
630
914
|
|---|---:|---:|
|
|
631
|
-
| Short record (
|
|
632
|
-
| Real document (3,
|
|
915
|
+
| Short record (~190 chars) | 154 µs <br><sub>89 – 237</sub> | 6,494 docs/s |
|
|
916
|
+
| Real document (~3,450 chars) | 1.43 ms <br><sub>1.04 – 1.96</sub> | 699 docs/s |
|
|
917
|
+
|
|
918
|
+
Cost tracks identifier density rather than length: the per-candidate cue and
|
|
919
|
+
suppressor checks dominate, so a form-like document dense in national IDs costs
|
|
920
|
+
several times an ordinary chat log of the same size.
|
|
633
921
|
|
|
634
922
|
No optional accelerator is needed or offered. The Python SDK ships an
|
|
635
923
|
`[fast]` extra (RE2 / Aho-Corasick) because CPython's regex engine is the
|
|
636
924
|
bottleneck there; V8's has literal prefilters that make it unnecessary here.
|
|
637
|
-
Measured on the same 611 documents, this SDK runs about
|
|
925
|
+
Measured on the same 611 documents, this SDK runs about 6–7× faster than the
|
|
638
926
|
accelerated Python path, so adding a native addon — and with it the loss of
|
|
639
927
|
bundler, edge-runtime and Deno compatibility — would buy nothing.
|
|
640
928
|
|
package/dist/cjs/cache.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
3
|
exports.ResultCache = void 0;
|
|
4
|
+
exports.resultChars = resultChars;
|
|
4
5
|
const node_crypto_1 = require("node:crypto");
|
|
5
6
|
// Default ceiling on retained characters, not entries. A count-only cap of
|
|
6
7
|
// 1024 entries says nothing about memory: 1024 cached 1 MB documents is a
|
|
@@ -11,6 +12,10 @@ function resultChars(result) {
|
|
|
11
12
|
let chars = result.redactedText.length;
|
|
12
13
|
for (const d of result.detections)
|
|
13
14
|
chars += d.text.length;
|
|
15
|
+
for (const [token, value] of Object.entries(result.tokens))
|
|
16
|
+
chars += token.length + value.length;
|
|
17
|
+
for (const e of result.exempted)
|
|
18
|
+
chars += e.text.length + e.rule.length;
|
|
14
19
|
return chars;
|
|
15
20
|
}
|
|
16
21
|
class ResultCache {
|