euredact 0.4.0 → 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 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");
@@ -99,7 +99,14 @@ const SHARED = {
99
99
  // is Unicode-aware) matched local parts like "petras_ž@..." while this one
100
100
  // did not. \p{L}\p{N} with the `u` flag restores parity; the leading guard
101
101
  // 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`),
102
+ // The apostrophe is allowed only between word characters: it belongs in a
103
+ // local part, but putting it in the class would let the match absorb a
104
+ // quote from the surrounding text, while leaving it out made
105
+ // `johno'neill@outlook.ie` mask as `johno'[EMAIL]` (issue rules-engine#10).
106
+ // The second lookbehind refuses to start a match after `<word-char>'`,
107
+ // which is a match already in progress; without it every apostrophe was a
108
+ // fresh start offset and the scan went quadratic. Mirrors Python.
109
+ 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
110
  // IBAN (any country) — structure + mod-97 only. Country-specific IBAN
104
111
  // patterns still exist and win on identical spans; this one guarantees an
105
112
  // IBAN is never missed just because its country was not requested.
@@ -109,7 +116,11 @@ const SHARED = {
109
116
  // "+43 664 8213 907" was invisible to the Austrian pattern, which expects
110
117
  // an unbroken subscriber number. A leading "+" is self-identifying, so
111
118
  // 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"),
119
+ // Parentheses are matched as a pair inside one group: `\(?...\)?` let the
120
+ // closing parenthesis of the surrounding text join the number, so
121
+ // "Call (+32 475 12 34 56) today." also masked the ")" (issue
122
+ // rules-engine#3). Mirrors the Python pattern character for character.
123
+ 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"),
113
124
  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
125
  // Space-separated form: "RZBA AT WW", "NICA BE BB". Written as a separate
115
126
  // pattern requiring a space between *every* group, rather than making the
@@ -120,7 +131,15 @@ const SHARED = {
120
131
  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
132
  p(types_js_1.EntityType.VIN, String.raw `\b[A-HJ-NPR-Z0-9]{17}\b`, "vin"),
122
133
  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`),
134
+ // Branch order is load-bearing: alternation is leftmost-first, not
135
+ // longest-match, so the form that consumes most is tried first, and the
136
+ // IPv4-embedded branches lead because every group of a dotted quad is
137
+ // also valid hex. `\b` is the wrong anchor around ":" (a non-word
138
+ // character), which is why `::1` and `2001:db8::` were not detected at
139
+ // all. A bare "::" is deliberately not matched. Mirrors the Python
140
+ // pattern character for character -- see the comment there
141
+ // (issue rules-engine#5).
142
+ 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
143
  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
144
  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
145
  p(types_js_1.EntityType.IMEI, String.raw `\b\d{15}\b`, "imei"),
@@ -224,12 +243,12 @@ const DE = {
224
243
  patterns: [
225
244
  p(types_js_1.EntityType.TAX_ID, String.raw `\b[1-9]\d{10}\b`, "german_tax_id"),
226
245
  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),
246
+ 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
247
  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
248
  p(types_js_1.EntityType.VAT, String.raw `\bDE\s?\d{9}\b`, "vat_de"),
230
249
  p(types_js_1.EntityType.PHONE, String.raw `\b0[1-9]\d{1,4}[\s/\-]?\d{3,8}\b`),
231
250
  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),
251
+ p(types_js_1.EntityType.PASSPORT, String.raw `\b[CFGHJK][0-9CFGHJKLMNPRTVWXYZ]{8}\d?\b`, null, "", ["Reisepass", "passport", "Passnummer", "Reisepassnummer", "Reisepass Nummer", "Reisepass-Nr", "Pass Nr"], true),
233
252
  // The separator after the city code is mandatory. When it was optional, a
234
253
  // contiguous letter run split across both groups and the hyphen was
235
254
  // consumed as the *second* separator, so any "LETTERS-DIGITS" token