euredact 0.3.6 → 0.3.8

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
@@ -167,7 +167,7 @@ interface Detection {
167
167
  text: string; // The matched substring
168
168
  source: DetectionSource; // "rules" or "cloud"
169
169
  country: string | null; // ISO code or null for shared/custom patterns
170
- confidence: string; // Confidence level
170
+ confidence: string; // "high" | "medium" | "low" — see below
171
171
  countryConfidence?: number; // How strongly the document supports `country`,
172
172
  // in [0, 1]. 0 means the attribution rests on
173
173
  // a checksum alone.
@@ -176,6 +176,26 @@ interface Detection {
176
176
  }
177
177
  ```
178
178
 
179
+ **`confidence`** describes how the *type* was arrived at. It never says anything
180
+ about whether the span is masked — every detection is, at every level.
181
+
182
+ | value | meaning |
183
+ |---|---|
184
+ | `"high"` | a pattern matched and, where one exists, its checksum passed |
185
+ | `"medium"` | the type comes from a label touching the span, because no pattern of that type claimed it — `Αρ. Ταυτότητας: 00892341` is a `NATIONAL_ID` although nothing can checksum it |
186
+ | `"low"` | a pattern matched, its checksum *failed*, and the document labels the span as that very type — `Rijksregisternummer: 85.03.19-284.73` is a national number with a bad check digit |
187
+
188
+ Filter on it when you need only checksum-backed detections:
189
+
190
+ ```ts
191
+ const strict = result.detections.filter(d => d.confidence === "high");
192
+ ```
193
+
194
+ A `"low"` detection is the honest description of a mistyped, OCR'd or invented
195
+ identifier: the shape and the label agree, the check digit does not. Earlier
196
+ versions dropped these, which meant a redaction library printed in full an
197
+ identifier it had recognised and rejected.
198
+
179
199
  #### `RedactResult`
180
200
 
181
201
  ```ts
@@ -205,9 +225,16 @@ PASSPORT DRIVERS_LICENSE RESIDENCE_PERMIT LICENSE_PLATE
205
225
  VIN VAT POSTAL_CODE IP_ADDRESS
206
226
  IPV6_ADDRESS MAC_ADDRESS HEALTH_INSURANCE HEALTHCARE_PROVIDER
207
227
  CHAMBER_OF_COMMERCE IMEI GPS_COORDINATES UUID
208
- SOCIAL_HANDLE SECRET OTHER
228
+ SOCIAL_HANDLE SECRET INTERNAL_ID OTHER
209
229
  ```
210
230
 
231
+ `INTERNAL_ID` — an employee, badge or customer number tied to a person — is
232
+ emitted **only** when an explicit label names it (`medarbejdernummer:`,
233
+ `Personalnummer:`, `Employee No:`). There is no pattern for one, because there
234
+ is no shape for one: without the label, a digit run is not distinguishable from
235
+ any other. The type exists so that a labelled employee number is filed
236
+ correctly instead of being claimed by the phone pattern.
237
+
211
238
  For custom patterns, `entityType` is a plain string (e.g. `"EMPLOYEE_ID"`).
212
239
 
213
240
  ## Country codes
@@ -313,7 +340,7 @@ for (const page of pages) {
313
340
  offset += page.length;
314
341
  }
315
342
 
316
- // page 1: "Factuur — IBAN NL91ABNA0417164300, info@jansen.nl"
343
+ // page 1: "Factuur — IBAN NL91ABNA0417164300, info@example.nl"
317
344
  // page 7: "Telefoon 0612345678" -> PHONE (NL), not NATIONAL_ID (DK)
318
345
  ```
319
346
 
package/dist/cjs/cache.js CHANGED
@@ -1,30 +1,44 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.ResultCache = void 0;
4
- // Simple hash function (FNV-1a) - no crypto dependency needed for cache keys
5
- function fnv1aHash(str) {
6
- let hash = 0x811c9dc5;
7
- for (let i = 0; i < str.length; i++) {
8
- hash ^= str.charCodeAt(i);
9
- hash = (hash * 0x01000193) >>> 0;
10
- }
11
- return hash.toString(16).padStart(8, "0");
4
+ const node_crypto_1 = require("node:crypto");
5
+ // Default ceiling on retained characters, not entries. A count-only cap of
6
+ // 1024 entries says nothing about memory: 1024 cached 1 MB documents is a
7
+ // gigabyte of PII-bearing text held live. ~16M characters is a few tens of MB.
8
+ const DEFAULT_MAX_CHARS = 16_000_000;
9
+ /** Approximate retained size of a cached result, in characters. */
10
+ function resultChars(result) {
11
+ let chars = result.redactedText.length;
12
+ for (const d of result.detections)
13
+ chars += d.text.length;
14
+ return chars;
12
15
  }
13
16
  class ResultCache {
14
17
  maxsize;
18
+ maxChars;
15
19
  enabled;
16
20
  store = new Map();
17
- constructor(maxsize = 1024, enabled = true) {
21
+ sizes = new Map();
22
+ chars = 0;
23
+ constructor(maxsize = 1024, enabled = true, maxChars = DEFAULT_MAX_CHARS) {
18
24
  this.maxsize = maxsize;
25
+ this.maxChars = maxChars;
19
26
  this.enabled = enabled;
20
27
  }
21
28
  key(text, countries, mode) {
22
29
  const sorted = [...countries].sort();
23
- const raw = `${text}|${sorted.join("|")}|${mode}`;
24
- // Include raw length to reduce collision risk alongside dual FNV-1a hashes
25
- const h1 = fnv1aHash(raw);
26
- const h2 = fnv1aHash(raw.slice(Math.floor(raw.length / 2)) + raw.slice(0, Math.floor(raw.length / 2)));
27
- return `${raw.length}:${h1}${h2}`;
30
+ // SHA-256, matching the Python implementation. The previous dual FNV-1a
31
+ // was non-cryptographic and collidable by construction: in a shared-process
32
+ // deployment an attacker who can guess a victim's document could submit a
33
+ // same-length colliding one and be served the victim's cached result,
34
+ // detections and raw matched PII included.
35
+ return (0, node_crypto_1.createHash)("sha256")
36
+ .update(text)
37
+ .update("|")
38
+ .update(sorted.join("|"))
39
+ .update("|")
40
+ .update(mode)
41
+ .digest("hex");
28
42
  }
29
43
  get(key) {
30
44
  if (!this.enabled)
@@ -41,16 +55,36 @@ class ResultCache {
41
55
  put(key, result) {
42
56
  if (!this.enabled)
43
57
  return;
44
- this.store.delete(key);
58
+ const size = resultChars(result);
59
+ // A single result bigger than the whole budget would evict everything else
60
+ // and still not fit; skip it rather than empty the cache for one document.
61
+ if (size > this.maxChars) {
62
+ this.evict(key);
63
+ return;
64
+ }
65
+ this.evict(key);
45
66
  this.store.set(key, result);
46
- while (this.store.size > this.maxsize) {
67
+ this.sizes.set(key, size);
68
+ this.chars += size;
69
+ while (this.store.size > this.maxsize || this.chars > this.maxChars) {
47
70
  const first = this.store.keys().next().value;
48
- if (first !== undefined)
49
- this.store.delete(first);
71
+ if (first === undefined)
72
+ break;
73
+ this.evict(first);
50
74
  }
51
75
  }
76
+ evict(key) {
77
+ const size = this.sizes.get(key);
78
+ if (size !== undefined) {
79
+ this.chars -= size;
80
+ this.sizes.delete(key);
81
+ }
82
+ this.store.delete(key);
83
+ }
52
84
  clear() {
53
85
  this.store.clear();
86
+ this.sizes.clear();
87
+ this.chars = 0;
54
88
  }
55
89
  }
56
90
  exports.ResultCache = ResultCache;
@@ -7,7 +7,7 @@
7
7
  * contain no IBAN, no `+CC` number and no ccTLD infers nothing at all, even
8
8
  * when the first page identified the document beyond doubt:
9
9
  *
10
- * page 1 "Factuur — IBAN NL91ABNA0417164300, info@jansen.nl"
10
+ * page 1 "Factuur — IBAN NL91ABNA0417164300, info@example.nl"
11
11
  * page 7 "Telefoon 0612345678" <- Dutch mobile, or Danish CPR?
12
12
  *
13
13
  * Scanned alone, page 7 has nothing to go on. Sharing a context across the two
@@ -160,7 +160,20 @@ const SHARED = {
160
160
  // --- Secret / API Key (assignment-based: KEY=value or KEY: value) ---
161
161
  p(types_js_1.EntityType.SECRET, String.raw `(?<=[:=] )\S{8,}|(?<=[:=])\S{8,}`, "high_entropy", "Assigned secret value", SECRET_CONTEXT, true),
162
162
  // --- Secret / API Key (entropy-based fallback for longer tokens) ---
163
- p(types_js_1.EntityType.SECRET, String.raw `\b[A-Za-z0-9_\-+/]{24,}[A-Za-z0-9_\-+/=]*\b`, "high_entropy", "High-entropy token", SECRET_CONTEXT, true),
163
+ // Only the *trailing* \b is replaced. It could not match when the token run
164
+ // ended on "-", "+" or "/", so the engine backtracked across two
165
+ // overlapping quantified classes: an ordinary dashed rule line ("x-----...")
166
+ // cost O(n^2), 8.3 s for 80 KB. The lookahead simply asserts the run is
167
+ // over, which is what \b was there to express, and it cannot fail after a
168
+ // maximal run — so there is nothing to backtrack into.
169
+ //
170
+ // The leading \b stays. A class lookbehind there widened the span leftwards
171
+ // over any "/" in front, so an authorized_keys entry started "//...",
172
+ // matched the protocol-relative URL shape in the endpoint suppressor, and
173
+ // was dropped — leaving SSH key material in the clear. Base64 padding is
174
+ // now inside the span instead of trailing outside it; that is the only
175
+ // intended difference.
176
+ p(types_js_1.EntityType.SECRET, String.raw `\b[A-Za-z0-9_\-+/]{24,}[A-Za-z0-9_\-+/=]*(?![A-Za-z0-9_\-+/=])`, "high_entropy", "High-entropy token", SECRET_CONTEXT, true),
164
177
  p(types_js_1.EntityType.DOB, String.raw `\b(?:0[1-9]|[12][0-9]|3[01])[/.\-](?:0[1-9]|1[0-2])[/.\-](?:19|20)\d{2}\b`, null, "DD/MM/YYYY", DOB_CONTEXT, true),
165
178
  p(types_js_1.EntityType.DATE_OF_DEATH, String.raw `\b(?:0[1-9]|[12][0-9]|3[01])[/.\-](?:0[1-9]|1[0-2])[/.\-](?:19|20)\d{2}\b`, null, "", DATE_OF_DEATH_CONTEXT, true),
166
179
  p(types_js_1.EntityType.DOB, String.raw `\b(?:19|20)\d{2}[/.\-](?:0[1-9]|1[0-2])[/.\-](?:0[1-9]|[12][0-9]|3[01])\b`, null, "ISO YYYY-MM-DD", DOB_ISO_CONTEXT, true),
@@ -0,0 +1,168 @@
1
+ "use strict";
2
+ /**
3
+ * Local cues: what the document calls the value sitting next to it.
4
+ *
5
+ * A label touching the left edge of a span is the strongest statement a
6
+ * document makes about what that value *is*. "Phone: 0705237535" is a phone
7
+ * number even though those digits pass the Swedish personnummer checksum, and
8
+ * "Αρ. Ταυτότητας: 00892341" is an identity-card number even though nothing in
9
+ * the engine can checksum it.
10
+ *
11
+ * This table is the single source of that knowledge. Three callers read it, and
12
+ * none of them may move a span:
13
+ *
14
+ * 1. `localCueBonus` — ranks a candidate above its rivals on the *same span*
15
+ * when the cue names its type. The oldest use, and why the table exists.
16
+ * 2. `detectWithEvidence` — re-admits a candidate whose checksum failed when the
17
+ * cue names that same type. A declined identifier with its own label in front
18
+ * of it is still that identifier, mistyped or truncated.
19
+ * 3. `retypedBy` — relabels a winning generic candidate whose type the cue
20
+ * contradicts, when nothing of the cued type claimed the span.
21
+ *
22
+ * Consolidated here from two places that had drifted apart: `LOCAL_CUES` in
23
+ * `engine.ts` (typed, but missing most of Europe) and `ID_LABEL_BEFORE` in
24
+ * `suppressors.ts` (broader, but untyped — it could only *delete* a phone
25
+ * candidate, which left the value unmasked).
26
+ *
27
+ * Kept character-for-character in step with `euredact/rules/cues.py`. Two
28
+ * constructs are written the way they are so that both engines agree:
29
+ *
30
+ * * Boundaries are `(?<![A-Za-z0-9_])`, not `\b`. JavaScript's `\b` is
31
+ * ASCII-only, so `\bΑΦΜ` never matches — a Greek letter is not a word
32
+ * character there, and a space before it is not a transition. A boundary of
33
+ * some kind is required regardless: without one the short alternatives match
34
+ * the *tail* of an ordinary word. "NIS" matched "tālru**nis**", the Latvian
35
+ * for telephone, and suppressed 653 real phone numbers; "iva" matched
36
+ * "Pr**iva**t:" and read a personal e-mail address as a VAT number.
37
+ * * The qualifier class is spelled out rather than written `[^\W\d_]`, which
38
+ * would reject "εταιρειας" here for the same ASCII reason.
39
+ *
40
+ * Every label ends in `(?:\w*|\s+<one word>)`: the label either runs on into a
41
+ * longer word ("telefoon" → "telefoonnummer") **or** takes one qualifier word
42
+ * naming whose identifier it is ("ΑΦΜ εταιρειας:", "Tel Nr:") — never both.
43
+ * Allowing both let "nie" grow into the surname "Nieminen" and then swallow the
44
+ * real label in "Matti Nieminen\nDOB: 08.05.1994", reading a date of birth as a
45
+ * Spanish NIE: 52 false positives across the corpus.
46
+ */
47
+ Object.defineProperty(exports, "__esModule", { value: true });
48
+ exports.CUE_TARGETS = exports.CUES = exports.CUE_WINDOW = void 0;
49
+ exports.cuedType = cuedType;
50
+ exports.retypedBy = retypedBy;
51
+ const types_js_1 = require("../types.js");
52
+ /**
53
+ * How far back a cue may sit. Short on purpose: a cue is only evidence about
54
+ * the value it introduces, which is the lesson of the postal-code year defect,
55
+ * where a cue 150 characters away licensed every number in the window.
56
+ */
57
+ exports.CUE_WINDOW = 22;
58
+ /**
59
+ * (entity type, label pattern). Ordered: the first match wins, so the narrower
60
+ * label comes first where two could read the same text.
61
+ */
62
+ exports.CUES = [
63
+ // Phone. Ranking only — nothing is ever relabelled *to* PHONE: a phone cue in
64
+ // front of a span that no phone pattern matched says the document is laid out
65
+ // unusually, not that the value is a phone number.
66
+ [types_js_1.EntityType.PHONE,
67
+ /(?<![A-Za-z0-9_])(?:phone|tel|telephone|téléphone|telefoon|telefon|telefono|teléfono|tlf|mobil|mobile|gsm|handy|sími|puh|móvil|cell|tālrunis|τηλέφωνο|τηλ)(?:\w*|\s+[^\s:.\-\d,;()/]{2,20})\s*[:.\-]?\s*$/i],
68
+ // National identity number.
69
+ [types_js_1.EntityType.NATIONAL_ID,
70
+ /(?<![A-Za-z0-9_])(?:bsn|personnummer|rijksregisternummer|nationaal\s*nummer|numéro\s*national|national\s*(?:id|number)|identiteitsnummer|henkilötunnus|kennitala|cpr(?:-?nummer)?|nif|nie|dni|rr|nn|nir|insz|niss|nis|matricule|ausweisnummer|personalausweis|osobní\s*číslo|ЕГН|EGN)(?:\w*|\s+[^\s:.\-\d,;()/]{2,20})\s*[:.\-]?\s*$/i],
71
+ // Cyprus: "ID number" in both official languages, plus the Greek abbreviation
72
+ // ΑΔΤ (Αστυνομική Ταυτότητα). Neither has a phone reading.
73
+ [types_js_1.EntityType.NATIONAL_ID,
74
+ /(?<![A-Za-z0-9_])(?:αρ\.?\s*ταυτότητας|ταυτότητας|αδτ|kimlik\s*numaras[ıi])(?:\w*|\s+[^\s:.\-\d,;()/]{2,20})\s*[:.\-]?\s*$/i],
75
+ // Social security.
76
+ [types_js_1.EntityType.SSN,
77
+ /(?<![A-Za-z0-9_])(?:ssn|social\s*security|sozialversicherungsnummer|svnr|sofi)(?:\w*|\s+[^\s:.\-\d,;()/]{2,20})\s*[:.\-]?\s*$/i],
78
+ // Tax number. Greece's ΑΦΜ is issued by the tax authority; the identity-card
79
+ // number is the ΑΔΤ, above. The engine used to return NATIONAL_ID for a value
80
+ // cued ΑΦΜ, which is wrong independently of the phone problem.
81
+ [types_js_1.EntityType.TAX_ID,
82
+ /(?<![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)(?:\w*|\s+[^\s:.\-\d,;()/]{2,20})\s*[:.\-]?\s*$/i],
83
+ // Health insurance.
84
+ [types_js_1.EntityType.HEALTH_INSURANCE,
85
+ /(?<![A-Za-z0-9_])(?:versichertennummer|krankenversicherung|kvnr|kv-?nr|mutualité|ziekenfonds|aansluitingsnummer)(?:\w*|\s+[^\s:.\-\d,;()/]{2,20})\s*[:.\-]?\s*$/i],
86
+ // Company / trade register.
87
+ [types_js_1.EntityType.CHAMBER_OF_COMMERCE,
88
+ /(?<![A-Za-z0-9_])(?:siren|siret|kbo|bce|kvk|ondernemingsnummer|ondernemingen\s+onder\s+nummer|kruispuntbank|numéro\s*d'entreprise|enterprise\s*number|handelsregister|company\s*(?:no|number|reg)|organisationsnummer|orgnr|i[čc]o|identifikační\s*číslo|ЕИК|eik|bulstat|cvr(?:-?nummer)?|virksomhedsnummer)(?:\w*|\s+[^\s:.\-\d,;()/]{2,20})\s*[:.\-]?\s*$/i],
89
+ // VAT.
90
+ [types_js_1.EntityType.VAT,
91
+ /(?<![A-Za-z0-9_])(?:vat|btw|tva|ust|iva|moms|alv|mwst|dič|vsk)(?:[\w.\-]*|\s+[^\s:.\-\d,;()/]{2,20})\s*[:.\-]?\s*$/i],
92
+ // BIC / SWIFT.
93
+ [types_js_1.EntityType.BIC,
94
+ /(?<![A-Za-z0-9_])(?:bic|swift)[\w/]*\s*[:.\-]?\s*\(?\s*$/i],
95
+ // Postal code. A postal code loses every contested span on priority (it is
96
+ // the weakest evidence the engine has), so without a cue entry
97
+ // "Cod postal: 040171" was handed to a German phone pattern.
98
+ [types_js_1.EntityType.POSTAL_CODE,
99
+ /(?<![A-Za-z0-9_])(?:cod\s*po[sș]tal|postnummer|postnr|postleitzahl|plz|postcode|code\s*postal|codice\s*postale|kod\s*pocztowy|ps[čc]|τ\.?κ\.?|пощенски\s*код)(?:\w*|\s+[^\s:.\-\d,;()/]{2,20})\s*[:.\-]?\s*$/i],
100
+ // Internal / employee identifier. The canon calls these INTERNAL_ID and
101
+ // annotates them with an LLM, so the engine has no pattern for them — but it
102
+ // does have a phone pattern that was happily claiming them. The cue is the
103
+ // only thing that identifies one, which is why the engine emits this type
104
+ // from here and nowhere else.
105
+ [types_js_1.EntityType.INTERNAL_ID,
106
+ /(?<![A-Za-z0-9_])(?:medarbejdernummer|ansattnummer|anställningsnummer|personalnummer|personeelsnummer|matricule\s*salarié|employee\s*(?:no|number|id)|badge\s*(?:no|number)|osobní\s*(?:číslo\s*)?zaměstnance)(?:\w*|\s+[^\s:.\-\d,;()/]{2,20})\s*[:.\-]?\s*$/i],
107
+ ];
108
+ /**
109
+ * The entity type a label immediately before `start` names, or null.
110
+ *
111
+ * Reads at most `CUE_WINDOW` characters, and every pattern is anchored to the
112
+ * end of that window, so the cue must *touch* the span. A label further away is
113
+ * about some other value.
114
+ */
115
+ function cuedType(text, start) {
116
+ const before = text.slice(Math.max(0, start - exports.CUE_WINDOW), start);
117
+ for (const [entityType, pattern] of exports.CUES) {
118
+ if (pattern.test(before))
119
+ return entityType;
120
+ }
121
+ return null;
122
+ }
123
+ /**
124
+ * Shapes so generic that a label in front is better evidence than the pattern
125
+ * that matched. A bare digit run is not a phone number because it *could* be
126
+ * dialled.
127
+ */
128
+ const RETYPABLE = new Set([
129
+ types_js_1.EntityType.PHONE, types_js_1.EntityType.POSTAL_CODE, types_js_1.EntityType.LICENSE_PLATE,
130
+ ]);
131
+ /**
132
+ * Types a label can assert on its own. Also the types eligible for the rescue
133
+ * in `engine.ts`, for the same reason read the other way — a label may vouch
134
+ * for one of these, so it may also vouch for a malformed one.
135
+ *
136
+ * PHONE is absent on purpose: "Tel:" in front of something no phone pattern
137
+ * matched means the document is laid out oddly, not that the value is a phone
138
+ * number. So are EMAIL, SECRET, DOB, VIN and the rest — those carry their own
139
+ * structure, and a nearby word is not entitled to overrule it.
140
+ *
141
+ * BIC's absence is what keeps the rescue honest. Its validator is a *registry
142
+ * lookup*, not a checksum, so a failure means "no such bank", which no label can
143
+ * talk you out of. Rescuing it read 34 unlisted codes behind a "BIC:" label as
144
+ * real ones. The same reasoning keeps CREDIT_CARD and BANK_ACCOUNT out: Luhn and
145
+ * mod-97 are strong enough that a failure really does mean "not one of these".
146
+ */
147
+ exports.CUE_TARGETS = new Set([
148
+ types_js_1.EntityType.NATIONAL_ID, types_js_1.EntityType.SSN, types_js_1.EntityType.TAX_ID,
149
+ types_js_1.EntityType.HEALTH_INSURANCE, types_js_1.EntityType.CHAMBER_OF_COMMERCE,
150
+ types_js_1.EntityType.VAT, types_js_1.EntityType.POSTAL_CODE, types_js_1.EntityType.PASSPORT,
151
+ types_js_1.EntityType.INTERNAL_ID,
152
+ ]);
153
+ /**
154
+ * The type a cue overrules `entityType` with, or null to leave it alone.
155
+ *
156
+ * Both gates are load-bearing. Measured over 2,000 corpus documents, re-typing
157
+ * without them fired 52 times and got roughly 30 of those wrong — EMAIL read as
158
+ * VAT, a date of birth as a phone number, a VIN as VAT — against 20 real fixes.
159
+ * With them: 14 re-typings, every one of them PHONE to a structured identifier.
160
+ */
161
+ function retypedBy(text, start, entityType) {
162
+ if (!RETYPABLE.has(String(entityType)))
163
+ return null;
164
+ const cued = cuedType(text, start);
165
+ if (cued === null || cued === entityType || !exports.CUE_TARGETS.has(String(cued)))
166
+ return null;
167
+ return cued;
168
+ }
@@ -38,6 +38,7 @@ exports.resolveCountryCode = resolveCountryCode;
38
38
  const types_js_1 = require("../types.js");
39
39
  const matchers_js_1 = require("./matchers.js");
40
40
  const suppressors_js_1 = require("./suppressors.js");
41
+ const cues_js_1 = require("./cues.js");
41
42
  const structural_js_1 = require("./structural.js");
42
43
  const index_js_1 = require("./countries/index.js");
43
44
  const evidenceMod = __importStar(require("./evidence.js"));
@@ -105,30 +106,13 @@ function warnUnknownCountry(code) {
105
106
  //
106
107
  // Measured: 263 phone numbers and 18 French SIREN were reported as national IDs,
107
108
  // each with an explicit cue in front of it.
108
- const LOCAL_CUES = [
109
- [types_js_1.EntityType.PHONE,
110
- /(?:phone|tel|telephone|téléphone|telefoon|telefon|telefono|teléfono|tlf|mobil|mobile|gsm|handy|sími|puh|móvil|cell)\w*\s*[:.\-]?\s*$/i],
111
- [types_js_1.EntityType.CHAMBER_OF_COMMERCE,
112
- /(?:siren|siret|kbo|bce|kvk|ondernemingsnummer|handelsregister|company\s*(?:no|number|reg)|organisationsnummer|orgnr)\w*\s*[:.\-]?\s*$/i],
113
- [types_js_1.EntityType.VAT,
114
- /(?:vat|btw|tva|ust|iva|moms|alv|mwst)[\w.\-]*\s*[:.\-]?\s*$/i],
115
- [types_js_1.EntityType.BIC,
116
- /(?:bic|swift)[\w/]*\s*[:.\-]?\s*\(?\s*$/i],
117
- [types_js_1.EntityType.NATIONAL_ID,
118
- /(?:bsn|personnummer|rijksregisternummer|national\s*id|identiteitsnummer|henkilötunnus|kennitala|cpr|nif|dni)\w*\s*[:.\-]?\s*$/i],
119
- ];
120
- // How far back a cue may sit. Short on purpose: a cue is only evidence about the
121
- // value it introduces, which is the lesson of the postal-code year defect, where
122
- // a cue 150 characters away licensed every number in the window.
123
- const CUE_WINDOW = 22;
109
+ //
110
+ // The table itself lives in `cues.ts`, because two other steps read it: the
111
+ // rescue of a declined identifier below, and the re-typing in deduplicate.
112
+ // `CUE_WINDOW` is re-exported from there.
124
113
  /** 1 when a cue naming `entityType` sits immediately before the span. */
125
114
  function localCueBonus(text, start, entityType) {
126
- const before = text.slice(Math.max(0, start - CUE_WINDOW), start);
127
- for (const [cuedType, pattern] of LOCAL_CUES) {
128
- if (cuedType === entityType && pattern.test(before))
129
- return 1;
130
- }
131
- return 0;
115
+ return (0, cues_js_1.cuedType)(text, start) === entityType ? 1 : 0;
132
116
  }
133
117
  class RuleEngine {
134
118
  matcher = new matchers_js_1.MultiPatternMatcher();
@@ -235,7 +219,19 @@ class RuleEngine {
235
219
  // but failed the checksum is recorded with its country and entity type;
236
220
  // what it is allowed to demote is decided below, once the document's
237
221
  // countries are known.
222
+ //
223
+ // One kind of failure is rescued rather than recorded: a span the document
224
+ // itself labels as the very type whose checksum just failed.
225
+ // "Rijksregisternummer: 85.03.19-284.73" is a Belgian national number with a
226
+ // bad check digit — mistyped, OCR'd, or invented — and it is still a
227
+ // national number. Before this, the pattern declined, the generic phone rule
228
+ // was denied the span as a fragment, and the value was left in the clear: a
229
+ // redaction library printing an identifier it had recognised and rejected.
230
+ //
231
+ // Rescued candidates are still recorded as failed spans, so the demotion
232
+ // zones and the fragment rule below are unchanged.
238
233
  const validated = [];
234
+ const rescued = [];
239
235
  const failedSpans = [];
240
236
  for (const m of rawMatches) {
241
237
  if (this.matcher.validate(m)) {
@@ -244,6 +240,9 @@ class RuleEngine {
244
240
  else if (m.patternDef.validator !== null && !m.patternDef.requiresContext) {
245
241
  failedSpans.push({ start: m.start, end: m.end, code: m.countryCode,
246
242
  etype: String(m.patternDef.entityType) });
243
+ if (cues_js_1.CUE_TARGETS.has(String(m.patternDef.entityType))
244
+ && (0, cues_js_1.cuedType)(text, m.start) === m.patternDef.entityType)
245
+ rescued.push(m);
247
246
  }
248
247
  }
249
248
  // Evidence pass: work out which countries this document belongs to, from
@@ -310,6 +309,14 @@ class RuleEngine {
310
309
  const allFailed = failedSpans
311
310
  .map(f => [f.start, f.end])
312
311
  .sort((a, b) => a[0] - b[0] || a[1] - b[1]);
312
+ // Running maximum of the end offsets, so the fragment test below can ask
313
+ // "does any span starting at or before me reach past my end?" in O(1)
314
+ // instead of walking the prefix per candidate.
315
+ const failedStarts = allFailed.map(f => f[0]);
316
+ const failedPrefixMax = new Array(allFailed.length + 1).fill(0);
317
+ for (let i = 0; i < allFailed.length; i++) {
318
+ failedPrefixMax[i + 1] = Math.max(failedPrefixMax[i], allFailed[i][1]);
319
+ }
313
320
  const mergedZones = new Map();
314
321
  for (const [etype, spans] of zonesByType) {
315
322
  spans.sort((a, b) => a[0] - b[0] || a[1] - b[1]);
@@ -332,8 +339,11 @@ class RuleEngine {
332
339
  mergedZones.set(etype, { starts, ends });
333
340
  }
334
341
  const candidates = [];
342
+ // Whole-document facts a suppressor may need, derived at most once per call
343
+ // and discarded with it.
344
+ const scratch = new suppressors_js_1.SuppressionScratch(text);
335
345
  for (const { match, hasValidValidator } of validated) {
336
- if ((0, suppressors_js_1.shouldSuppress)(text, match))
346
+ if ((0, suppressors_js_1.shouldSuppress)(text, match, scratch))
337
347
  continue;
338
348
  // A validator-less match inside a failed-validation span of its own type
339
349
  // is demoted below every other candidate rather than deleted. Demotion
@@ -361,13 +371,31 @@ class RuleEngine {
361
371
  // default — the generic phone pattern kept claiming the tail of the
362
372
  // rejected national number.
363
373
  if (match.patternDef.validator === null && !demoted) {
374
+ // Upper bound of the spans starting at or before match.start.
375
+ let lo = 0, hi = failedStarts.length;
376
+ while (lo < hi) {
377
+ const mid = (lo + hi) >>> 1;
378
+ if (failedStarts[mid] <= match.start)
379
+ lo = mid + 1;
380
+ else
381
+ hi = mid;
382
+ }
364
383
  let fragment = false;
365
- for (const [zs, ze] of allFailed) {
366
- if (zs > match.start)
367
- break;
368
- if (match.end <= ze && (match.start > zs || match.end < ze)) {
369
- fragment = true;
370
- break;
384
+ const reach = failedPrefixMax[lo];
385
+ if (reach > match.end) {
386
+ // Something starting no later than us ends strictly later: we are a
387
+ // proper subset of it.
388
+ fragment = true;
389
+ }
390
+ else if (reach === match.end) {
391
+ // Same end offset — a proper subset only if some span with that end
392
+ // starts strictly earlier. Scanning backwards finds the enclosing
393
+ // span first, and the equal case is rare.
394
+ for (let i = lo - 1; i >= 0; i--) {
395
+ if (allFailed[i][1] === match.end && allFailed[i][0] < match.start) {
396
+ fragment = true;
397
+ break;
398
+ }
371
399
  }
372
400
  }
373
401
  if (fragment)
@@ -409,8 +437,28 @@ class RuleEngine {
409
437
  priority = 1;
410
438
  blindPriority = 1;
411
439
  }
412
- const country = (match.countryCode !== "SHARED" && match.countryCode !== "CUSTOM")
440
+ let entityType = match.patternDef.entityType;
441
+ let country = (match.countryCode !== "SHARED" && match.countryCode !== "CUSTOM")
413
442
  ? match.countryCode : null;
443
+ let outOfScope = declared !== null && country !== null && !declared.has(match.countryCode);
444
+ let confidence = "high";
445
+ // The document labels this span as something else. Relabelling, not
446
+ // suppression: the span is found either way, so removing the claim would
447
+ // decide only whether the value is masked — and the answer was "no".
448
+ // "Rijksregisternummer: 85.03.19-284.73" produced no detection at all.
449
+ //
450
+ // The country came from the pattern that matched, and that pattern was
451
+ // just overruled: a Finnish phone rule saying "Finland" is worthless once
452
+ // the value is filed as a Cypriot identity number. Dropping it also clears
453
+ // outOfScope, which would otherwise claim the detection belongs to a
454
+ // country the caller did not declare while naming no country at all.
455
+ const retyped = (0, cues_js_1.retypedBy)(text, match.start, entityType);
456
+ if (retyped !== null) {
457
+ entityType = retyped;
458
+ country = null;
459
+ outOfScope = false;
460
+ confidence = "medium";
461
+ }
414
462
  candidates.push({
415
463
  blindPriority,
416
464
  cue: localCueBonus(text, match.start, match.patternDef.entityType),
@@ -418,6 +466,45 @@ class RuleEngine {
418
466
  length: match.end - match.start,
419
467
  countryScore: countryScores.get(match.countryCode) ?? 0,
420
468
  inScope: declared === null || declared.has(match.countryCode) ? 1 : 0,
469
+ tier: 0,
470
+ det: {
471
+ entityType,
472
+ start: match.start,
473
+ end: match.end,
474
+ text: match.text,
475
+ source: types_js_1.DetectionSource.RULES,
476
+ country,
477
+ confidence,
478
+ outOfScope,
479
+ },
480
+ });
481
+ }
482
+ // A declined identifier the document itself labels. Cue-backed by
483
+ // construction, so `cue` is 1 — that is what lets it take a span from a
484
+ // foreign pattern whose checksum happened to pass, which is how
485
+ // "ΑΦΜ: 147382965" was reported as a Czech national ID.
486
+ //
487
+ // `priority` is 0: below every regex-only claim, so it wins only what
488
+ // nothing better wants. `blindPriority` is 1 so that span selection — the
489
+ // country-blind half of the sort — treats it like any other regex-strength
490
+ // claim and invariant I1 is untouched.
491
+ //
492
+ // Emitted as "low", which is the honest description: the shape and the label
493
+ // agree, the check digit does not. A caller wanting only checksum-backed
494
+ // detections filters on it.
495
+ for (const match of rescued) {
496
+ if ((0, suppressors_js_1.shouldSuppress)(text, match, scratch))
497
+ continue;
498
+ const country = (match.countryCode !== "SHARED" && match.countryCode !== "CUSTOM")
499
+ ? match.countryCode : null;
500
+ candidates.push({
501
+ blindPriority: 1,
502
+ cue: 1,
503
+ priority: 0,
504
+ length: match.end - match.start,
505
+ countryScore: countryScores.get(match.countryCode) ?? 0,
506
+ inScope: declared === null || declared.has(match.countryCode) ? 1 : 0,
507
+ tier: 0,
421
508
  det: {
422
509
  entityType: match.patternDef.entityType,
423
510
  start: match.start,
@@ -425,14 +512,14 @@ class RuleEngine {
425
512
  text: match.text,
426
513
  source: types_js_1.DetectionSource.RULES,
427
514
  country,
428
- confidence: "high",
515
+ confidence: "low",
429
516
  outOfScope: declared !== null && country !== null && !declared.has(match.countryCode),
430
517
  },
431
518
  });
432
519
  }
433
520
  for (const d of (0, structural_js_1.detectStructuralDob)(text)) {
434
521
  candidates.push({ blindPriority: 1, cue: 0, priority: 1, length: d.end - d.start,
435
- countryScore: 0, inScope: 1, det: d });
522
+ countryScore: 0, inScope: 1, tier: 0, det: d });
436
523
  }
437
524
  const detections = this.deduplicate(candidates);
438
525
  detections.sort((a, b) => a.start - b.start || b.end - a.end);
@@ -466,7 +553,10 @@ class RuleEngine {
466
553
  if (seen === undefined || c.blindPriority > seen)
467
554
  spanTier.set(key, c.blindPriority);
468
555
  }
469
- const tierOf = (c) => spanTier.get(`${c.det.start}:${c.det.end}`);
556
+ // Resolved once per candidate rather than inside the comparator, which ran
557
+ // it O(n log n) times and rebuilt the key string on every comparison.
558
+ for (const c of candidates)
559
+ c.tier = spanTier.get(`${c.det.start}:${c.det.end}`);
470
560
  // Span length outranks *everything*, including validation.
471
561
  //
472
562
  // A shorter candidate winning re-cuts a longer one and leaves the
@@ -492,7 +582,7 @@ class RuleEngine {
492
582
  // country-blind up to and including `start`, and (length, start) already
493
583
  // identifies a span uniquely.
494
584
  const sorted = [...candidates].sort((a, b) => b.length - a.length || // longest first } country-blind
495
- tierOf(b) - tierOf(a) || // best tier here } — decides
585
+ b.tier - a.tier || // best tier here } — decides
496
586
  a.det.start - b.det.start || // leftmost } WHICH span
497
587
  b.cue - a.cue || // local cue } country-aware
498
588
  b.priority - a.priority || // country priority } — decides the