euredact 0.4.0 → 0.5.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/README.md CHANGED
@@ -42,6 +42,11 @@ console.log(result.detections);
42
42
  OpenAI, Slack, JWT, SendGrid, plus Shannon entropy-based detection for generic
43
43
  high-entropy tokens near context keywords
44
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
45
50
  - **Checksum validation:** IBAN mod-97, Luhn (credit cards/IMEI), and 30+
46
51
  country-specific validators
47
52
  - **Priority-aware deduplication:** validated (checksum, corroborated by the
@@ -66,6 +71,26 @@ console.log(result.detections);
66
71
 
67
72
  ## API Reference
68
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
+
69
94
  ### Module-Level Functions
70
95
 
71
96
  #### `redact(text, options?)`
@@ -78,25 +103,68 @@ Main entry point. Detects and redacts PII in the given text.
78
103
 
79
104
  ```ts
80
105
  interface RedactOptions {
81
- countries?: string[] | null; // Scope. Flags anything attributed elsewhere as
82
- // outOfScope. Never gates what is looked for.
83
- countryHint?: string[] | null; // A prior only — resolves ambiguity without
84
- // narrowing scope or flagging anything
85
- context?: DocumentContext | null; // Share country evidence across the chunks
86
- // of one document (see "Chunked documents")
87
- chunkOffset?: number; // Where this chunk starts in the document
88
- referentialIntegrity?: boolean; // Replace with consistent labels (default: false)
89
- detectDates?: boolean; // Include DOB/date-of-death detections (default: false)
90
- cache?: boolean; // Enable result caching (default: true)
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
91
117
  }
92
118
  ```
93
119
 
120
+ **What is looked for**
121
+
94
122
  | Parameter | Default | Description |
95
123
  |---|---|---|
96
- | `countries` | `null` | ISO 3166-1 alpha-2 codes to restrict detection. `null` loads all 31 countries. |
97
- | `referentialIntegrity` | `false` | Replace PII with consistent labels instead of entity-type labels. |
98
- | `detectDates` | `false` | Include date-of-birth and date-of-death detections. Off by default. |
99
- | `cache` | `true` | Cache results for identical inputs. |
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
+
141
+ | Parameter | Default | Description |
142
+ |---|---|---|
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).
100
168
 
101
169
  #### `redactBatch(texts, options?)`
102
170
 
@@ -116,6 +184,16 @@ function addCustomPattern(name: string, pattern: string): void;
116
184
  Register a custom regex pattern. Matches are reported with `name` as the entity
117
185
  type. See [Custom Patterns](#custom-patterns) below.
118
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
+
119
197
  #### `availableCountries()`
120
198
 
121
199
  ```ts
@@ -142,8 +220,25 @@ console.log(result.redactedText);
142
220
  // "See [CASE_REF] for details"
143
221
  ```
144
222
 
145
- The `EuRedact` class exposes: `redact()`, `redactBatch()`, and
146
- `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.
147
242
 
148
243
  ### Return Types
149
244
 
@@ -155,6 +250,8 @@ interface RedactResult {
155
250
  detections: Detection[]; // All PII spans found
156
251
  source: string; // Detection backend ("rules")
157
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
158
255
  }
159
256
  ```
160
257
 
@@ -211,6 +308,7 @@ interface RedactResult {
211
308
  evidence: CountryEvidence[]; // every signal, with the span behind it
212
309
  detectionMode: string; // "declared" if countries was passed,
213
310
  // "inferred" otherwise
311
+ tokens: Record<string, string>; // token -> original value; only with tokenize: true
214
312
  }
215
313
  ```
216
314
 
@@ -314,6 +412,8 @@ seen a chunk boundary.
314
412
 
315
413
  Options the service cannot honour reject rather than being ignored: multiple
316
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.
317
417
 
318
418
  The package stays **zero-dependency** — the client uses the platform's own
319
419
  `fetch`. Node 18+ provides one; on Node 16 the rules engine is unaffected and a
@@ -617,6 +717,120 @@ console.log(result.redactedText);
617
717
  // "BSN NATIONAL_ID_1 en later weer NATIONAL_ID_1"
618
718
  ```
619
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
+
620
834
  ## Architecture
621
835
 
622
836
  ```
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 {
@@ -74,11 +74,33 @@ function toEntityType(raw) {
74
74
  const known = Object.values(types_js_1.EntityType).includes(canonical);
75
75
  return known ? canonical : canonical;
76
76
  }
77
+ /**
78
+ * Map the service's offsets, which count code points, onto this SDK's, which
79
+ * count UTF-16 units.
80
+ *
81
+ * The two agree until the first character outside the BMP — an emoji, say —
82
+ * after which every service offset is short by one unit per such character.
83
+ * Returns the identity when the text has none, which is the common case and
84
+ * costs one regex test.
85
+ */
86
+ function codePointOffsets(text) {
87
+ if (!/[\uD800-\uDBFF]/.test(text))
88
+ return cp => cp;
89
+ const units = [];
90
+ let i = 0;
91
+ for (const ch of text) {
92
+ units.push(i);
93
+ i += ch.length;
94
+ }
95
+ units.push(i);
96
+ return cp => units[Math.min(cp, units.length - 1)];
97
+ }
77
98
  function toResult(payload, text) {
99
+ const offset = codePointOffsets(text);
78
100
  const detections = (payload.entities ?? []).map(span => ({
79
101
  entityType: toEntityType(span.type),
80
- start: span.start,
81
- end: span.end,
102
+ start: offset(span.start),
103
+ end: offset(span.end),
82
104
  text: span.text,
83
105
  source: span.source === "model" ? types_js_1.DetectionSource.CLOUD : types_js_1.DetectionSource.RULES,
84
106
  country: null,
@@ -93,6 +115,8 @@ function toResult(payload, text) {
93
115
  inferredCountries: [],
94
116
  evidence: [],
95
117
  detectionMode: "declared",
118
+ tokens: {},
119
+ exempted: [],
96
120
  };
97
121
  }
98
122
  class CloudClient {
package/dist/cjs/index.js CHANGED
@@ -1,6 +1,6 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.TooLargeError = exports.QuotaExceededError = exports.NotConfiguredError = exports.CloudError = exports.CloudClient = exports.getConfig = exports.configure = exports.SEED_BIC6_PREFIXES = exports.getBicRegistry = exports.setBicRegistry = exports.COUNTRY_CONFIGS = exports.EuRedact = exports.DocumentContext = exports.canonicalType = exports.LEGACY_TYPE_ALIASES = exports.DetectionSource = exports.EntityType = void 0;
3
+ exports.TooLargeError = exports.QuotaExceededError = exports.NotConfiguredError = exports.CloudError = exports.CloudClient = exports.getConfig = exports.configure = exports.SEED_BIC6_PREFIXES = exports.getBicRegistry = exports.setBicRegistry = exports.COUNTRY_CONFIGS = exports.restore = exports.EuRedact = exports.DocumentContext = exports.canonicalType = exports.LEGACY_TYPE_ALIASES = exports.DetectionSource = exports.EntityType = void 0;
4
4
  exports.availableCountries = availableCountries;
5
5
  exports.redact = redact;
6
6
  exports.redactAsync = redactAsync;
@@ -16,6 +16,7 @@ var context_js_1 = require("./rules/context.js");
16
16
  Object.defineProperty(exports, "DocumentContext", { enumerable: true, get: function () { return context_js_1.DocumentContext; } });
17
17
  var sdk_js_1 = require("./sdk.js");
18
18
  Object.defineProperty(exports, "EuRedact", { enumerable: true, get: function () { return sdk_js_1.EuRedact; } });
19
+ Object.defineProperty(exports, "restore", { enumerable: true, get: function () { return sdk_js_1.restore; } });
19
20
  var index_js_1 = require("./rules/countries/index.js");
20
21
  Object.defineProperty(exports, "COUNTRY_CONFIGS", { enumerable: true, get: function () { return index_js_1.COUNTRY_CONFIGS; } });
21
22
  var bicRegistry_js_1 = require("./rules/bicRegistry.js");
@@ -31,6 +31,41 @@ function p(entityType, pattern, validator = null, description = "", contextKeywo
31
31
  // ---------------------------------------------------------------------------
32
32
  // Secret context keywords (consolidated, reused across SECRET patterns)
33
33
  // ---------------------------------------------------------------------------
34
+ /**
35
+ * Words that name a travel document, in the official languages of the 31
36
+ * countries this engine covers, plus English.
37
+ *
38
+ * Shared by every passport pattern. Before this existed each country kept its
39
+ * own list, so a passport was only recognised when its shape and its label
40
+ * came from the same country: `Reisepass: CA1234567` was missed because the
41
+ * German pattern rejects that alphabet while the Dutch pattern, whose shape
42
+ * fits, had never heard of "Reisepass" (issue rules-engine#23).
43
+ *
44
+ * The bare Scandinavian "pass", Finnish "passi" and Latvian "pase" are
45
+ * omitted deliberately: context matching is substring, not word-boundary, so
46
+ * they fire inside "password", "passenger", "passive" and "phase". Only the
47
+ * compound forms are listed. Mirrors PASSPORT_CONTEXT in Python.
48
+ */
49
+ const PASSPORT_CONTEXT = [
50
+ "passport", "passport no", "passport number", "travel document",
51
+ "reisepass", "passnummer", "reisepassnummer", "reisepass-nr", "pass nr",
52
+ "reisedokument",
53
+ "paspoort", "paspoortnummer", "reisdocument", "identiteitsbewijs",
54
+ "passeport", "numéro de passeport", "document de voyage",
55
+ "passaporto", "numero di passaporto", "documento di viaggio",
56
+ "pasaporte", "número de pasaporte", "documento de viaje",
57
+ "passaporte", "número do passaporte",
58
+ "passnr", "passets nummer", "passin numero", "passinumero",
59
+ "passi number", "passinumber",
60
+ "vegabréf", "vegabréfsnúmer",
61
+ "paso numeris", "pases numurs",
62
+ "paszport", "numer paszportu", "cestovní pas", "číslo pasu",
63
+ "cestovný pas", "potni list", "številka potnega lista",
64
+ "putovnica", "broj putovnice",
65
+ "útlevél", "útlevélszám", "pașaport", "numărul pașaportului",
66
+ "паспорт", "номер на паспорт", "διαβατήριο",
67
+ "αριθμός διαβατηρίου", "passaport",
68
+ ];
34
69
  const SECRET_CONTEXT = [
35
70
  // English
36
71
  "key", "token", "secret", "password", "credential",
@@ -99,7 +134,14 @@ const SHARED = {
99
134
  // is Unicode-aware) matched local parts like "petras_ž@..." while this one
100
135
  // did not. \p{L}\p{N} with the `u` flag restores parity; the leading guard
101
136
  // is a Unicode-safe word boundary.
102
- p(types_js_1.EntityType.EMAIL, String.raw `(?<![\p{L}\p{N}._%+\-])[\p{L}\p{N}._%+\-]+@(?:[\p{L}\p{N}\-]+\.)+\p{L}{2,}\b`),
137
+ // The apostrophe is allowed only between word characters: it belongs in a
138
+ // local part, but putting it in the class would let the match absorb a
139
+ // quote from the surrounding text, while leaving it out made
140
+ // `johno'neill@outlook.ie` mask as `johno'[EMAIL]` (issue rules-engine#10).
141
+ // The second lookbehind refuses to start a match after `<word-char>'`,
142
+ // which is a match already in progress; without it every apostrophe was a
143
+ // fresh start offset and the scan went quadratic. Mirrors Python.
144
+ p(types_js_1.EntityType.EMAIL, String.raw `(?<![\p{L}\p{N}._%+\-])(?<![\p{L}\p{N}._%+\-]')[\p{L}\p{N}._%+\-]+(?:'[\p{L}\p{N}._%+\-]+)*@(?:[\p{L}\p{N}\-]+\.)+\p{L}{2,}\b`),
103
145
  // IBAN (any country) — structure + mod-97 only. Country-specific IBAN
104
146
  // patterns still exist and win on identical spans; this one guarantees an
105
147
  // IBAN is never missed just because its country was not requested.
@@ -109,7 +151,22 @@ const SHARED = {
109
151
  // "+43 664 8213 907" was invisible to the Austrian pattern, which expects
110
152
  // an unbroken subscriber number. A leading "+" is self-identifying, so
111
153
  // this is deliberately not country-gated.
112
- p(types_js_1.EntityType.PHONE, String.raw `\+\d{1,3}(?:[\s\-/]?\(?\d{1,4}\)?){2,7}`, "e164", "International phone number (E.164) — any country"),
154
+ // Parentheses are matched as a pair inside one group: `\(?...\)?` let the
155
+ // closing parenthesis of the surrounding text join the number, so
156
+ // "Call (+32 475 12 34 56) today." also masked the ")" (issue
157
+ // rules-engine#3). Mirrors the Python pattern character for character.
158
+ p(types_js_1.EntityType.PHONE, String.raw `\+\d{1,3}(?:[\s\-/]?(?:\(\d{1,4}\)|\d{1,4})){2,7}`, "e164", "International phone number (E.164) — any country"),
159
+ // Passport (any country). The per-country patterns cover BE, DE, FR and NL
160
+ // only; the other 26 had none. Same argument as the E.164 phone rule: one
161
+ // country-independent shape, with the label carrying the precision.
162
+ // Shapes from the project canon (prompts/pii_definitions.md, PASSPORT):
163
+ // EU is 1-2 letters + 7 digits, UK is 9 digits optionally GBR-prefixed.
164
+ // The canon's position is that the EU converged on one shape, so national
165
+ // regexes would contradict it as well as being unverifiable. An earlier
166
+ // draft required a leading letter and missed all-numeric passports, the
167
+ // UK's own format among them; requiring exactly nine digits for the
168
+ // numeric form rejects eight-digit dates on shape (issue rules-engine#23).
169
+ p(types_js_1.EntityType.PASSPORT, String.raw `\b(?:GBR\d{9}|[A-Z]{1,2}\d{7}|\d{9})\b`, null, "Passport number — any country, label-gated", PASSPORT_CONTEXT, true),
113
170
  p(types_js_1.EntityType.BIC, String.raw `\b[A-Z]{4}[A-Z]{2}[A-Z0-9]{2}(?:[A-Z0-9]{3})?\b`, "bic"),
114
171
  // Space-separated form: "RZBA AT WW", "NICA BE BB". Written as a separate
115
172
  // pattern requiring a space between *every* group, rather than making the
@@ -120,7 +177,15 @@ const SHARED = {
120
177
  p(types_js_1.EntityType.CREDIT_CARD, String.raw `\b(?:4[0-9]{3}[\s\-]?[0-9]{4}[\s\-]?[0-9]{4}[\s\-]?[0-9]{4}|5[1-5][0-9]{2}[\s\-]?[0-9]{4}[\s\-]?[0-9]{4}[\s\-]?[0-9]{4}|3[47][0-9]{2}[\s\-]?[0-9]{6}[\s\-]?[0-9]{5})\b`, "luhn"),
121
178
  p(types_js_1.EntityType.VIN, String.raw `\b[A-HJ-NPR-Z0-9]{17}\b`, "vin"),
122
179
  p(types_js_1.EntityType.IP_ADDRESS, String.raw `\b(?:(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.){3}(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\b`),
123
- p(types_js_1.EntityType.IPV6_ADDRESS, String.raw `\b(?:[0-9a-fA-F]{1,4}:){7}[0-9a-fA-F]{1,4}\b|\b(?:[0-9a-fA-F]{1,4}:){1,7}:\b|\b(?:[0-9a-fA-F]{1,4}:){1,6}:[0-9a-fA-F]{1,4}\b|\b::(?:[0-9a-fA-F]{1,4}:){0,5}[0-9a-fA-F]{1,4}\b`),
180
+ // Branch order is load-bearing: alternation is leftmost-first, not
181
+ // longest-match, so the form that consumes most is tried first, and the
182
+ // IPv4-embedded branches lead because every group of a dotted quad is
183
+ // also valid hex. `\b` is the wrong anchor around ":" (a non-word
184
+ // character), which is why `::1` and `2001:db8::` were not detected at
185
+ // all. A bare "::" is deliberately not matched. Mirrors the Python
186
+ // pattern character for character -- see the comment there
187
+ // (issue rules-engine#5).
188
+ p(types_js_1.EntityType.IPV6_ADDRESS, String.raw `(?<![0-9A-Za-z_:.])(?:(?:[0-9A-Fa-f]{1,4}:){6}(?:(?:25[0-5]|2[0-4][0-9]|1[0-9]{2}|[1-9]?[0-9])\.){3}(?:25[0-5]|2[0-4][0-9]|1[0-9]{2}|[1-9]?[0-9])|(?:[0-9A-Fa-f]{1,4}:){1,5}:(?:[0-9A-Fa-f]{1,4}:)*(?:(?:25[0-5]|2[0-4][0-9]|1[0-9]{2}|[1-9]?[0-9])\.){3}(?:25[0-5]|2[0-4][0-9]|1[0-9]{2}|[1-9]?[0-9])|::(?:[0-9A-Fa-f]{1,4}:)*(?:(?:25[0-5]|2[0-4][0-9]|1[0-9]{2}|[1-9]?[0-9])\.){3}(?:25[0-5]|2[0-4][0-9]|1[0-9]{2}|[1-9]?[0-9])|(?:[0-9A-Fa-f]{1,4}:){7}[0-9A-Fa-f]{1,4}|(?:[0-9A-Fa-f]{1,4}:){1,7}(?::[0-9A-Fa-f]{1,4}){1,7}|(?:[0-9A-Fa-f]{1,4}:){1,7}:|:(?::[0-9A-Fa-f]{1,4}){1,7})(?:%[0-9A-Za-z][0-9A-Za-z._-]*)?(?![0-9A-Za-z_:])`),
124
189
  p(types_js_1.EntityType.MAC_ADDRESS, String.raw `\b(?:[0-9a-fA-F]{2}[:\-]){5}[0-9a-fA-F]{2}\b`, null, "colon/dash"),
125
190
  p(types_js_1.EntityType.MAC_ADDRESS, String.raw `\b[0-9a-fA-F]{4}\.[0-9a-fA-F]{4}\.[0-9a-fA-F]{4}\b`, null, "Cisco"),
126
191
  p(types_js_1.EntityType.IMEI, String.raw `\b\d{15}\b`, "imei"),
@@ -195,7 +260,7 @@ const NL = {
195
260
  p(types_js_1.EntityType.PHONE, String.raw `\b06[\s\-]?\d{2}[\s\-]?\d{2}[\s\-]?\d{2}[\s\-]?\d{2}\b`),
196
261
  p(types_js_1.EntityType.PHONE, String.raw `\b0[1-9]\d{1,2}[\s\-]?\d{2,3}[\s\-]?\d{2}[\s\-]?\d{2}\b`),
197
262
  p(types_js_1.EntityType.PHONE, String.raw `\+31\s?[1-9]\d{0,2}[\s\-]?\d{3,4}[\s\-]?\d{3,4}\b`),
198
- p(types_js_1.EntityType.PASSPORT, String.raw `\b[A-Z][A-Z0-9]{8}\b`, null, "", ["paspoort", "passport", "reisdocument", "travel document", "paspoortnummer", "identiteitsbewijs"], true),
263
+ p(types_js_1.EntityType.PASSPORT, String.raw `\b[A-Z][A-Z0-9]{8}\b`, null, "", PASSPORT_CONTEXT, true),
199
264
  p(types_js_1.EntityType.LICENSE_PLATE, String.raw `\b(?:[A-Z]{2}[\-\s]?\d{3}[\-\s]?[A-Z]|\d[\-\s]?[A-Z]{3}[\-\s]?\d{2}|\d{2}[\-\s]?[A-Z]{3}[\-\s]?\d|[A-Z]{2}[\-\s]?\d{2}[\-\s]?[A-Z]{2}|\d{2}[\-\s]?[A-Z]{2}[\-\s]?\d{2}|\d[\-\s]?[A-Z]{2}[\-\s]?\d{3})\b`),
200
265
  p(types_js_1.EntityType.POSTAL_CODE, String.raw `\b[1-9]\d{3}\s?[A-Z]{2}\b`),
201
266
  ],
@@ -210,7 +275,7 @@ const BE = {
210
275
  p(types_js_1.EntityType.CHAMBER_OF_COMMERCE, String.raw `\b0\d{3}\.?\d{3}\.?\d{3}\b`, null, "", ["KBO", "BCE", "ondernemingsnummer", "numéro d'entreprise", "enterprise number", "bedrijfsnummer", "Kruispuntbank", "Banque-Carrefour", "Banque Carrefour", "Ondernemingen", "Entreprises", "onder nummer"], true),
211
276
  p(types_js_1.EntityType.PHONE, String.raw `\b0[1-9]\d{0,2}[/\s.\-]?\d{2,3}[.\s\-]?\d{2,3}[.\s\-]?\d{2,3}\b`),
212
277
  p(types_js_1.EntityType.PHONE, String.raw `\+32\s?\d{1,3}[\s.\-]?\d{2,3}[\s.\-]?\d{2}[\s.\-]?\d{2}`),
213
- p(types_js_1.EntityType.PASSPORT, String.raw `\b[A-Z]{2}\d{6}\b`, null, "", ["paspoort", "passport", "passeport", "reisdocument", "travel document", "document de voyage"], true),
278
+ p(types_js_1.EntityType.PASSPORT, String.raw `\b[A-Z]{2}\d{6}\b`, null, "", PASSPORT_CONTEXT, true),
214
279
  p(types_js_1.EntityType.DRIVERS_LICENSE, String.raw `\b\d{10}\b`, null, "", ["rijbewijs", "permis de conduire", "driving licence", "driving license", "rijbewijsnummer"], true),
215
280
  p(types_js_1.EntityType.LICENSE_PLATE, String.raw `\b[12][\-\s]?[A-Z]{3}[\-\s]?\d{3}\b`),
216
281
  p(types_js_1.EntityType.POSTAL_CODE, String.raw `\b[1-9]\d{3}\b`, null, "", ["postcode", "code postal", "postnummer", "postal code", "zip", "B-", "adres", "adresse", "wonende", "woonplaats", "rue", "straat", "laan", "avenue", "boulevard", "plein", "steenweg", "chaussée", "domicilié", "gedomicilieerd", "Levering:", "siège"], true),
@@ -224,12 +289,12 @@ const DE = {
224
289
  patterns: [
225
290
  p(types_js_1.EntityType.TAX_ID, String.raw `\b[1-9]\d{10}\b`, "german_tax_id"),
226
291
  p(types_js_1.EntityType.TAX_ID, String.raw `\b[1-9]\d[\s.]?\d{3}[\s.]?\d{3}[\s.]?\d{3}\b`, "german_tax_id"),
227
- p(types_js_1.EntityType.NATIONAL_ID, String.raw `\b[CFGHJKLMNPRTVWXYZ][0-9A-Z]{8,9}\b`, null, "", ["Personalausweis", "Personalausweisnummer", "Ausweis", "Ausweisnummer", "Ausweis-Nr", "identity card", "Identitätskarte", "Perso", "PA-Nummer"], true),
292
+ p(types_js_1.EntityType.NATIONAL_ID, String.raw `\b[CFGHJKLMNPRTVWXYZ][0-9CFGHJKLMNPRTVWXYZ]{8}\d?\b`, null, "", ["Personalausweis", "Personalausweisnummer", "Ausweis", "Ausweisnummer", "Ausweis-Nr", "identity card", "Identitätskarte", "Perso", "PA-Nummer"], true),
228
293
  p(types_js_1.EntityType.IBAN, String.raw `\bDE\d{2}\s?\d{4}\s?\d{4}\s?\d{4}\s?\d{4}\s?\d{2}\b`, "iban"),
229
294
  p(types_js_1.EntityType.VAT, String.raw `\bDE\s?\d{9}\b`, "vat_de"),
230
295
  p(types_js_1.EntityType.PHONE, String.raw `\b0[1-9]\d{1,4}[\s/\-]?\d{3,8}\b`),
231
296
  p(types_js_1.EntityType.PHONE, String.raw `\+49\s?\d{2,5}[\s/\-]?\d{3,8}\b`),
232
- p(types_js_1.EntityType.PASSPORT, String.raw `\b[CFGHJK][0-9A-Z]{8,9}\b`, null, "", ["Reisepass", "passport", "Passnummer", "Reisepassnummer", "Reisepass Nummer", "Reisepass-Nr", "Pass Nr"], true),
297
+ p(types_js_1.EntityType.PASSPORT, String.raw `\b[CFGHJK][0-9CFGHJKLMNPRTVWXYZ]{8}\d?\b`, null, "", PASSPORT_CONTEXT, true),
233
298
  // The separator after the city code is mandatory. When it was optional, a
234
299
  // contiguous letter run split across both groups and the hyphen was
235
300
  // consumed as the *second* separator, so any "LETTERS-DIGITS" token
@@ -290,7 +355,7 @@ const FR = {
290
355
  p(types_js_1.EntityType.VAT, String.raw `\bFR\s?[0-9A-HJ-NP-Z]{2}\s?\d{9}\b`, "vat_fr"),
291
356
  p(types_js_1.EntityType.PHONE, String.raw `\b0[1-9][\s.\-]?\d{2}[\s.\-]?\d{2}[\s.\-]?\d{2}[\s.\-]?\d{2}\b`),
292
357
  p(types_js_1.EntityType.PHONE, String.raw `\+33\s?[1-9][\s.\-]?\d{2}[\s.\-]?\d{2}[\s.\-]?\d{2}[\s.\-]?\d{2}`),
293
- p(types_js_1.EntityType.PASSPORT, String.raw `\b\d{2}[A-Z]{2}\d{5}\b`, null, "", ["passeport", "passport", "numéro de passeport"], true),
358
+ p(types_js_1.EntityType.PASSPORT, String.raw `\b\d{2}[A-Z]{2}\d{5}\b`, null, "", PASSPORT_CONTEXT, true),
294
359
  p(types_js_1.EntityType.LICENSE_PLATE, String.raw `\b[A-Z]{2}[\-\s]?\d{3}[\-\s]?[A-Z]{2}\b`),
295
360
  p(types_js_1.EntityType.POSTAL_CODE, String.raw `\b(?:0[1-9]|[1-8]\d|9[0-5]|97[1-6])\d{3}\b`, null, "", ["code postal", "CP", "postal code", "postcode", "adresse", "domicilié", "résidant", "rue", "avenue", "boulevard", "place", "chemin", "allée", "impasse", "ville"], true),
296
361
  p(types_js_1.EntityType.CHAMBER_OF_COMMERCE, String.raw `\b\d{3}\s?\d{3}\s?\d{3}\b`, null, "", ["SIREN", "siren", "RCS", "entreprise", "immatricul", "numéro d'entreprise", "registre du commerce"], true),
@@ -69,8 +69,20 @@ const types_js_1 = require("../types.js");
69
69
  * How far back a cue may sit. Short on purpose: a cue is only evidence about
70
70
  * the value it introduces, which is the lesson of the postal-code year defect,
71
71
  * where a cue 150 characters away licensed every number in the window.
72
+ *
73
+ * This bounds how long a *label* may be, not how far it may sit from the
74
+ * value: every pattern is anchored with `$`, so the label still has to reach
75
+ * the span through a run-on or one qualifier word.
76
+ *
77
+ * 32 was too short for German tax labels, the longest the engine carries.
78
+ * "Steuerliche Identifikationsnummer " is 34 characters and
79
+ * "Steueridentifikationsnummer lautet: " is 36, so the label's own start sat
80
+ * outside the window and `(?<![A-Za-z0-9_])` had nothing to anchor against,
81
+ * leaving a critical-tier identifier in the clear (rules-engine#26). 44
82
+ * admits the longest with a qualifier: "Steuerliche Identifikationsnummer
83
+ * lautet: " is 42.
72
84
  */
73
- exports.CUE_WINDOW = 32;
85
+ exports.CUE_WINDOW = 44;
74
86
  /**
75
87
  * (entity type, label pattern). Ordered: the first match wins, so the narrower
76
88
  * label comes first where two could read the same text.
@@ -93,8 +105,15 @@ exports.CUES = [
93
105
  /(?<![A-Za-z0-9_])(?:ssn|social\s*security|sozialversicherungsnummer|svnr|sofi)(?:(?:[A-Za-z0-9_]|[^\x00-\x7F])*|\s+[^\s:.\-\d,;()\/]{2,20})\s*\)?\s*[:.\-]*\s*$/i],
94
106
  // Tax number. Greece's ΑΦΜ is issued by the tax authority; the identity-card
95
107
  // number is the ΑΔΤ, above.
108
+ //
109
+ // The German long forms are spelled out rather than left to the run-on after
110
+ // `steuer-?id`. A cue may use its run-on *or* one qualifier word, never both,
111
+ // and "Steuer-IdNr lautet:" needs both: the run-on for "Nr" and the qualifier
112
+ // for "lautet". Naming the whole label leaves the qualifier free.
113
+ // "Steuerliche Identifikationsnummer" matched nothing at all — it is two
114
+ // words, and `steuer-?id` cannot reach across "liche " (rules-engine#26).
96
115
  [types_js_1.EntityType.TAX_ID,
97
- /(?<![A-Za-z0-9_])(?:αφμ|α\.φ\.μ\.|steuer-?id|steuernummer|st\.?-?nr|stnr|tin|finanzamt\s+ist|tax\s*(?:id|no|number)|daňové\s*číslo|numer\s*podatkowy)(?:(?:[A-Za-z0-9_]|[^\x00-\x7F])*|\s+[^\s:.\-\d,;()\/]{2,20})\s*\)?\s*[:.\-]*\s*$/i],
116
+ /(?<![A-Za-z0-9_])(?:αφμ|α\.φ\.μ\.|steuerliche\s*identifikations-?nummer|steuer-?identifikations-?nummer|steuer-?id-?nr\.?|steuer-?id|steuernummer|st\.?-?nr|stnr|tin|finanzamt\s+ist|tax\s*(?:id|no|number)|daňové\s*číslo|numer\s*podatkowy)(?:(?:[A-Za-z0-9_]|[^\x00-\x7F])*|\s+[^\s:.\-\d,;()\/]{2,20})\s*\)?\s*[:.\-]*\s*$/i],
98
117
  // Health insurance: the insured person.
99
118
  [types_js_1.EntityType.HEALTH_INSURANCE,
100
119
  /(?<![A-Za-z0-9_])(?:versichertennummer|krankenversicherung|kvnr|kv-?nr|mutualité|ziekenfonds|aansluitingsnummer|medical\s*card(?:\s*(?:no|number))?)(?:(?:[A-Za-z0-9_]|[^\x00-\x7F])*|\s+[^\s:.\-\d,;()\/]{2,20})\s*\)?\s*[:.\-]*\s*$/i],