euredact 0.3.7 → 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
@@ -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
@@ -438,8 +437,28 @@ class RuleEngine {
438
437
  priority = 1;
439
438
  blindPriority = 1;
440
439
  }
441
- const country = (match.countryCode !== "SHARED" && match.countryCode !== "CUSTOM")
440
+ let entityType = match.patternDef.entityType;
441
+ let country = (match.countryCode !== "SHARED" && match.countryCode !== "CUSTOM")
442
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
+ }
443
462
  candidates.push({
444
463
  blindPriority,
445
464
  cue: localCueBonus(text, match.start, match.patternDef.entityType),
@@ -448,6 +467,44 @@ class RuleEngine {
448
467
  countryScore: countryScores.get(match.countryCode) ?? 0,
449
468
  inScope: declared === null || declared.has(match.countryCode) ? 1 : 0,
450
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,
451
508
  det: {
452
509
  entityType: match.patternDef.entityType,
453
510
  start: match.start,
@@ -455,7 +512,7 @@ class RuleEngine {
455
512
  text: match.text,
456
513
  source: types_js_1.DetectionSource.RULES,
457
514
  country,
458
- confidence: "high",
515
+ confidence: "low",
459
516
  outOfScope: declared !== null && country !== null && !declared.has(match.countryCode),
460
517
  },
461
518
  });
@@ -44,11 +44,16 @@ const MATH_AFTER = /^\s*[=+\-×÷*/]/;
44
44
  const COUNTRY_PREFIX_HYPHEN = /(?:^|[^A-Za-z0-9])[A-Z]{1,2}-\s*$/;
45
45
  const SEQUENTIAL_PATTERNS = /^(?:0{6,}|1234567890?|0123456789|9876543210?|1111111111?|000000000|123456789)$/;
46
46
  // YEAR_PATTERN kept for reference but replaced by RECENT_YEAR in suppressYearAsPostal
47
- // The leading \b is load-bearing. Without it the short alternatives match the
48
- // *tail* of an ordinary word: "NIS" matched "tālru**nis** ", the Latvian for
49
- // "telephone", so every Latvian phone number was suppressed as though it sat
50
- // behind a Belgian national-ID label. 653 misses from a missing word boundary.
51
- const ID_LABEL_BEFORE = /\b(?:BSN|RR|NN|NIR|INSZ|NISS|NIS|Steuer-?ID|TIN|NIF|NIE|SSN|rijksregisternummer|numéro\s*national|national\s*number|matricule|Ausweisnummer|Personalausweis|Versichertennummer|KVNR|KV-Nr|Steuernummer|St\.\-Nr|StNr|Finanzamt\s+ist|Ondernemingen\s+onder\s+nummer|ondernemingsnummer|numéro\s*d'entreprise|enterprise\s*number|Kruispuntbank)\s*[:.]?\s*$/i;
47
+ // `ID_LABEL_BEFORE` and `suppressPhoneAfterIdLabel` lived here. Their labels are
48
+ // now typed entries in `cues.ts`, and a phone-shaped span behind one is
49
+ // *relabelled* rather than dropped.
50
+ //
51
+ // Dropping was the wrong verb. The span is found either way, so removing the
52
+ // claim decided only whether the value was masked — and the answer was "no":
53
+ // "Rijksregisternummer: 85.03.19-284.73" produced no detection at all, a
54
+ // redaction library printing in full an identifier it had recognised and
55
+ // rejected. The 653-miss word-boundary lesson recorded here moved with the
56
+ // labels; see the module comment in `cues.ts`.
52
57
  const SERVICE_NUMBER = /^0800[\-\s]/;
53
58
  const DATE_PATTERN_FULL = /^\d{2}[-/.]\d{2}[-/.]\d{4}$|^\d{4}[-/.]\d{2}[-/.]\d{2}$/;
54
59
  const PASSPORT_CONTEXT_BEFORE = /(?:Reisepass|passport|passeport|paspoort|Bisheriger\s+Reisepass)\s*(?:Nr\.?|Nummer|nummer|number|n[°o])?\s*[:.]?\s*$/i;
@@ -342,12 +347,6 @@ function suppressYearAsPostal(text, match) {
342
347
  // Suppress: recent years without postal context are almost never postal codes
343
348
  return true;
344
349
  }
345
- function suppressPhoneAfterIdLabel(text, match) {
346
- if (match.patternDef.entityType !== types_js_1.EntityType.PHONE)
347
- return false;
348
- const [before] = getContext(text, match.start, match.end);
349
- return ID_LABEL_BEFORE.test(before);
350
- }
351
350
  function suppressPhoneServiceNumber(_text, match) {
352
351
  if (match.patternDef.entityType !== types_js_1.EntityType.PHONE)
353
352
  return false;
@@ -793,7 +792,7 @@ function suppressRequiresContext(text, match) {
793
792
  return !match.patternDef.contextKeywords.some(kw => context.includes(kw.toLowerCase()));
794
793
  }
795
794
  const TYPE_SUPPRESSORS = {
796
- [types_js_1.EntityType.PHONE]: [suppressCurrency, suppressUnits, suppressReference, suppressMath, suppressPhoneAfterIdLabel, suppressPhoneServiceNumber, suppressPhoneDateOverlap, suppressPhoneAsNumberRange],
795
+ [types_js_1.EntityType.PHONE]: [suppressCurrency, suppressUnits, suppressReference, suppressMath, suppressPhoneServiceNumber, suppressPhoneDateOverlap, suppressPhoneAsNumberRange],
797
796
  [types_js_1.EntityType.NATIONAL_ID]: [suppressCurrency, suppressUnits, suppressReference, suppressLegal, suppressMath, suppressNatidAsPassport, suppressSeNatidAsOrg],
798
797
  [types_js_1.EntityType.SSN]: [suppressCurrency, suppressUnits, suppressReference, suppressMath],
799
798
  [types_js_1.EntityType.TAX_ID]: [suppressCurrency, suppressUnits, suppressReference, suppressMath, suppressTaxidAsIpAddress],
package/dist/cjs/types.js CHANGED
@@ -36,6 +36,16 @@ var EntityType;
36
36
  EntityType["HEALTH_INSURANCE"] = "HEALTH_INSURANCE";
37
37
  EntityType["HEALTHCARE_PROVIDER"] = "HEALTHCARE_PROVIDER";
38
38
  EntityType["CHAMBER_OF_COMMERCE"] = "CHAMBER_OF_COMMERCE";
39
+ /**
40
+ * Employee, badge or customer number tied to a person.
41
+ *
42
+ * Emitted **only** when an explicit label names it — see `rules/cues.ts`.
43
+ * There is no pattern for an internal identifier because there is no shape
44
+ * for one; without the label, a digit run is not distinguishable from any
45
+ * other. The type exists so that a labelled employee number is filed
46
+ * correctly instead of being claimed by the phone pattern.
47
+ */
48
+ EntityType["INTERNAL_ID"] = "INTERNAL_ID";
39
49
  EntityType["IMEI"] = "IMEI";
40
50
  EntityType["GPS_COORDINATES"] = "GPS_COORDINATES";
41
51
  EntityType["UUID"] = "UUID";
@@ -0,0 +1,91 @@
1
+ /**
2
+ * Local cues: what the document calls the value sitting next to it.
3
+ *
4
+ * A label touching the left edge of a span is the strongest statement a
5
+ * document makes about what that value *is*. "Phone: 0705237535" is a phone
6
+ * number even though those digits pass the Swedish personnummer checksum, and
7
+ * "Αρ. Ταυτότητας: 00892341" is an identity-card number even though nothing in
8
+ * the engine can checksum it.
9
+ *
10
+ * This table is the single source of that knowledge. Three callers read it, and
11
+ * none of them may move a span:
12
+ *
13
+ * 1. `localCueBonus` — ranks a candidate above its rivals on the *same span*
14
+ * when the cue names its type. The oldest use, and why the table exists.
15
+ * 2. `detectWithEvidence` — re-admits a candidate whose checksum failed when the
16
+ * cue names that same type. A declined identifier with its own label in front
17
+ * of it is still that identifier, mistyped or truncated.
18
+ * 3. `retypedBy` — relabels a winning generic candidate whose type the cue
19
+ * contradicts, when nothing of the cued type claimed the span.
20
+ *
21
+ * Consolidated here from two places that had drifted apart: `LOCAL_CUES` in
22
+ * `engine.ts` (typed, but missing most of Europe) and `ID_LABEL_BEFORE` in
23
+ * `suppressors.ts` (broader, but untyped — it could only *delete* a phone
24
+ * candidate, which left the value unmasked).
25
+ *
26
+ * Kept character-for-character in step with `euredact/rules/cues.py`. Two
27
+ * constructs are written the way they are so that both engines agree:
28
+ *
29
+ * * Boundaries are `(?<![A-Za-z0-9_])`, not `\b`. JavaScript's `\b` is
30
+ * ASCII-only, so `\bΑΦΜ` never matches — a Greek letter is not a word
31
+ * character there, and a space before it is not a transition. A boundary of
32
+ * some kind is required regardless: without one the short alternatives match
33
+ * the *tail* of an ordinary word. "NIS" matched "tālru**nis**", the Latvian
34
+ * for telephone, and suppressed 653 real phone numbers; "iva" matched
35
+ * "Pr**iva**t:" and read a personal e-mail address as a VAT number.
36
+ * * The qualifier class is spelled out rather than written `[^\W\d_]`, which
37
+ * would reject "εταιρειας" here for the same ASCII reason.
38
+ *
39
+ * Every label ends in `(?:\w*|\s+<one word>)`: the label either runs on into a
40
+ * longer word ("telefoon" → "telefoonnummer") **or** takes one qualifier word
41
+ * naming whose identifier it is ("ΑΦΜ εταιρειας:", "Tel Nr:") — never both.
42
+ * Allowing both let "nie" grow into the surname "Nieminen" and then swallow the
43
+ * real label in "Matti Nieminen\nDOB: 08.05.1994", reading a date of birth as a
44
+ * Spanish NIE: 52 false positives across the corpus.
45
+ */
46
+ import { EntityType } from "../types.js";
47
+ /**
48
+ * How far back a cue may sit. Short on purpose: a cue is only evidence about
49
+ * the value it introduces, which is the lesson of the postal-code year defect,
50
+ * where a cue 150 characters away licensed every number in the window.
51
+ */
52
+ export declare const CUE_WINDOW = 22;
53
+ /**
54
+ * (entity type, label pattern). Ordered: the first match wins, so the narrower
55
+ * label comes first where two could read the same text.
56
+ */
57
+ export declare const CUES: Array<[EntityType, RegExp]>;
58
+ /**
59
+ * The entity type a label immediately before `start` names, or null.
60
+ *
61
+ * Reads at most `CUE_WINDOW` characters, and every pattern is anchored to the
62
+ * end of that window, so the cue must *touch* the span. A label further away is
63
+ * about some other value.
64
+ */
65
+ export declare function cuedType(text: string, start: number): EntityType | null;
66
+ /**
67
+ * Types a label can assert on its own. Also the types eligible for the rescue
68
+ * in `engine.ts`, for the same reason read the other way — a label may vouch
69
+ * for one of these, so it may also vouch for a malformed one.
70
+ *
71
+ * PHONE is absent on purpose: "Tel:" in front of something no phone pattern
72
+ * matched means the document is laid out oddly, not that the value is a phone
73
+ * number. So are EMAIL, SECRET, DOB, VIN and the rest — those carry their own
74
+ * structure, and a nearby word is not entitled to overrule it.
75
+ *
76
+ * BIC's absence is what keeps the rescue honest. Its validator is a *registry
77
+ * lookup*, not a checksum, so a failure means "no such bank", which no label can
78
+ * talk you out of. Rescuing it read 34 unlisted codes behind a "BIC:" label as
79
+ * real ones. The same reasoning keeps CREDIT_CARD and BANK_ACCOUNT out: Luhn and
80
+ * mod-97 are strong enough that a failure really does mean "not one of these".
81
+ */
82
+ export declare const CUE_TARGETS: Set<string>;
83
+ /**
84
+ * The type a cue overrules `entityType` with, or null to leave it alone.
85
+ *
86
+ * Both gates are load-bearing. Measured over 2,000 corpus documents, re-typing
87
+ * without them fired 52 times and got roughly 30 of those wrong — EMAIL read as
88
+ * VAT, a date of birth as a phone number, a VIN as VAT — against 20 real fixes.
89
+ * With them: 14 re-typings, every one of them PHONE to a structured identifier.
90
+ */
91
+ export declare function retypedBy(text: string, start: number, entityType: EntityType | string): EntityType | null;
@@ -0,0 +1,163 @@
1
+ /**
2
+ * Local cues: what the document calls the value sitting next to it.
3
+ *
4
+ * A label touching the left edge of a span is the strongest statement a
5
+ * document makes about what that value *is*. "Phone: 0705237535" is a phone
6
+ * number even though those digits pass the Swedish personnummer checksum, and
7
+ * "Αρ. Ταυτότητας: 00892341" is an identity-card number even though nothing in
8
+ * the engine can checksum it.
9
+ *
10
+ * This table is the single source of that knowledge. Three callers read it, and
11
+ * none of them may move a span:
12
+ *
13
+ * 1. `localCueBonus` — ranks a candidate above its rivals on the *same span*
14
+ * when the cue names its type. The oldest use, and why the table exists.
15
+ * 2. `detectWithEvidence` — re-admits a candidate whose checksum failed when the
16
+ * cue names that same type. A declined identifier with its own label in front
17
+ * of it is still that identifier, mistyped or truncated.
18
+ * 3. `retypedBy` — relabels a winning generic candidate whose type the cue
19
+ * contradicts, when nothing of the cued type claimed the span.
20
+ *
21
+ * Consolidated here from two places that had drifted apart: `LOCAL_CUES` in
22
+ * `engine.ts` (typed, but missing most of Europe) and `ID_LABEL_BEFORE` in
23
+ * `suppressors.ts` (broader, but untyped — it could only *delete* a phone
24
+ * candidate, which left the value unmasked).
25
+ *
26
+ * Kept character-for-character in step with `euredact/rules/cues.py`. Two
27
+ * constructs are written the way they are so that both engines agree:
28
+ *
29
+ * * Boundaries are `(?<![A-Za-z0-9_])`, not `\b`. JavaScript's `\b` is
30
+ * ASCII-only, so `\bΑΦΜ` never matches — a Greek letter is not a word
31
+ * character there, and a space before it is not a transition. A boundary of
32
+ * some kind is required regardless: without one the short alternatives match
33
+ * the *tail* of an ordinary word. "NIS" matched "tālru**nis**", the Latvian
34
+ * for telephone, and suppressed 653 real phone numbers; "iva" matched
35
+ * "Pr**iva**t:" and read a personal e-mail address as a VAT number.
36
+ * * The qualifier class is spelled out rather than written `[^\W\d_]`, which
37
+ * would reject "εταιρειας" here for the same ASCII reason.
38
+ *
39
+ * Every label ends in `(?:\w*|\s+<one word>)`: the label either runs on into a
40
+ * longer word ("telefoon" → "telefoonnummer") **or** takes one qualifier word
41
+ * naming whose identifier it is ("ΑΦΜ εταιρειας:", "Tel Nr:") — never both.
42
+ * Allowing both let "nie" grow into the surname "Nieminen" and then swallow the
43
+ * real label in "Matti Nieminen\nDOB: 08.05.1994", reading a date of birth as a
44
+ * Spanish NIE: 52 false positives across the corpus.
45
+ */
46
+ import { EntityType } from "../types.js";
47
+ /**
48
+ * How far back a cue may sit. Short on purpose: a cue is only evidence about
49
+ * the value it introduces, which is the lesson of the postal-code year defect,
50
+ * where a cue 150 characters away licensed every number in the window.
51
+ */
52
+ export const CUE_WINDOW = 22;
53
+ /**
54
+ * (entity type, label pattern). Ordered: the first match wins, so the narrower
55
+ * label comes first where two could read the same text.
56
+ */
57
+ export const CUES = [
58
+ // Phone. Ranking only — nothing is ever relabelled *to* PHONE: a phone cue in
59
+ // front of a span that no phone pattern matched says the document is laid out
60
+ // unusually, not that the value is a phone number.
61
+ [EntityType.PHONE,
62
+ /(?<![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],
63
+ // National identity number.
64
+ [EntityType.NATIONAL_ID,
65
+ /(?<![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],
66
+ // Cyprus: "ID number" in both official languages, plus the Greek abbreviation
67
+ // ΑΔΤ (Αστυνομική Ταυτότητα). Neither has a phone reading.
68
+ [EntityType.NATIONAL_ID,
69
+ /(?<![A-Za-z0-9_])(?:αρ\.?\s*ταυτότητας|ταυτότητας|αδτ|kimlik\s*numaras[ıi])(?:\w*|\s+[^\s:.\-\d,;()/]{2,20})\s*[:.\-]?\s*$/i],
70
+ // Social security.
71
+ [EntityType.SSN,
72
+ /(?<![A-Za-z0-9_])(?:ssn|social\s*security|sozialversicherungsnummer|svnr|sofi)(?:\w*|\s+[^\s:.\-\d,;()/]{2,20})\s*[:.\-]?\s*$/i],
73
+ // Tax number. Greece's ΑΦΜ is issued by the tax authority; the identity-card
74
+ // number is the ΑΔΤ, above. The engine used to return NATIONAL_ID for a value
75
+ // cued ΑΦΜ, which is wrong independently of the phone problem.
76
+ [EntityType.TAX_ID,
77
+ /(?<![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],
78
+ // Health insurance.
79
+ [EntityType.HEALTH_INSURANCE,
80
+ /(?<![A-Za-z0-9_])(?:versichertennummer|krankenversicherung|kvnr|kv-?nr|mutualité|ziekenfonds|aansluitingsnummer)(?:\w*|\s+[^\s:.\-\d,;()/]{2,20})\s*[:.\-]?\s*$/i],
81
+ // Company / trade register.
82
+ [EntityType.CHAMBER_OF_COMMERCE,
83
+ /(?<![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],
84
+ // VAT.
85
+ [EntityType.VAT,
86
+ /(?<![A-Za-z0-9_])(?:vat|btw|tva|ust|iva|moms|alv|mwst|dič|vsk)(?:[\w.\-]*|\s+[^\s:.\-\d,;()/]{2,20})\s*[:.\-]?\s*$/i],
87
+ // BIC / SWIFT.
88
+ [EntityType.BIC,
89
+ /(?<![A-Za-z0-9_])(?:bic|swift)[\w/]*\s*[:.\-]?\s*\(?\s*$/i],
90
+ // Postal code. A postal code loses every contested span on priority (it is
91
+ // the weakest evidence the engine has), so without a cue entry
92
+ // "Cod postal: 040171" was handed to a German phone pattern.
93
+ [EntityType.POSTAL_CODE,
94
+ /(?<![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],
95
+ // Internal / employee identifier. The canon calls these INTERNAL_ID and
96
+ // annotates them with an LLM, so the engine has no pattern for them — but it
97
+ // does have a phone pattern that was happily claiming them. The cue is the
98
+ // only thing that identifies one, which is why the engine emits this type
99
+ // from here and nowhere else.
100
+ [EntityType.INTERNAL_ID,
101
+ /(?<![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],
102
+ ];
103
+ /**
104
+ * The entity type a label immediately before `start` names, or null.
105
+ *
106
+ * Reads at most `CUE_WINDOW` characters, and every pattern is anchored to the
107
+ * end of that window, so the cue must *touch* the span. A label further away is
108
+ * about some other value.
109
+ */
110
+ export function cuedType(text, start) {
111
+ const before = text.slice(Math.max(0, start - CUE_WINDOW), start);
112
+ for (const [entityType, pattern] of CUES) {
113
+ if (pattern.test(before))
114
+ return entityType;
115
+ }
116
+ return null;
117
+ }
118
+ /**
119
+ * Shapes so generic that a label in front is better evidence than the pattern
120
+ * that matched. A bare digit run is not a phone number because it *could* be
121
+ * dialled.
122
+ */
123
+ const RETYPABLE = new Set([
124
+ EntityType.PHONE, EntityType.POSTAL_CODE, EntityType.LICENSE_PLATE,
125
+ ]);
126
+ /**
127
+ * Types a label can assert on its own. Also the types eligible for the rescue
128
+ * in `engine.ts`, for the same reason read the other way — a label may vouch
129
+ * for one of these, so it may also vouch for a malformed one.
130
+ *
131
+ * PHONE is absent on purpose: "Tel:" in front of something no phone pattern
132
+ * matched means the document is laid out oddly, not that the value is a phone
133
+ * number. So are EMAIL, SECRET, DOB, VIN and the rest — those carry their own
134
+ * structure, and a nearby word is not entitled to overrule it.
135
+ *
136
+ * BIC's absence is what keeps the rescue honest. Its validator is a *registry
137
+ * lookup*, not a checksum, so a failure means "no such bank", which no label can
138
+ * talk you out of. Rescuing it read 34 unlisted codes behind a "BIC:" label as
139
+ * real ones. The same reasoning keeps CREDIT_CARD and BANK_ACCOUNT out: Luhn and
140
+ * mod-97 are strong enough that a failure really does mean "not one of these".
141
+ */
142
+ export const CUE_TARGETS = new Set([
143
+ EntityType.NATIONAL_ID, EntityType.SSN, EntityType.TAX_ID,
144
+ EntityType.HEALTH_INSURANCE, EntityType.CHAMBER_OF_COMMERCE,
145
+ EntityType.VAT, EntityType.POSTAL_CODE, EntityType.PASSPORT,
146
+ EntityType.INTERNAL_ID,
147
+ ]);
148
+ /**
149
+ * The type a cue overrules `entityType` with, or null to leave it alone.
150
+ *
151
+ * Both gates are load-bearing. Measured over 2,000 corpus documents, re-typing
152
+ * without them fired 52 times and got roughly 30 of those wrong — EMAIL read as
153
+ * VAT, a date of birth as a phone number, a VIN as VAT — against 20 real fixes.
154
+ * With them: 14 re-typings, every one of them PHONE to a structured identifier.
155
+ */
156
+ export function retypedBy(text, start, entityType) {
157
+ if (!RETYPABLE.has(String(entityType)))
158
+ return null;
159
+ const cued = cuedType(text, start);
160
+ if (cued === null || cued === entityType || !CUE_TARGETS.has(String(cued)))
161
+ return null;
162
+ return cued;
163
+ }
@@ -1,6 +1,7 @@
1
1
  import { DetectionSource, EntityType } from "../types.js";
2
2
  import { MultiPatternMatcher } from "./matchers.js";
3
3
  import { shouldSuppress, SuppressionScratch } from "./suppressors.js";
4
+ import { CUE_TARGETS, cuedType, retypedBy } from "./cues.js";
4
5
  import { detectStructuralDob } from "./structural.js";
5
6
  import { COUNTRY_CONFIGS } from "./countries/index.js";
6
7
  import * as evidenceMod from "./evidence.js";
@@ -68,30 +69,13 @@ function warnUnknownCountry(code) {
68
69
  //
69
70
  // Measured: 263 phone numbers and 18 French SIREN were reported as national IDs,
70
71
  // each with an explicit cue in front of it.
71
- const LOCAL_CUES = [
72
- [EntityType.PHONE,
73
- /(?: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],
74
- [EntityType.CHAMBER_OF_COMMERCE,
75
- /(?:siren|siret|kbo|bce|kvk|ondernemingsnummer|handelsregister|company\s*(?:no|number|reg)|organisationsnummer|orgnr)\w*\s*[:.\-]?\s*$/i],
76
- [EntityType.VAT,
77
- /(?:vat|btw|tva|ust|iva|moms|alv|mwst)[\w.\-]*\s*[:.\-]?\s*$/i],
78
- [EntityType.BIC,
79
- /(?:bic|swift)[\w/]*\s*[:.\-]?\s*\(?\s*$/i],
80
- [EntityType.NATIONAL_ID,
81
- /(?:bsn|personnummer|rijksregisternummer|national\s*id|identiteitsnummer|henkilötunnus|kennitala|cpr|nif|dni)\w*\s*[:.\-]?\s*$/i],
82
- ];
83
- // How far back a cue may sit. Short on purpose: a cue is only evidence about the
84
- // value it introduces, which is the lesson of the postal-code year defect, where
85
- // a cue 150 characters away licensed every number in the window.
86
- const CUE_WINDOW = 22;
72
+ //
73
+ // The table itself lives in `cues.ts`, because two other steps read it: the
74
+ // rescue of a declined identifier below, and the re-typing in deduplicate.
75
+ // `CUE_WINDOW` is re-exported from there.
87
76
  /** 1 when a cue naming `entityType` sits immediately before the span. */
88
77
  function localCueBonus(text, start, entityType) {
89
- const before = text.slice(Math.max(0, start - CUE_WINDOW), start);
90
- for (const [cuedType, pattern] of LOCAL_CUES) {
91
- if (cuedType === entityType && pattern.test(before))
92
- return 1;
93
- }
94
- return 0;
78
+ return cuedType(text, start) === entityType ? 1 : 0;
95
79
  }
96
80
  export class RuleEngine {
97
81
  matcher = new MultiPatternMatcher();
@@ -198,7 +182,19 @@ export class RuleEngine {
198
182
  // but failed the checksum is recorded with its country and entity type;
199
183
  // what it is allowed to demote is decided below, once the document's
200
184
  // countries are known.
185
+ //
186
+ // One kind of failure is rescued rather than recorded: a span the document
187
+ // itself labels as the very type whose checksum just failed.
188
+ // "Rijksregisternummer: 85.03.19-284.73" is a Belgian national number with a
189
+ // bad check digit — mistyped, OCR'd, or invented — and it is still a
190
+ // national number. Before this, the pattern declined, the generic phone rule
191
+ // was denied the span as a fragment, and the value was left in the clear: a
192
+ // redaction library printing an identifier it had recognised and rejected.
193
+ //
194
+ // Rescued candidates are still recorded as failed spans, so the demotion
195
+ // zones and the fragment rule below are unchanged.
201
196
  const validated = [];
197
+ const rescued = [];
202
198
  const failedSpans = [];
203
199
  for (const m of rawMatches) {
204
200
  if (this.matcher.validate(m)) {
@@ -207,6 +203,9 @@ export class RuleEngine {
207
203
  else if (m.patternDef.validator !== null && !m.patternDef.requiresContext) {
208
204
  failedSpans.push({ start: m.start, end: m.end, code: m.countryCode,
209
205
  etype: String(m.patternDef.entityType) });
206
+ if (CUE_TARGETS.has(String(m.patternDef.entityType))
207
+ && cuedType(text, m.start) === m.patternDef.entityType)
208
+ rescued.push(m);
210
209
  }
211
210
  }
212
211
  // Evidence pass: work out which countries this document belongs to, from
@@ -401,8 +400,28 @@ export class RuleEngine {
401
400
  priority = 1;
402
401
  blindPriority = 1;
403
402
  }
404
- const country = (match.countryCode !== "SHARED" && match.countryCode !== "CUSTOM")
403
+ let entityType = match.patternDef.entityType;
404
+ let country = (match.countryCode !== "SHARED" && match.countryCode !== "CUSTOM")
405
405
  ? match.countryCode : null;
406
+ let outOfScope = declared !== null && country !== null && !declared.has(match.countryCode);
407
+ let confidence = "high";
408
+ // The document labels this span as something else. Relabelling, not
409
+ // suppression: the span is found either way, so removing the claim would
410
+ // decide only whether the value is masked — and the answer was "no".
411
+ // "Rijksregisternummer: 85.03.19-284.73" produced no detection at all.
412
+ //
413
+ // The country came from the pattern that matched, and that pattern was
414
+ // just overruled: a Finnish phone rule saying "Finland" is worthless once
415
+ // the value is filed as a Cypriot identity number. Dropping it also clears
416
+ // outOfScope, which would otherwise claim the detection belongs to a
417
+ // country the caller did not declare while naming no country at all.
418
+ const retyped = retypedBy(text, match.start, entityType);
419
+ if (retyped !== null) {
420
+ entityType = retyped;
421
+ country = null;
422
+ outOfScope = false;
423
+ confidence = "medium";
424
+ }
406
425
  candidates.push({
407
426
  blindPriority,
408
427
  cue: localCueBonus(text, match.start, match.patternDef.entityType),
@@ -411,6 +430,44 @@ export class RuleEngine {
411
430
  countryScore: countryScores.get(match.countryCode) ?? 0,
412
431
  inScope: declared === null || declared.has(match.countryCode) ? 1 : 0,
413
432
  tier: 0,
433
+ det: {
434
+ entityType,
435
+ start: match.start,
436
+ end: match.end,
437
+ text: match.text,
438
+ source: DetectionSource.RULES,
439
+ country,
440
+ confidence,
441
+ outOfScope,
442
+ },
443
+ });
444
+ }
445
+ // A declined identifier the document itself labels. Cue-backed by
446
+ // construction, so `cue` is 1 — that is what lets it take a span from a
447
+ // foreign pattern whose checksum happened to pass, which is how
448
+ // "ΑΦΜ: 147382965" was reported as a Czech national ID.
449
+ //
450
+ // `priority` is 0: below every regex-only claim, so it wins only what
451
+ // nothing better wants. `blindPriority` is 1 so that span selection — the
452
+ // country-blind half of the sort — treats it like any other regex-strength
453
+ // claim and invariant I1 is untouched.
454
+ //
455
+ // Emitted as "low", which is the honest description: the shape and the label
456
+ // agree, the check digit does not. A caller wanting only checksum-backed
457
+ // detections filters on it.
458
+ for (const match of rescued) {
459
+ if (shouldSuppress(text, match, scratch))
460
+ continue;
461
+ const country = (match.countryCode !== "SHARED" && match.countryCode !== "CUSTOM")
462
+ ? match.countryCode : null;
463
+ candidates.push({
464
+ blindPriority: 1,
465
+ cue: 1,
466
+ priority: 0,
467
+ length: match.end - match.start,
468
+ countryScore: countryScores.get(match.countryCode) ?? 0,
469
+ inScope: declared === null || declared.has(match.countryCode) ? 1 : 0,
470
+ tier: 0,
414
471
  det: {
415
472
  entityType: match.patternDef.entityType,
416
473
  start: match.start,
@@ -418,7 +475,7 @@ export class RuleEngine {
418
475
  text: match.text,
419
476
  source: DetectionSource.RULES,
420
477
  country,
421
- confidence: "high",
478
+ confidence: "low",
422
479
  outOfScope: declared !== null && country !== null && !declared.has(match.countryCode),
423
480
  },
424
481
  });
@@ -40,11 +40,16 @@ const MATH_AFTER = /^\s*[=+\-×÷*/]/;
40
40
  const COUNTRY_PREFIX_HYPHEN = /(?:^|[^A-Za-z0-9])[A-Z]{1,2}-\s*$/;
41
41
  const SEQUENTIAL_PATTERNS = /^(?:0{6,}|1234567890?|0123456789|9876543210?|1111111111?|000000000|123456789)$/;
42
42
  // YEAR_PATTERN kept for reference but replaced by RECENT_YEAR in suppressYearAsPostal
43
- // The leading \b is load-bearing. Without it the short alternatives match the
44
- // *tail* of an ordinary word: "NIS" matched "tālru**nis** ", the Latvian for
45
- // "telephone", so every Latvian phone number was suppressed as though it sat
46
- // behind a Belgian national-ID label. 653 misses from a missing word boundary.
47
- const ID_LABEL_BEFORE = /\b(?:BSN|RR|NN|NIR|INSZ|NISS|NIS|Steuer-?ID|TIN|NIF|NIE|SSN|rijksregisternummer|numéro\s*national|national\s*number|matricule|Ausweisnummer|Personalausweis|Versichertennummer|KVNR|KV-Nr|Steuernummer|St\.\-Nr|StNr|Finanzamt\s+ist|Ondernemingen\s+onder\s+nummer|ondernemingsnummer|numéro\s*d'entreprise|enterprise\s*number|Kruispuntbank)\s*[:.]?\s*$/i;
43
+ // `ID_LABEL_BEFORE` and `suppressPhoneAfterIdLabel` lived here. Their labels are
44
+ // now typed entries in `cues.ts`, and a phone-shaped span behind one is
45
+ // *relabelled* rather than dropped.
46
+ //
47
+ // Dropping was the wrong verb. The span is found either way, so removing the
48
+ // claim decided only whether the value was masked — and the answer was "no":
49
+ // "Rijksregisternummer: 85.03.19-284.73" produced no detection at all, a
50
+ // redaction library printing in full an identifier it had recognised and
51
+ // rejected. The 653-miss word-boundary lesson recorded here moved with the
52
+ // labels; see the module comment in `cues.ts`.
48
53
  const SERVICE_NUMBER = /^0800[\-\s]/;
49
54
  const DATE_PATTERN_FULL = /^\d{2}[-/.]\d{2}[-/.]\d{4}$|^\d{4}[-/.]\d{2}[-/.]\d{2}$/;
50
55
  const PASSPORT_CONTEXT_BEFORE = /(?:Reisepass|passport|passeport|paspoort|Bisheriger\s+Reisepass)\s*(?:Nr\.?|Nummer|nummer|number|n[°o])?\s*[:.]?\s*$/i;
@@ -338,12 +343,6 @@ function suppressYearAsPostal(text, match) {
338
343
  // Suppress: recent years without postal context are almost never postal codes
339
344
  return true;
340
345
  }
341
- function suppressPhoneAfterIdLabel(text, match) {
342
- if (match.patternDef.entityType !== EntityType.PHONE)
343
- return false;
344
- const [before] = getContext(text, match.start, match.end);
345
- return ID_LABEL_BEFORE.test(before);
346
- }
347
346
  function suppressPhoneServiceNumber(_text, match) {
348
347
  if (match.patternDef.entityType !== EntityType.PHONE)
349
348
  return false;
@@ -788,7 +787,7 @@ function suppressRequiresContext(text, match) {
788
787
  return !match.patternDef.contextKeywords.some(kw => context.includes(kw.toLowerCase()));
789
788
  }
790
789
  const TYPE_SUPPRESSORS = {
791
- [EntityType.PHONE]: [suppressCurrency, suppressUnits, suppressReference, suppressMath, suppressPhoneAfterIdLabel, suppressPhoneServiceNumber, suppressPhoneDateOverlap, suppressPhoneAsNumberRange],
790
+ [EntityType.PHONE]: [suppressCurrency, suppressUnits, suppressReference, suppressMath, suppressPhoneServiceNumber, suppressPhoneDateOverlap, suppressPhoneAsNumberRange],
792
791
  [EntityType.NATIONAL_ID]: [suppressCurrency, suppressUnits, suppressReference, suppressLegal, suppressMath, suppressNatidAsPassport, suppressSeNatidAsOrg],
793
792
  [EntityType.SSN]: [suppressCurrency, suppressUnits, suppressReference, suppressMath],
794
793
  [EntityType.TAX_ID]: [suppressCurrency, suppressUnits, suppressReference, suppressMath, suppressTaxidAsIpAddress],
@@ -32,6 +32,16 @@ export declare enum EntityType {
32
32
  HEALTH_INSURANCE = "HEALTH_INSURANCE",
33
33
  HEALTHCARE_PROVIDER = "HEALTHCARE_PROVIDER",
34
34
  CHAMBER_OF_COMMERCE = "CHAMBER_OF_COMMERCE",
35
+ /**
36
+ * Employee, badge or customer number tied to a person.
37
+ *
38
+ * Emitted **only** when an explicit label names it — see `rules/cues.ts`.
39
+ * There is no pattern for an internal identifier because there is no shape
40
+ * for one; without the label, a digit run is not distinguishable from any
41
+ * other. The type exists so that a labelled employee number is filed
42
+ * correctly instead of being claimed by the phone pattern.
43
+ */
44
+ INTERNAL_ID = "INTERNAL_ID",
35
45
  IMEI = "IMEI",
36
46
  GPS_COORDINATES = "GPS_COORDINATES",
37
47
  UUID = "UUID",
package/dist/esm/types.js CHANGED
@@ -33,6 +33,16 @@ export var EntityType;
33
33
  EntityType["HEALTH_INSURANCE"] = "HEALTH_INSURANCE";
34
34
  EntityType["HEALTHCARE_PROVIDER"] = "HEALTHCARE_PROVIDER";
35
35
  EntityType["CHAMBER_OF_COMMERCE"] = "CHAMBER_OF_COMMERCE";
36
+ /**
37
+ * Employee, badge or customer number tied to a person.
38
+ *
39
+ * Emitted **only** when an explicit label names it — see `rules/cues.ts`.
40
+ * There is no pattern for an internal identifier because there is no shape
41
+ * for one; without the label, a digit run is not distinguishable from any
42
+ * other. The type exists so that a labelled employee number is filed
43
+ * correctly instead of being claimed by the phone pattern.
44
+ */
45
+ EntityType["INTERNAL_ID"] = "INTERNAL_ID";
36
46
  EntityType["IMEI"] = "IMEI";
37
47
  EntityType["GPS_COORDINATES"] = "GPS_COORDINATES";
38
48
  EntityType["UUID"] = "UUID";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "euredact",
3
- "version": "0.3.7",
3
+ "version": "0.3.8",
4
4
  "description": "European PII redaction SDK -- fast, zero-dependency, rule-based detection and redaction of personal data across 31 European countries",
5
5
  "homepage": "https://euredact.dev",
6
6
  "license": "Apache-2.0",