euredact 0.5.1 → 0.6.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.
@@ -105,18 +105,50 @@ const SECRET_CONTEXT = [
105
105
  // ---------------------------------------------------------------------------
106
106
  // DOB context keywords (reused across SHARED patterns)
107
107
  // ---------------------------------------------------------------------------
108
+ // Every way the documents this engine sees introduce a date of birth.
109
+ //
110
+ // DOB recall was 62.8% -- 33,441 unmasked birth dates -- because this covered
111
+ // seven languages of thirty-one. Eleven countries scored exactly 100% (the
112
+ // listed languages) while twenty sat at 42-51%, which is what proved it was
113
+ // coverage rather than capability (rules-engine#38).
114
+ //
115
+ // Matching is substring, not word-boundary, so every entry was screened against
116
+ // the 29.2-million-character corpus for occurrences inside a longer word. Two
117
+ // were rejected: `fædd` lives inside `fæddur`, and `born` was found inside
118
+ // `gabornagy` and `gabornemeth` -- Hungarian names as e-mail local parts -- so
119
+ // `"born "` with the trailing space is listed instead.
120
+ //
121
+ // DOB_CONTEXT is gone: it was a second, shorter copy missing `nato il`,
122
+ // `nascido`, `geburtstag` and more, so ISO dates were gated on fewer keywords
123
+ // than DD/MM/YYYY ones for no stated reason.
108
124
  const DOB_CONTEXT = [
125
+ // Germanic / Romance -- the original set, kept verbatim.
109
126
  "geboren", "geboortedatum", "date de naissance", "né le", "née le",
110
127
  "né(e) le", "nee le", "nee(e) le", "date of birth", "DOB",
111
128
  "Geburtsdatum", "geboren am", "geboren op", "nascido", "nacido",
112
129
  "data di nascita", "nato il", "nata il", "geb.", "geb.datum", "geb ",
113
130
  "birth date", "birthday", "naissance", "geboorte", "geburtstag",
114
- ];
115
- const DOB_ISO_CONTEXT = [
116
- "geboren", "geboortedatum", "date de naissance", "né le", "née le",
117
- "né(e) le", "nee le", "date of birth", "DOB", "Geburtsdatum",
118
- "geboren am", "geboren op", "geb.", "birth date", "birthday",
119
- "naissance", "geboorte",
131
+ // English and Italian forms the list was missing.
132
+ "born ", "d.o.b", "nato/a il",
133
+ // Nordic.
134
+ "født", "fødselsdato", "född", "födelsedatum",
135
+ "syntynyt", "syntymäaika", "fæddur", "fæðingardagur",
136
+ // Greek -- Greece and Cyprus.
137
+ "γεννηθείς", "γεννήθηκε", "ημερομηνία γέννησης",
138
+ // West Slavic.
139
+ "urodzony", "urodzona", "data urodzenia",
140
+ "narozen", "datum narození", "narodený", "dátum narodenia",
141
+ // Hungarian, Romanian, Bulgarian.
142
+ "született", "születési", "născut", "născută", "data nașterii",
143
+ "роден", "родена", "дата на раждане",
144
+ // South Slavic.
145
+ "rođen", "rođena", "datum rođenja", "rojen", "rojena", "datum rojstva",
146
+ // Baltic.
147
+ "sündinud", "sünniaeg", "dzimis", "dzimusi", "dzimšanas datums",
148
+ "gimęs", "gimusi", "gimimo data",
149
+ // Maltese, Irish, Turkish (Cyprus).
150
+ "twieled", "twieldet", "data tat-twelid",
151
+ "rugadh", "dáta breithe", "doğum tarihi",
120
152
  ];
121
153
  const DATE_OF_DEATH_CONTEXT = [
122
154
  "overleden", "overlijdensdatum", "date de décès", "décédé le",
@@ -223,7 +255,12 @@ const SHARED = {
223
255
  // --- Secret / API Key (connection strings with embedded credentials) ---
224
256
  p(types_js_1.EntityType.SECRET, String.raw `(?:mongodb|mysql|postgres(?:ql)?|redis|amqp|rabbitmq):\/\/[^\s:]{1,256}:[^\s@]{1,256}@[^\s]+`, null, "Connection string with credentials"),
225
257
  // --- Secret / API Key (assignment-based: KEY=value or KEY: value) ---
226
- p(types_js_1.EntityType.SECRET, String.raw `(?<=[:=] )\S{8,}|(?<=[:=])\S{8,}`, "high_entropy", "Assigned secret value", SECRET_CONTEXT, true),
258
+ // The final character may not be sentence punctuation. `\S` does not stop
259
+ // at a full stop, so "Reisepass: CA1234567." was claimed as the
260
+ // ten-character span "CA1234567." -- a passport number plus the sentence's
261
+ // period, masked as a credential because a "credentials" word sat within
262
+ // the context window (rules-engine#35).
263
+ p(types_js_1.EntityType.SECRET, String.raw `(?<=[:=] )\S{7,}[^\s.,;:!?)'"]|(?<=[:=])\S{7,}[^\s.,;:!?)'"]`, "high_entropy", "Assigned secret value", SECRET_CONTEXT, true),
227
264
  // --- Secret / API Key (entropy-based fallback for longer tokens) ---
228
265
  // Only the *trailing* \b is replaced. It could not match when the token run
229
266
  // ended on "-", "+" or "/", so the engine backtracked across two
@@ -241,7 +278,7 @@ const SHARED = {
241
278
  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),
242
279
  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),
243
280
  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),
244
- 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),
281
+ 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_CONTEXT, true),
245
282
  ],
246
283
  };
247
284
  const NL = {
@@ -251,7 +288,7 @@ const NL = {
251
288
  p(types_js_1.EntityType.NATIONAL_ID, String.raw `\b[0-9]{9}\b`, "bsn"),
252
289
  p(types_js_1.EntityType.NATIONAL_ID, String.raw `\b[0-9]{2,4}\.[0-9]{2,4}\.[0-9]{2,4}\b`, "bsn"),
253
290
  p(types_js_1.EntityType.IBAN, String.raw `\bNL\d{2}\s?[A-Z]{4}\s?\d{4}\s?\d{4}\s?\d{2}\b`, "iban"),
254
- p(types_js_1.EntityType.VAT, String.raw `\bNL\d{9}B\d{2}\b`, "vat_nl"),
291
+ p(types_js_1.EntityType.VAT, String.raw `\bNL[\s.]?\d{9}B\d{2}\b`, "vat_nl"),
255
292
  p(types_js_1.EntityType.CHAMBER_OF_COMMERCE, String.raw `\b\d{8}\b`, "kvk", "", ["KvK", "Kamer van Koophandel", "kvk-nummer", "KVK-nummer", "handelsregister", "chamber of commerce"], true),
256
293
  p(types_js_1.EntityType.PHONE, String.raw `\b0[1-9]\d[\s\-]?\d{3}[\s\-]?\d{4}\b`),
257
294
  p(types_js_1.EntityType.PHONE, String.raw `\b0[1-9]\d{2}[\s\-]?\d{6,7}\b`),
@@ -278,7 +315,7 @@ const BE = {
278
315
  p(types_js_1.EntityType.PASSPORT, String.raw `\b[A-Z]{2}\d{6}\b`, null, "", PASSPORT_CONTEXT, true),
279
316
  p(types_js_1.EntityType.DRIVERS_LICENSE, String.raw `\b\d{10}\b`, null, "", ["rijbewijs", "permis de conduire", "driving licence", "driving license", "rijbewijsnummer"], true),
280
317
  p(types_js_1.EntityType.LICENSE_PLATE, String.raw `\b[12][\-\s]?[A-Z]{3}[\-\s]?\d{3}\b`),
281
- p(types_js_1.EntityType.POSTAL_CODE, String.raw `\b[1-9]\d{3}\b`, null, "", ["postcode", "code postal", "postnummer", "postal code", "zip", "B-", "adres", "adresse", "wonende", "woonplaats", "rue", "straat", "laan", "avenue", "boulevard", "plein", "steenweg", "chaussée", "domicilié", "gedomicilieerd", "Levering:", "siège"], true),
318
+ p(types_js_1.EntityType.POSTAL_CODE, String.raw `\b[1-9]\d{3}\b`, null, "", ["postcode", "code postal", "postnummer", "postal code", "zip", "B-", "adres", "adresse", "wonende", "woonplaats", "domicile", "rue", "straat", "laan", "avenue", "boulevard", "plein", "steenweg", "chaussée", "domicilié", "gedomicilieerd", "Levering:", "siège"], true),
282
319
  p(types_js_1.EntityType.POSTAL_CODE, String.raw `(?<=, )[1-9]\d{3}(?= [A-Z])`),
283
320
  p(types_js_1.EntityType.HEALTHCARE_PROVIDER, String.raw `\b\d{1}[\-.]?\d{5}[\-.]?\d{2}[\-.]?\d{3}\b`, null, "", ["RIZIV", "INAMI", "arts", "médecin", "zorgverlener", "prestataire", "dokter", "doctor"], true),
284
321
  ],
@@ -303,7 +340,7 @@ const DE = {
303
340
  // separates the city code: "B-AB 1234", "M-XY 99".
304
341
  p(types_js_1.EntityType.LICENSE_PLATE, String.raw `\b[A-ZÄÖÜ]{1,3}[\s\-][A-Z]{1,2}[\s\-]?\d{1,4}[EH]?\b`),
305
342
  p(types_js_1.EntityType.POSTAL_CODE, String.raw `\b\d{5}\b`, null, "", ["PLZ", "Postleitzahl", "postal code", "postcode", "Anschrift", "Adresse", "Wohnort", "Ort", "Stadt", "wohnt", "wohnhaft", "Straße", "Str.", "Weg", "Platz", "Allee", "Ring"], true),
306
- p(types_js_1.EntityType.CHAMBER_OF_COMMERCE, String.raw `\bHR[AB]\s?\d{4,6}\b`),
343
+ p(types_js_1.EntityType.CHAMBER_OF_COMMERCE, String.raw `\bHR[AB]\s?\d{4,6}(?:\s[A-Z]\b)?`),
307
344
  p(types_js_1.EntityType.SSN, String.raw `\b\d{2}[\s]?\d{6}[\s]?[A-Z][\s]?\d{3}\b`, null, "", ["Rentenversicherung", "RV-Nummer", "Sozialversicherung", "SV-Nummer", "Versicherungsnummer", "Rentenversicherungsnummer", "Sozialversicherungsnummer", "SVNR", "SV-Nr", "RVNR"], true),
308
345
  p(types_js_1.EntityType.TAX_ID, String.raw `\b\d{2,3}/\d{3,4}/\d{4,5}\b`, null, "", ["Steuernummer", "St.-Nr", "StNr", "Finanzamt", "Steuer"], true),
309
346
  p(types_js_1.EntityType.TAX_ID, String.raw `\b\d{13}\b`, null, "", ["Steuernummer", "St.-Nr", "StNr", "Finanzamt", "Steuer"], true),
@@ -316,7 +353,7 @@ const AT = {
316
353
  patterns: [
317
354
  p(types_js_1.EntityType.NATIONAL_ID, String.raw `\b\d{4}\s?\d{6}\b`, "austrian_svnr"),
318
355
  p(types_js_1.EntityType.IBAN, String.raw `\bAT\d{2}\s?\d{4}\s?\d{4}\s?\d{4}\s?\d{4}\b`, "iban"),
319
- p(types_js_1.EntityType.VAT, String.raw `\bATU\d{8}\b`),
356
+ p(types_js_1.EntityType.VAT, String.raw `\bATU[\s.]?\d{8}\b`),
320
357
  p(types_js_1.EntityType.PHONE, String.raw `\b0\d{3,4}[\s\-]?\d{5,8}\b`),
321
358
  // Short area codes (Vienna is "01") split across three groups:
322
359
  // "01 53460 2215". The pattern above requires 3-4 digits after the trunk
@@ -357,7 +394,7 @@ const FR = {
357
394
  p(types_js_1.EntityType.PHONE, String.raw `\+33\s?[1-9][\s.\-]?\d{2}[\s.\-]?\d{2}[\s.\-]?\d{2}[\s.\-]?\d{2}`),
358
395
  p(types_js_1.EntityType.PASSPORT, String.raw `\b\d{2}[A-Z]{2}\d{5}\b`, null, "", PASSPORT_CONTEXT, true),
359
396
  p(types_js_1.EntityType.LICENSE_PLATE, String.raw `\b[A-Z]{2}[\-\s]?\d{3}[\-\s]?[A-Z]{2}\b`),
360
- p(types_js_1.EntityType.POSTAL_CODE, String.raw `\b(?:0[1-9]|[1-8]\d|9[0-5]|97[1-6])\d{3}\b`, null, "", ["code postal", "CP", "postal code", "postcode", "adresse", "domicilié", "résidant", "rue", "avenue", "boulevard", "place", "chemin", "allée", "impasse", "ville"], true),
397
+ p(types_js_1.EntityType.POSTAL_CODE, String.raw `\b(?:0[1-9]|[1-8]\d|9[0-5]|97[1-6])\d{3}\b`, null, "", ["code postal", "CP", "postal code", "postcode", "adresse", "domicile", "domicilié", "résidant", "rue", "avenue", "boulevard", "place", "chemin", "allée", "impasse", "ville"], true),
361
398
  p(types_js_1.EntityType.CHAMBER_OF_COMMERCE, String.raw `\b\d{3}\s?\d{3}\s?\d{3}\b`, null, "", ["SIREN", "siren", "RCS", "entreprise", "immatricul", "numéro d'entreprise", "registre du commerce"], true),
362
399
  p(types_js_1.EntityType.CHAMBER_OF_COMMERCE, String.raw `\b\d{3}\s?\d{3}\s?\d{3}\s?\d{5}\b`, null, "", ["SIRET", "siret", "établissement", "immatricul"], true),
363
400
  p(types_js_1.EntityType.TAX_ID, String.raw `\b\d{13}\b`, null, "", ["numéro fiscal", "SPI", "référence fiscale", "avis d'impôt", "impôt sur le revenu", "déclaration fiscale", "fiscal"], true),
@@ -390,9 +427,9 @@ const SE = {
390
427
  p(types_js_1.EntityType.PHONE, String.raw `\b0\d{1,3}[\-\s]?\d{2,3}[\s]?\d{2,3}[\s]?\d{2}\b`),
391
428
  p(types_js_1.EntityType.PHONE, String.raw `\+46\s?\d{1,3}[\s\-]?\d{2,3}[\s\-]?\d{2,3}[\s\-]?\d{2}`),
392
429
  p(types_js_1.EntityType.LICENSE_PLATE, String.raw `\b[A-Z]{3}\s?\d{2}[A-Z0-9]\b`),
393
- p(types_js_1.EntityType.POSTAL_CODE, String.raw `\b\d{3}\s?\d{2}\b`, null, "", ["postnummer", "postort", "postkod", "adress", "bostadsadress", "gatuadress", "boende", "gatan", "vägen", "gata", "väg", "allé", "plats", "torg", "stigen", "Bostadsadress"], true),
394
- p(types_js_1.EntityType.POSTAL_CODE, String.raw `\b\d{4}\s\d{2}\b`, null, "", ["postnummer", "postort", "postkod", "adress", "bostadsadress", "gatuadress", "boende", "gatan", "vägen", "gata", "väg", "allé"], true),
395
- p(types_js_1.EntityType.POSTAL_CODE, String.raw `\b\d{6}\b`, null, "", ["postnummer", "postort", "postkod", "adress", "bostadsadress", "gatuadress", "boende", "gatan", "vägen", "gata", "väg", "allé"], true),
430
+ p(types_js_1.EntityType.POSTAL_CODE, String.raw `\b\d{3}\s?\d{2}\b`, null, "", ["postnummer", "postort", "postkod", "adress", "Postal:", "Address:", "bostadsadress", "gatuadress", "boende", "gatan", "vägen", "gata", "väg", "allé", "plats", "torg", "stigen", "Bostadsadress"], true),
431
+ p(types_js_1.EntityType.POSTAL_CODE, String.raw `\b\d{4}\s\d{2}\b`, null, "", ["postnummer", "postort", "postkod", "adress", "Postal:", "Address:", "bostadsadress", "gatuadress", "boende", "gatan", "vägen", "gata", "väg", "allé"], true),
432
+ p(types_js_1.EntityType.POSTAL_CODE, String.raw `\b\d{6}\b`, null, "", ["postnummer", "postort", "postkod", "adress", "Postal:", "Address:", "bostadsadress", "gatuadress", "boende", "gatan", "vägen", "gata", "väg", "allé"], true),
396
433
  p(types_js_1.EntityType.CHAMBER_OF_COMMERCE, String.raw `\b\d{6}-?\d{4}\b`, null, "", ["organisationsnummer", "org.nr", "org nr", "Bolagsverket", "registreringsnummer"], true),
397
434
  ],
398
435
  };
@@ -408,7 +445,7 @@ const NO = {
408
445
  p(types_js_1.EntityType.PHONE, String.raw `\b[2-9]\d\s?\d{2}\s?\d{2}\s?\d{2}\b`),
409
446
  p(types_js_1.EntityType.PHONE, String.raw `\+47\s?\d{2}\s?\d{2}\s?\d{2}\s?\d{2}`),
410
447
  p(types_js_1.EntityType.LICENSE_PLATE, String.raw `\b[A-Z]{2}\s?\d{5}\b`),
411
- p(types_js_1.EntityType.POSTAL_CODE, String.raw `\b\d{4}\b`, null, "", ["postnummer", "poststed", "postnr", "adresse", "bostedsadresse", "gate", "vei", "veien", "gata", "plass", "stien", "allé"], true),
448
+ p(types_js_1.EntityType.POSTAL_CODE, String.raw `\b\d{4}\b`, null, "", ["postnummer", "poststed", "postnr", "adresse", "bostedsadresse", "gate", "vei", "veien", "gata", "plass", "stien", "allé", "Postal:", "Address:"], true),
412
449
  ],
413
450
  };
414
451
  const FI = {
@@ -417,7 +454,7 @@ const FI = {
417
454
  patterns: [
418
455
  p(types_js_1.EntityType.NATIONAL_ID, String.raw `\b\d{6}[-+ABCDEFYXWVU]\d{3}[0-9A-FHJK-NPR-Y]\b`, "finnish_hetu"),
419
456
  p(types_js_1.EntityType.IBAN, String.raw `\bFI\d{2}\s?\d{4}\s?\d{4}\s?\d{4}\s?\d{2}\b`, "iban"),
420
- p(types_js_1.EntityType.VAT, String.raw `\bFI\d{8}\b`),
457
+ p(types_js_1.EntityType.VAT, String.raw `\bFI[\s.]?\d{8}\b`),
421
458
  p(types_js_1.EntityType.CHAMBER_OF_COMMERCE, String.raw `\b\d{7}-\d\b`, "finnish_business_id"),
422
459
  p(types_js_1.EntityType.PHONE, String.raw `\b0\d{1,2}\s?\d{3,4}\s?\d{3,4}\b`),
423
460
  p(types_js_1.EntityType.PHONE, String.raw `\+358\s?\d{1,3}[\s\-]?\d{3,4}[\s\-]?\d{3,4}`),
@@ -435,7 +472,7 @@ const IS = {
435
472
  p(types_js_1.EntityType.PHONE, String.raw `\b[3-9]\d{6}\b`, null, "", ["sími", "símanúmer", "farsími", "gsm", "phone", "tel", "hringja", "hringdu", "ná í", "nás á", "nás", "SMS", "sent to"], true),
436
473
  p(types_js_1.EntityType.PHONE, String.raw `\+354\s?\d{3}\s?\d{4}`),
437
474
  p(types_js_1.EntityType.LICENSE_PLATE, String.raw `\b[A-Z]{2}\s?\d{3}\b`, null, "", ["skráningarnúmer", "bílnúmer", "ökutæki", "bifreið", "plata"], true),
438
- p(types_js_1.EntityType.POSTAL_CODE, String.raw `\b[1-9]\d{2}\b`, null, "", ["póstnúmer", "póstfang", "staður", "heimilisfang"], true),
475
+ p(types_js_1.EntityType.POSTAL_CODE, String.raw `\b[1-9]\d{2}\b`, null, "", ["póstnúmer", "póstfang", "staður", "heimilisfang", "Postal:", "Address:"], true),
439
476
  ],
440
477
  };
441
478
  const IT = {
@@ -445,7 +482,7 @@ const IT = {
445
482
  p(types_js_1.EntityType.NATIONAL_ID, String.raw `\b[A-Z]{6}\d{2}[ABCDEHLMPRST]\d{2}[A-Z]\d{3}[A-Z]\b`, "italian_cf"),
446
483
  p(types_js_1.EntityType.IBAN, String.raw `\bIT\d{2}\s?[A-Z]\d{3}\s?\d{4}\s?\d{4}\s?\d{4}\s?\d{4}\s?\d{3}\b`, "iban"),
447
484
  p(types_js_1.EntityType.IBAN, String.raw `\bIT\d{2}[A-Z]\d{22}\b`, "iban"),
448
- p(types_js_1.EntityType.VAT, String.raw `\bIT\d{11}\b`),
485
+ p(types_js_1.EntityType.VAT, String.raw `\bIT[\s.]?\d{11}\b`),
449
486
  p(types_js_1.EntityType.PHONE, String.raw `\b3\d{2}[\s\-]?\d{3}[\s\-]?\d{4}\b`),
450
487
  p(types_js_1.EntityType.PHONE, String.raw `\b0\d{1,3}[\s\-]?\d{6,8}\b`),
451
488
  p(types_js_1.EntityType.PHONE, String.raw `\b0\d{1,2}[\s\-]\d{3}[\s\-]\d{4}\b`),
@@ -461,7 +498,7 @@ const ES = {
461
498
  p(types_js_1.EntityType.NATIONAL_ID, String.raw `\b\d{8}[A-Z]\b`, "spanish_dni"),
462
499
  p(types_js_1.EntityType.NATIONAL_ID, String.raw `\b[XYZ]\d{7}[A-Z]\b`, "spanish_nie"),
463
500
  p(types_js_1.EntityType.IBAN, String.raw `\bES\d{2}\s?\d{4}\s?\d{4}\s?\d{4}\s?\d{4}\s?\d{4}\b`, "iban"),
464
- p(types_js_1.EntityType.VAT, String.raw `\bES[A-Z0-9]\d{7}[A-Z0-9]\b`),
501
+ p(types_js_1.EntityType.VAT, String.raw `\bES[\s.]?[A-Z0-9]\d{7}[A-Z0-9]\b`),
465
502
  p(types_js_1.EntityType.PHONE, String.raw `\b[679]\d{2}[\s\-]?\d{3}[\s\-]?\d{3}\b`),
466
503
  p(types_js_1.EntityType.PHONE, String.raw `\b[679]\d{8}\b`),
467
504
  p(types_js_1.EntityType.PHONE, String.raw `\b[679]\d[\s\-]?\d{3}[\s\-]?\d{2}[\s\-]?\d{2}\b`),
@@ -484,7 +521,7 @@ const PT = {
484
521
  p(types_js_1.EntityType.NATIONAL_ID, String.raw `\b[1-35-9]\d{8}\b`, "portuguese_nif"),
485
522
  p(types_js_1.EntityType.NATIONAL_ID, String.raw `\b[1-35-9]\d{2}[\s.]\d{3}[\s.]\d{3}\b`, "portuguese_nif"),
486
523
  p(types_js_1.EntityType.IBAN, String.raw `\bPT\d{2}\s?\d{4}\s?\d{4}\s?\d{4}\s?\d{4}\s?\d{5}\b`, "iban"),
487
- p(types_js_1.EntityType.VAT, String.raw `\bPT\d{9}\b`),
524
+ p(types_js_1.EntityType.VAT, String.raw `\bPT[\s.]?\d{9}\b`),
488
525
  p(types_js_1.EntityType.PHONE, String.raw `\b[29]\d{2}[\s\-]?\d{3}[\s\-]?\d{3}\b`),
489
526
  p(types_js_1.EntityType.PHONE, String.raw `\b[29]\d{8}\b`),
490
527
  p(types_js_1.EntityType.PHONE, String.raw `\+351\s?[29]\d{2}[\s\-]?\d{3}[\s\-]?\d{3}`),
@@ -517,7 +554,7 @@ const PL = {
517
554
  p(types_js_1.EntityType.TAX_ID, String.raw `\b\d{3}-?\d{3}-?\d{2}-?\d{2}\b`, "polish_nip"),
518
555
  p(types_js_1.EntityType.IBAN, String.raw `\bPL\d{2}\s?\d{4}\s?\d{4}\s?\d{4}\s?\d{4}\s?\d{4}\s?\d{4}\b`, "iban"),
519
556
  p(types_js_1.EntityType.IBAN, String.raw `\bPL\d{26}\b`, "iban"),
520
- p(types_js_1.EntityType.VAT, String.raw `\bPL\d{10}\b`),
557
+ p(types_js_1.EntityType.VAT, String.raw `\bPL[\s.]?\d{10}\b`),
521
558
  p(types_js_1.EntityType.PHONE, String.raw `\b[5-8]\d{2}[\s\-]?\d{3}[\s\-]?\d{3}\b`),
522
559
  p(types_js_1.EntityType.PHONE, String.raw `\b[5-8]\d{8}\b`),
523
560
  p(types_js_1.EntityType.PHONE, String.raw `\+48\s?[5-8]\d{2}[\s\-]?\d{3}[\s\-]?\d{3}`),
@@ -531,7 +568,7 @@ const IE = {
531
568
  p(types_js_1.EntityType.NATIONAL_ID, String.raw `\b\d{7}[A-W][ABWTXZ]?\b`, "irish_pps"),
532
569
  p(types_js_1.EntityType.IBAN, String.raw `\bIE\d{2}\s?[A-Z]{4}\s?\d{4}\s?\d{4}\s?\d{4}\s?\d{2}\b`, "iban"),
533
570
  p(types_js_1.EntityType.IBAN, String.raw `\bIE\d{2}[A-Z]{4}\d{14}\b`, "iban"),
534
- p(types_js_1.EntityType.VAT, String.raw `\bIE\d{7}[A-Z]\b`),
571
+ p(types_js_1.EntityType.VAT, String.raw `\bIE[\s.]?\d{7}[A-Z]\b`),
535
572
  p(types_js_1.EntityType.PHONE, String.raw `\b0[89]\d[\s\-]?\d{3}[\s\-]?\d{4}\b`),
536
573
  p(types_js_1.EntityType.PHONE, String.raw `\b0[89]\d{8}\b`),
537
574
  p(types_js_1.EntityType.PHONE, String.raw `\b0[1-9]\d{0,2}[\s\-]?\d{5,8}\b`),
@@ -547,7 +584,7 @@ const UK = {
547
584
  p(types_js_1.EntityType.HEALTH_INSURANCE, String.raw `\b\d{3}\s?\d{3}\s?\d{4}\b`, "uk_nhs", "", ["NHS", "NHS number", "health number", "NHS no"], true),
548
585
  p(types_js_1.EntityType.IBAN, String.raw `\bGB\d{2}\s?[A-Z]{4}\s?\d{4}\s?\d{4}\s?\d{4}\s?\d{2}\b`, "iban"),
549
586
  p(types_js_1.EntityType.IBAN, String.raw `\bGB\d{2}[A-Z]{4}\d{14}\b`, "iban"),
550
- p(types_js_1.EntityType.VAT, String.raw `\bGB\d{9}\b`),
587
+ p(types_js_1.EntityType.VAT, String.raw `\bGB[\s.]?\d{3}[\s.]?\d{4}[\s.]?\d{2}\b`),
551
588
  p(types_js_1.EntityType.PHONE, String.raw `\b07[4-9]\d{2}[\s\-]?\d{3}[\s\-]?\d{3}\b`),
552
589
  p(types_js_1.EntityType.PHONE, String.raw `\b07[4-9]\d{8}\b`),
553
590
  p(types_js_1.EntityType.PHONE, String.raw `\b0[12]\d{2}[\s\-]?\d{3}[\s\-]?\d{4}\b`),
@@ -563,7 +600,7 @@ const BG = {
563
600
  patterns: [
564
601
  p(types_js_1.EntityType.NATIONAL_ID, String.raw `\b\d{10}\b`, "bulgarian_egn"),
565
602
  p(types_js_1.EntityType.IBAN, String.raw `\bBG\d{2}\s?[A-Z]{4}\s?\d{4}\s?\d{4}\s?\d{4}\s?\d{2}\b`, "iban"),
566
- p(types_js_1.EntityType.VAT, String.raw `\bBG\d{9,10}\b`),
603
+ p(types_js_1.EntityType.VAT, String.raw `\bBG[\s.]?\d{9,10}\b`),
567
604
  p(types_js_1.EntityType.PHONE, String.raw `\b0?[89][789][\s\-]?\d{3}[\s\-]?\d{3,4}\b`),
568
605
  p(types_js_1.EntityType.PHONE, String.raw `\b0?[89][789]\d{7,8}\b`),
569
606
  p(types_js_1.EntityType.PHONE, String.raw `\+359\s?[89][789][\s\-]?\d{3}[\s\-]?\d{3,4}`),
@@ -578,7 +615,7 @@ const CY = {
578
615
  p(types_js_1.EntityType.NATIONAL_ID, String.raw `\b\d{8}[A-Z]\b`),
579
616
  p(types_js_1.EntityType.IBAN, String.raw `\bCY\d{2}\s?\d{4}\s?\d{4}\s?\d{4}\s?\d{4}\s?\d{4}\s?\d{4}\b`, "iban"),
580
617
  p(types_js_1.EntityType.IBAN, String.raw `\bCY\d{26}\b`, "iban"),
581
- p(types_js_1.EntityType.VAT, String.raw `\bCY\d{8}[A-Z]\b`),
618
+ p(types_js_1.EntityType.VAT, String.raw `\bCY[\s.]?\d{8}[A-Z]\b`),
582
619
  p(types_js_1.EntityType.PHONE, String.raw `\b[29]\d{3}[\s\-]?\d{4}\b`),
583
620
  p(types_js_1.EntityType.PHONE, String.raw `\b[29]\d[\s\-]?\d{3}[\s\-]?\d{3}\b`),
584
621
  p(types_js_1.EntityType.PHONE, String.raw `\b[29]\d{7}\b`),
@@ -594,7 +631,7 @@ const CZ = {
594
631
  p(types_js_1.EntityType.NATIONAL_ID, String.raw `\b\d{6}/?\d{3,4}\b`, "czech_birth_number"),
595
632
  p(types_js_1.EntityType.IBAN, String.raw `\bCZ\d{2}\s?\d{4}\s?\d{4}\s?\d{4}\s?\d{4}\s?\d{4}\b`, "iban"),
596
633
  p(types_js_1.EntityType.IBAN, String.raw `\bCZ\d{22}\b`, "iban"),
597
- p(types_js_1.EntityType.VAT, String.raw `\bCZ\d{8,10}\b`),
634
+ p(types_js_1.EntityType.VAT, String.raw `\bCZ[\s.]?\d{8,10}\b`),
598
635
  p(types_js_1.EntityType.PHONE, String.raw `\b[67]\d{2}[\s\-]?\d{3}[\s\-]?\d{3}\b`),
599
636
  p(types_js_1.EntityType.PHONE, String.raw `\b[67]\d{8}\b`),
600
637
  p(types_js_1.EntityType.PHONE, String.raw `\+420\s?[67]\d{2}[\s\-]?\d{3}[\s\-]?\d{3}`),
@@ -608,7 +645,7 @@ const SK = {
608
645
  p(types_js_1.EntityType.NATIONAL_ID, String.raw `\b\d{6}/?\d{3,4}\b`, "czech_birth_number"),
609
646
  p(types_js_1.EntityType.IBAN, String.raw `\bSK\d{2}\s?\d{4}\s?\d{4}\s?\d{4}\s?\d{4}\s?\d{4}\b`, "iban"),
610
647
  p(types_js_1.EntityType.IBAN, String.raw `\bSK\d{22}\b`, "iban"),
611
- p(types_js_1.EntityType.VAT, String.raw `\bSK\d{10}\b`),
648
+ p(types_js_1.EntityType.VAT, String.raw `\bSK[\s.]?\d{10}\b`),
612
649
  p(types_js_1.EntityType.PHONE, String.raw `\b0?9\d{2}[\s\-]?\d{3}[\s\-]?\d{3}\b`),
613
650
  p(types_js_1.EntityType.PHONE, String.raw `\+421\s?9\d{2}[\s\-]?\d{3}[\s\-]?\d{3}`),
614
651
  p(types_js_1.EntityType.POSTAL_CODE, String.raw `\b\d{3}\s?\d{2}\b`, null, "", ["PSČ", "poštové", "adresa", "ulica", "Postal:", "Address:"], true),
@@ -621,7 +658,7 @@ const EE = {
621
658
  p(types_js_1.EntityType.NATIONAL_ID, String.raw `\b[1-6]\d{10}\b`, "estonian_id"),
622
659
  p(types_js_1.EntityType.IBAN, String.raw `\bEE\d{2}\s?\d{4}\s?\d{4}\s?\d{4}\s?\d{4}\b`, "iban"),
623
660
  p(types_js_1.EntityType.IBAN, String.raw `\bEE\d{18}\b`, "iban"),
624
- p(types_js_1.EntityType.VAT, String.raw `\bEE\d{9}\b`),
661
+ p(types_js_1.EntityType.VAT, String.raw `\bEE[\s.]?\d{9}\b`),
625
662
  p(types_js_1.EntityType.PHONE, String.raw `\b5\d{3}[\s\-]?\d{4}\b`),
626
663
  p(types_js_1.EntityType.PHONE, String.raw `\b5\d{7}\b`),
627
664
  p(types_js_1.EntityType.PHONE, String.raw `\+372\s?5\d{3}[\s\-]?\d{3,4}`),
@@ -635,7 +672,7 @@ const LT = {
635
672
  p(types_js_1.EntityType.NATIONAL_ID, String.raw `\b[1-6]\d{10}\b`, "estonian_id"),
636
673
  p(types_js_1.EntityType.IBAN, String.raw `\bLT\d{2}\s?\d{4}\s?\d{4}\s?\d{4}\s?\d{4}\b`, "iban"),
637
674
  p(types_js_1.EntityType.IBAN, String.raw `\bLT\d{18}\b`, "iban"),
638
- p(types_js_1.EntityType.VAT, String.raw `\bLT\d{9,12}\b`),
675
+ p(types_js_1.EntityType.VAT, String.raw `\bLT[\s.]?\d{9,12}\b`),
639
676
  p(types_js_1.EntityType.PHONE, String.raw `\b6\d{2}[\s\-]?\d{2}[\s\-]?\d{3}\b`),
640
677
  p(types_js_1.EntityType.PHONE, String.raw `\b6\d{7}\b`),
641
678
  p(types_js_1.EntityType.PHONE, String.raw `\b8[\s\-]?6\d{2}[\s\-]?\d{2}[\s\-]?\d{3}\b`),
@@ -651,7 +688,7 @@ const LV = {
651
688
  p(types_js_1.EntityType.NATIONAL_ID, String.raw `\b(?:0[1-9]|[12]\d|3[01])(?:0[1-9]|1[0-2])\d{7}\b`, null, "", ["personas kods", "PK", "identifikācijas"], true),
652
689
  p(types_js_1.EntityType.IBAN, String.raw `\bLV\d{2}\s?[A-Z]{4}\s?\d{4}\s?\d{4}\s?\d{4}\s?\d\b`, "iban"),
653
690
  p(types_js_1.EntityType.IBAN, String.raw `\bLV\d{2}[A-Z]{4}\d{13}\b`, "iban"),
654
- p(types_js_1.EntityType.VAT, String.raw `\bLV\d{11}\b`),
691
+ p(types_js_1.EntityType.VAT, String.raw `\bLV[\s.]?\d{11}\b`),
655
692
  p(types_js_1.EntityType.PHONE, String.raw `\b[2]\d{3}[\s\-]?\d{4}\b`),
656
693
  p(types_js_1.EntityType.PHONE, String.raw `\b[2]\d{7}\b`),
657
694
  p(types_js_1.EntityType.PHONE, String.raw `\+371\s?[2]\d{3}[\s\-]?\d{3,4}`),
@@ -666,7 +703,7 @@ const HR = {
666
703
  p(types_js_1.EntityType.IBAN, String.raw `\bHR\d{2}\s?\d{4}\s?\d{4}\s?\d{4}\s?\d{4}\s?\d{3}\b`, "iban"),
667
704
  p(types_js_1.EntityType.IBAN, String.raw `\bHR\d{2}\s\d{4}\s\d{4}\s\d{4}\s\d{5}\b`, "iban"),
668
705
  p(types_js_1.EntityType.IBAN, String.raw `\bHR\d{19}\b`, "iban"),
669
- p(types_js_1.EntityType.VAT, String.raw `\bHR\d{11}\b`),
706
+ p(types_js_1.EntityType.VAT, String.raw `\bHR[\s.]?\d{11}\b`),
670
707
  p(types_js_1.EntityType.PHONE, String.raw `\b0?9[12579][\s\-]?\d{3}[\s\-]?\d{3,4}\b`),
671
708
  p(types_js_1.EntityType.PHONE, String.raw `\b0?9[12579]\d{7,8}\b`),
672
709
  p(types_js_1.EntityType.PHONE, String.raw `\+385\s?9[12579][\s\-]?\d{3}[\s\-]?\d{3,4}`),
@@ -680,7 +717,7 @@ const HU = {
680
717
  p(types_js_1.EntityType.NATIONAL_ID, String.raw `\b\d{9}\b`, "hungarian_taj"),
681
718
  p(types_js_1.EntityType.NATIONAL_ID, String.raw `\b\d{3}\s\d{3}\s\d{3}\b`, "hungarian_taj"),
682
719
  p(types_js_1.EntityType.TAX_ID, String.raw `\b8\d{9}\b`, null, "", ["adóazonosító", "adószám", "adóazonosító jel"], true),
683
- p(types_js_1.EntityType.VAT, String.raw `\bHU\d{8,11}\b`, null, "", ["adószám", "ÁFA"]),
720
+ p(types_js_1.EntityType.VAT, String.raw `\bHU[\s.]?\d{8,11}\b`, null, "", ["adószám", "ÁFA"]),
684
721
  p(types_js_1.EntityType.IBAN, String.raw `\bHU\d{2}\s?\d{4}\s?\d{4}\s?\d{4}\s?\d{4}\s?\d{4}\s?\d{4}\b`, "iban"),
685
722
  p(types_js_1.EntityType.IBAN, String.raw `\bHU\d{26}\b`, "iban"),
686
723
  p(types_js_1.EntityType.PHONE, String.raw `\b06\s?\d{1,2}[\s\-]?\d{3}[\s\-]?\d{3,4}\b`),
@@ -695,7 +732,7 @@ const RO = {
695
732
  patterns: [
696
733
  p(types_js_1.EntityType.NATIONAL_ID, String.raw `\b[1-8]\d{12}\b`, "romanian_cnp"),
697
734
  p(types_js_1.EntityType.IBAN, String.raw `\bRO\d{2}[A-Z]{4}\d{16}\b`, "iban"),
698
- p(types_js_1.EntityType.VAT, String.raw `\bRO\d{2,10}\b`),
735
+ p(types_js_1.EntityType.VAT, String.raw `\bRO[\s.]?\d{2,10}\b`),
699
736
  p(types_js_1.EntityType.PHONE, String.raw `\b0?7[2-9]\d{1}[\s\-]?\d{3}[\s\-]?\d{3,4}\b`),
700
737
  p(types_js_1.EntityType.PHONE, String.raw `\b0?7[2-9]\d{7,8}\b`),
701
738
  p(types_js_1.EntityType.PHONE, String.raw `\+40\s?7[2-9]\d{1}[\s\-]?\d{3}[\s\-]?\d{3,4}`),
@@ -710,7 +747,7 @@ const EL = {
710
747
  p(types_js_1.EntityType.NATIONAL_ID, String.raw `\b\d{11}\b`, "greek_amka"),
711
748
  p(types_js_1.EntityType.IBAN, String.raw `\bGR\d{2}\s?\d{4}\s?\d{4}\s?\d{4}\s?\d{4}\s?\d{4}\s?\d{3}\b`, "iban"),
712
749
  p(types_js_1.EntityType.IBAN, String.raw `\bGR\d{25}\b`, "iban"),
713
- p(types_js_1.EntityType.VAT, String.raw `\bEL\d{9}\b`),
750
+ p(types_js_1.EntityType.VAT, String.raw `\bEL[\s.]?\d{9}\b`),
714
751
  p(types_js_1.EntityType.PHONE, String.raw `\b69\d[\s\-]?\d{3,4}[\s\-]?\d{4}\b`),
715
752
  p(types_js_1.EntityType.PHONE, String.raw `\b69\d{8}\b`),
716
753
  p(types_js_1.EntityType.PHONE, String.raw `\b2\d{1,3}[\s\-]?\d{3,4}[\s\-]?\d{3,4}\b`),
@@ -725,7 +762,7 @@ const SI = {
725
762
  p(types_js_1.EntityType.NATIONAL_ID, String.raw `\b\d{13}\b`, "slovenian_emso"),
726
763
  p(types_js_1.EntityType.IBAN, String.raw `\bSI\d{2}\s?\d{4}\s?\d{4}\s?\d{4}\s?\d{3}\b`, "iban"),
727
764
  p(types_js_1.EntityType.IBAN, String.raw `\bSI\d{17}\b`, "iban"),
728
- p(types_js_1.EntityType.VAT, String.raw `\bSI\d{8}\b`),
765
+ p(types_js_1.EntityType.VAT, String.raw `\bSI[\s.]?\d{8}\b`),
729
766
  p(types_js_1.EntityType.PHONE, String.raw `\b0?[34567]\d{1}[\s\-]?\d{3}[\s\-]?\d{3}\b`),
730
767
  p(types_js_1.EntityType.PHONE, String.raw `\b0?[34567]\d{7}\b`),
731
768
  p(types_js_1.EntityType.PHONE, String.raw `\+386\s?[34567]\d{1}[\s\-]?\d{3}[\s\-]?\d{3}`),
@@ -740,7 +777,7 @@ const MT = {
740
777
  p(types_js_1.EntityType.NATIONAL_ID, String.raw `\b\d{5,7}[MGAPLHBZ]\b`),
741
778
  p(types_js_1.EntityType.IBAN, String.raw `\bMT\d{2}\s?[A-Z]{4}\s?\d{4}\s?\d{4}\s?[A-Z0-9]{4}\s?[A-Z0-9]{4}\s?[A-Z0-9]{4}\s?[A-Z0-9]{3}\b`, "iban"),
742
779
  p(types_js_1.EntityType.IBAN, String.raw `\bMT\d{2}[A-Z]{4}\d{5}[A-Z0-9]{18}\b`, "iban"),
743
- p(types_js_1.EntityType.VAT, String.raw `\bMT\d{8}\b`),
780
+ p(types_js_1.EntityType.VAT, String.raw `\bMT[\s.]?\d{8}\b`),
744
781
  p(types_js_1.EntityType.PHONE, String.raw `\b[79]\d{3}[\s\-]?\d{4}\b`),
745
782
  p(types_js_1.EntityType.PHONE, String.raw `\b[79]\d{7}\b`),
746
783
  p(types_js_1.EntityType.PHONE, String.raw `\+356\s?[79]\d{3}[\s\-]?\d{3,4}`),
@@ -313,9 +313,85 @@ function suppressSequential(_text, match) {
313
313
  const clean = match.text.replace(/[\s.\-]/g, "");
314
314
  return SEQUENTIAL_PATTERNS.test(clean);
315
315
  }
316
+ // ── The engine's own output ───────────────────────────────────────────────
317
+ //
318
+ // Redaction has to be idempotent: running it over a document it already
319
+ // produced must leave the placeholders it wrote alone. It did not.
320
+ // `[POSTAL_CODE]` is thirteen characters of mixed case with an underscore, so
321
+ // the entropy-based SECRET rule read it as a credential and a second pass
322
+ // replaced it with `[SECRET]` (rules-engine#33).
323
+ //
324
+ // All three emitted forms are guarded, because all three come back as input in
325
+ // a re-processing pipeline: `[TYPE]`, `TYPE_1` (referentialIntegrity) and
326
+ // `TYPE_K7Q2` (tokenize -- where a false detection breaks restore()).
327
+ //
328
+ // Only real entity-type names count. Guarding any bracketed upper-case token
329
+ // would be simpler and wrong: `[AKIAIOSFODNN7EXAMPLE]` is one, and also a live
330
+ // AWS key. A placeholder for a *custom* pattern is therefore not covered.
331
+ // The closing bracket is optional: another pattern's span need not agree with
332
+ // this one about where the marker ends. The assigned-secret rule stops before
333
+ // sentence punctuation (rules-engine#35), so it claims "[POSTAL_CODE" without
334
+ // the "]". The opening bracket is still required.
335
+ const PLACEHOLDER = /^(?:\[([A-Z][A-Z0-9_]*)\]?|([A-Z][A-Z0-9_]*?)_(?:\d+|[A-HJ-NP-Z2-9]{4}))$/;
336
+ const KNOWN_TYPE_NAMES = new Set(Object.values(types_js_1.EntityType));
337
+ function suppressRedactionPlaceholder(_text, match) {
338
+ const found = PLACEHOLDER.exec(match.text);
339
+ if (found === null)
340
+ return false;
341
+ const name = found[1] ?? found[2];
342
+ return KNOWN_TYPE_NAMES.has(name);
343
+ }
316
344
  const RECENT_YEAR = /^(?:19[5-9]\d|20[0-3]\d)$/;
317
345
  const POSTAL_CONTEXT_NEAR = /(?:postcode|postal|code\s*postal|PLZ|Postleitzahl|postnummer|postinumero|póstnúmer|zip|straat|straße|strasse|rue\s|via\s|calle\s|rua\s|ulica|utca|street|avenue|laan\s|weg\s|plein|adres|adresse|address|woonplaats|wonende|woonachtig|gevestigd|domicili|demeurant|résidant|residant|bosatt|bopæl|wohnhaft|ansässig|stad\b|ville\b|city\b|Stadt|città|ciudad|cidade|miasto|město|város)/i;
318
346
  const DATE_KEYWORD_NEAR = /(?:født|fødselsdato|fødsel|Fødselsdato|född|födelsedatum|födelsedag|syntynyt|syntymäaika|fæddur|fæðingardagur|geboren|geboortedatum|Geburtsdatum|nascido|nacido|data di nascita|nato il|nata il|Tiltr[æa]delsesdato|Tiltredelsesdato|datum|date\b|Datum|jaar|year|année|Jahr|since|sinds|depuis|seit|\d{2}\.\d{2}\.|(?:januar|februar|marts|april|maj|juni|juli|august|september|oktober|november|december|januari|februari|mars|mei|juin|juillet|août))/i;
347
+ // A sentence boundary: a full stop, question or exclamation mark followed by
348
+ // space, or a line break. Written so it cannot fire inside "11.03.1970", where
349
+ // the periods are not followed by whitespace -- that date is one of the false
350
+ // positives this gate has to reject.
351
+ const SENTENCE_BREAK = /[.!?][ \t\n]|\n/;
352
+ const SENTENCE_BREAK_G = /[.!?][ \t\n]|\n/g;
353
+ // The value's own sentence, bounded either side.
354
+ function sentenceAround(text, start, end, reach = 160) {
355
+ let before = text.slice(Math.max(0, start - reach), start);
356
+ let after = text.slice(end, end + reach);
357
+ let lastEnd = -1;
358
+ SENTENCE_BREAK_G.lastIndex = 0;
359
+ for (let m = SENTENCE_BREAK_G.exec(before); m !== null; m = SENTENCE_BREAK_G.exec(before)) {
360
+ lastEnd = m.index + m[0].length;
361
+ }
362
+ if (lastEnd >= 0)
363
+ before = before.slice(lastEnd);
364
+ const found = SENTENCE_BREAK.exec(after);
365
+ if (found !== null)
366
+ after = after.slice(0, found.index);
367
+ return before + after;
368
+ }
369
+ // A postal label whose only distance from the value is punctuation:
370
+ // "PLZ: 2011", "code postal : 2011", "Woonplaats: 1950".
371
+ const POSTAL_LABEL_TOUCHING = new RegExp(`(?:${POSTAL_CONTEXT_NEAR.source})[\\s:.\\-]{0,4}$`, "i");
372
+ // A capitalised place name after the code: "2000 Antwerpen". A following label
373
+ // ("1970 Fødselsnummer:") is excluded by the colon.
374
+ const CITY_AFTER = /^[ \t](?!\w+\s*:)[A-ZÀ-ÞŁŠŽ][\w\-']{2,}/u;
375
+ // Every position is local to the value or inside its own sentence. Proximity to
376
+ // an address *word* anywhere in a 300-character window is what the old rescue
377
+ // asked, and that is a neighbourhood rather than a position (rules-engine#32).
378
+ function yearSitsInAddressStructure(text, start, end) {
379
+ const before = text.slice(Math.max(0, start - 28), start);
380
+ // "Amsterdam, 2026" -- the comma of an address line.
381
+ if (/,[ \t]*$/.test(before))
382
+ return true;
383
+ // "PLZ: 2011" -- a postal label with nothing but punctuation between.
384
+ if (POSTAL_LABEL_TOUCHING.test(before))
385
+ return true;
386
+ // A capitalised place name immediately after.
387
+ if (CITY_AFTER.test(text.slice(end, end + 24)))
388
+ return true;
389
+ // An address word in the value's own sentence. "Te huur: Lange Nieuwstraat
390
+ // 12, rustige ligging in 2018, vlakbij openbaar vervoer." is an Antwerp
391
+ // postal code in prose; the false positives put the year and the address in
392
+ // different sentences.
393
+ return POSTAL_CONTEXT_NEAR.test(sentenceAround(text, start, end));
394
+ }
319
395
  function suppressYearAsPostal(text, match) {
320
396
  if (match.patternDef.entityType !== types_js_1.EntityType.POSTAL_CODE)
321
397
  return false;
@@ -334,8 +410,10 @@ function suppressYearAsPostal(text, match) {
334
410
  return true;
335
411
  const [before, after] = getContext(text, match.start, match.end);
336
412
  const context = before + after;
337
- // Keep as postal code if postal/address context nearby
338
- if (POSTAL_CONTEXT_NEAR.test(context))
413
+ // Keep as postal code only when the value sits in address *structure* --
414
+ // see yearSitsInAddressStructure for why a 300-character window of address
415
+ // words was the defect rather than the keyword list.
416
+ if (yearSitsInAddressStructure(text, match.start, match.end))
339
417
  return false;
340
418
  // Keep if preceded by comma+space (address pattern: "Amsterdam, 2026")
341
419
  const immediateBefore = text.slice(Math.max(0, match.start - 3), match.start);
@@ -510,6 +588,35 @@ function suppressPostalInsideIban(text, match) {
510
588
  }
511
589
  return false;
512
590
  }
591
+ // An IBAN-shaped run: two letters, two check digits, then grouped
592
+ // alphanumerics. Deliberately not checksum-gated -- the point is to describe the
593
+ // run a human wrote as one account number, including the ones that fail their
594
+ // checksum, because those are exactly the ones no structured detector claims.
595
+ const ACCOUNT_RUN = /\b[A-Z]{2}\d{2}(?:[ ]?[A-Z0-9]{2,4}){2,8}\b/g;
596
+ // "Compte: FR76 3000 4008 0300 0109 5374" fails the IBAN checksum, so no
597
+ // BANK_ACCOUNT candidate survives and the Danish eight-digit phone pattern took
598
+ // "3000 4008" out of the middle of it, leaving the rest in the clear
599
+ // (rules-engine#30). POSTAL_CODE has carried its own version of this guard
600
+ // since 0.3.3, in suppressPostalInsideIban.
601
+ function suppressPhoneInsideAccountRun(text, match) {
602
+ if (match.patternDef.entityType !== types_js_1.EntityType.PHONE)
603
+ return false;
604
+ const lineStart = text.lastIndexOf("\n", match.start) + 1;
605
+ let lineEnd = text.indexOf("\n", match.end);
606
+ if (lineEnd < 0)
607
+ lineEnd = text.length;
608
+ const line = text.slice(lineStart, lineEnd);
609
+ ACCOUNT_RUN.lastIndex = 0;
610
+ for (let m = ACCOUNT_RUN.exec(line); m !== null; m = ACCOUNT_RUN.exec(line)) {
611
+ const runStart = lineStart + m.index;
612
+ const runEnd = runStart + m[0].length;
613
+ if (runStart <= match.start && match.end <= runEnd
614
+ && runEnd - runStart > match.end - match.start) {
615
+ return true;
616
+ }
617
+ }
618
+ return false;
619
+ }
513
620
  function suppressPostalAsHouseNumber(text, match) {
514
621
  if (match.patternDef.entityType !== types_js_1.EntityType.POSTAL_CODE)
515
622
  return false;
@@ -738,12 +845,19 @@ function suppressBicWithoutEvidence(text, match, scratch) {
738
845
  // Repeated per alternative, the engine retried the same unbounded prefix five
739
846
  // times per starting position — the most expensive single regex in the engine
740
847
  // at 197 ms per 1 MB document. Same language, 8x faster on the worst case.
848
+ // An identifier cue that is really a postal label -- see the use below.
849
+ const POSTAL_LABEL_CUE = /^post/i;
741
850
  const ID_CUE_BEFORE = /(?:[\w\-]*(?:Nr|N[°ºo]|Nummer|Numero|Numéro)|No|number|num|Kennzahl|Aktenzeichen|Az|e-?card|Polizze|Police|Policen)\.?\s*:?\s*$/i;
742
851
  // An international dialling prefix earlier on the same line, with nothing but
743
852
  // number punctuation in between: these digits belong to the phone detector.
744
853
  const DIALLING_PREFIX_BEFORE = /\+\d{1,3}[\d\s\-().]*$/;
745
854
  // A country prefix on a postal code — "A-1010 Wien", "B-2000", "L-1234".
746
- const COUNTRY_PREFIXED = /(?:^|[^A-Za-z0-9])[A-Z]{1,2}$/;
855
+ // The boundary excludes a hyphen, which is what makes this a *prefix* rather
856
+ // than a segment. It did not, so any `XX-NNNNN` tail of a hyphenated reference
857
+ // read as a country-prefixed address: in "PV-2026-LU-09143" the `LU` is
858
+ // preceded by `-` and the German five-digit postal pattern kept `09143`
859
+ // (rules-engine#31).
860
+ const COUNTRY_PREFIXED = /(?:^|[^A-Za-z0-9\-])[A-Z]{1,2}$/;
747
861
  /**
748
862
  * Suppress bare digit runs that belong to a longer number, not an address.
749
863
  *
@@ -787,7 +901,16 @@ function suppressPostalInLongerIdentifier(text, match) {
787
901
  if (/^[ \t]+\d/.test(text.slice(match.end, match.end + 4)))
788
902
  return true;
789
903
  // An identifier label introduces the digits: "DiNr. 4471", "Policen-Nr."
790
- if (ID_CUE_BEFORE.test(text.slice(Math.max(0, match.start - 40), match.start)))
904
+ // Unless the label is a *postal* one. ID_CUE_BEFORE matches any word ending
905
+ // in "Nummer", "Nr", "Numero" or "Numéro" -- the wildcard is `[\w\-]*` -- so
906
+ // it matches `postnummer`, `postnr` and `postinumero`, the canonical postal
907
+ // labels of Norway, Denmark and Finland. Those countries write a bare
908
+ // four- or five-digit code, so the isdigit() guard above lets this run and
909
+ // the code was suppressed by its own label: `postnummer: 5020 Bergen`
910
+ // produced nothing at all (rules-engine#41).
911
+ const before = text.slice(Math.max(0, match.start - 40), match.start);
912
+ const cue = ID_CUE_BEFORE.exec(before);
913
+ if (cue !== null && !POSTAL_LABEL_CUE.test(cue[0].trimStart()))
791
914
  return true;
792
915
  // Digits after an international dialling prefix belong to PHONE
793
916
  const [lineStart] = enclosingLine(text, match.start, match.end);
@@ -839,7 +962,7 @@ function suppressRequiresContext(text, match) {
839
962
  return !match.patternDef.contextKeywords.some(kw => context.includes(kw.toLowerCase()));
840
963
  }
841
964
  const TYPE_SUPPRESSORS = {
842
- [types_js_1.EntityType.PHONE]: [suppressCurrency, suppressUnits, suppressReference, suppressMath, suppressPhoneServiceNumber, suppressPhoneDateOverlap, suppressPhoneAsNumberRange],
965
+ [types_js_1.EntityType.PHONE]: [suppressCurrency, suppressUnits, suppressReference, suppressMath, suppressPhoneServiceNumber, suppressPhoneInsideAccountRun, suppressPhoneDateOverlap, suppressPhoneAsNumberRange],
843
966
  [types_js_1.EntityType.NATIONAL_ID]: [suppressCurrency, suppressUnits, suppressReference, suppressLegal, suppressMath, suppressNatidAsPassport, suppressSeNatidAsOrg],
844
967
  [types_js_1.EntityType.SSN]: [suppressCurrency, suppressUnits, suppressReference, suppressMath],
845
968
  [types_js_1.EntityType.TAX_ID]: [suppressCurrency, suppressUnits, suppressReference, suppressMath, suppressTaxidAsIpAddress],
@@ -853,6 +976,8 @@ const TYPE_SUPPRESSORS = {
853
976
  function shouldSuppress(text, match, scratch) {
854
977
  if (suppressSequential(text, match))
855
978
  return true;
979
+ if (suppressRedactionPlaceholder(text, match))
980
+ return true;
856
981
  const typeSups = TYPE_SUPPRESSORS[match.patternDef.entityType];
857
982
  if (typeSups) {
858
983
  for (const s of typeSups) {
@@ -513,10 +513,24 @@ function validatePortugueseNif(candidate) {
513
513
  const check = remainder < 2 ? 0 : 11 - remainder;
514
514
  return parseInt(c[8]) === check;
515
515
  }
516
+ // PESEL carries the century in the month field: +0 is the 1900s, +20 the 2000s,
517
+ // +40 the 2100s, +60 the 2200s, +80 the 1800s. Without a date check this
518
+ // accepted 80666603210 -- day 66 -- as NATIONAL_ID at high confidence
519
+ // (rules-engine#43).
516
520
  function validatePolishPesel(candidate) {
517
521
  const c = candidate.replace(/[\s\-]/g, "");
518
522
  if (c.length !== 11 || !/^\d{11}$/.test(c))
519
523
  return false;
524
+ const rawMonth = parseInt(c.slice(2, 4), 10);
525
+ let month = null;
526
+ for (const off of [0, 20, 40, 60, 80]) {
527
+ if (rawMonth - off >= 1 && rawMonth - off <= 12) {
528
+ month = rawMonth - off;
529
+ break;
530
+ }
531
+ }
532
+ if (month === null || !isRealDay(month, parseInt(c.slice(4, 6), 10)))
533
+ return false;
520
534
  const d = c.split("").map(Number);
521
535
  const weights = [1, 3, 7, 9, 1, 3, 7, 9, 1, 3];
522
536
  let total = 0;
@@ -535,18 +549,53 @@ function validatePolishNip(candidate) {
535
549
  total += d[i] * weights[i];
536
550
  return total % 11 === d[9];
537
551
  }
552
+ // Days per month for the rodné číslo date check. February is 29 because the
553
+ // century is not recoverable from a two-digit year, so a leap year cannot be
554
+ // ruled out -- being permissive by one day is the right direction here.
555
+ const RC_DAYS = [31, 29, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31];
556
+ // The calendar month a rodné číslo month field encodes, or null. Four ranges
557
+ // are legal: 1-12 is a man, +50 a woman, and since 2004 +20 marks a birth on a
558
+ // day whose sequence numbers were exhausted (so +70 is a woman on such a day).
559
+ // Whether `day` exists in `month`, February permissive at 29 because a
560
+ // two-digit year does not reveal the century.
561
+ function isRealDay(month, day) {
562
+ return month >= 1 && month <= 12 && day >= 1 && day <= RC_DAYS[month - 1];
563
+ }
564
+ function czechMonth(raw) {
565
+ for (const offset of [0, 20, 50, 70]) {
566
+ if (raw - offset >= 1 && raw - offset <= 12)
567
+ return raw - offset;
568
+ }
569
+ return null;
570
+ }
571
+ // The date is checked, not only the checksum. Without it the validator accepted
572
+ // an impossible month, and a Czech mobile number is the same shape -- nine
573
+ // digits opening 6 or 7 -- so any mobile that happened to pass mod 11 was typed
574
+ // NATIONAL_ID at high confidence: 606666032 (MM=66), 778836400 (MM=88),
575
+ // 728990603 (MM=89). 284 per corpus pass, the largest single false-positive
576
+ // bucket in the evaluation (rules-engine#37).
538
577
  function validateCzechBirthNumber(candidate) {
539
578
  const c = candidate.replace(/[/\s]/g, "");
540
579
  if (!/^\d+$/.test(c) || (c.length !== 9 && c.length !== 10))
541
580
  return false;
581
+ const month = czechMonth(parseInt(c.slice(2, 4), 10));
582
+ if (month === null)
583
+ return false;
584
+ const day = parseInt(c.slice(4, 6), 10);
585
+ if (day < 1 || day > RC_DAYS[month - 1])
586
+ return false;
542
587
  if (c.length === 10)
543
588
  return parseInt(c) % 11 === 0;
544
589
  return true;
545
590
  }
591
+ // S YY MM DD JJ NNN C -- the leading S carries the century, so the month needs
592
+ // no offset convention here (rules-engine#43).
546
593
  function validateRomanianCnp(candidate) {
547
594
  const c = candidate.replace(/[\s\-]/g, "");
548
595
  if (c.length !== 13 || !/^\d{13}$/.test(c) || !"12345678".includes(c[0]))
549
596
  return false;
597
+ if (!isRealDay(parseInt(c.slice(3, 5), 10), parseInt(c.slice(5, 7), 10)))
598
+ return false;
550
599
  const key = "279146358279";
551
600
  let total = 0;
552
601
  for (let i = 0; i < 12; i++)
@@ -593,10 +642,14 @@ function validateCroatianOib(candidate) {
593
642
  const check = (11 - product) % 10;
594
643
  return check === parseInt(c[10]);
595
644
  }
645
+ // DD MM YYY RR BBB K -- note EMSO opens with the *day*, where the Czech, Polish
646
+ // and Romanian forms open with the year or a century digit (rules-engine#43).
596
647
  function validateSlovenianEmso(candidate) {
597
648
  const c = candidate.replace(/[\s\-]/g, "");
598
649
  if (c.length !== 13 || !/^\d{13}$/.test(c))
599
650
  return false;
651
+ if (!isRealDay(parseInt(c.slice(2, 4), 10), parseInt(c.slice(0, 2), 10)))
652
+ return false;
600
653
  const d = c.split("").map(Number);
601
654
  const weights = [7, 6, 5, 4, 3, 2, 7, 6, 5, 4, 3, 2];
602
655
  let total = 0;