euredact 0.2.0 → 0.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -2,6 +2,8 @@
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.shouldSuppress = shouldSuppress;
4
4
  const types_js_1 = require("../types.js");
5
+ const bicRegistry_js_1 = require("./bicRegistry.js");
6
+ const deDistricts_js_1 = require("./deDistricts.js");
5
7
  const CONTEXT_CHARS = 150;
6
8
  function getContext(text, start, end) {
7
9
  const ctxStart = Math.max(0, start - CONTEXT_CHARS);
@@ -21,12 +23,14 @@ const REFERENCE_BEFORE = /(?:dossier|ref\.?|referentie|reference|référence|fac
21
23
  const LEGAL_BEFORE = /(?:Art(?:ikel|icle|\.)|§|Artikel|Section|Sectie|Afdeling|paragraaf|Absatz|alinéa|punt|point|Punkt|lid)\s*$/i;
22
24
  const MATH_BEFORE = /[=+\-×÷*/]\s*$/;
23
25
  const MATH_AFTER = /^\s*[=+\-×÷*/]/;
26
+ // "A-1010", "B-2000", "L-1234", "CH-8000", "D-10115": a country prefix on a
27
+ // postal code, not a minus sign.
28
+ const COUNTRY_PREFIX_HYPHEN = /(?:^|[^A-Za-z0-9])[A-Z]{1,2}-\s*$/;
24
29
  const SEQUENTIAL_PATTERNS = /^(?:0{6,}|1234567890?|0123456789|9876543210?|1111111111?|000000000|123456789)$/;
25
- const YEAR_PATTERN = /^(?:19[4-9]\d|20[0-3]\d)$/;
26
- const DATE_KEYWORD_NEAR = /(?:jaar|year|année|Jahr|datum|date|Datum|in\s+\d{4}|since|sinds|depuis|seit|født|fødselsdato|fødsel|Fødselsdato|född|födelsedatum|födelsedag|syntynyt|syntymäaika|fæddur|fæðingardagur|\d{2}\.\d{2}\.|(?:januar|februar|marts|april|maj|juni|juli|august|september|oktober|november|december|januari|februari|mars|april|mei|juin|juillet|août|Tiltr[æa]delsesdato|Tiltredelsesdato))/i;
30
+ // YEAR_PATTERN kept for reference but replaced by RECENT_YEAR in suppressYearAsPostal
27
31
  const ID_LABEL_BEFORE = /(?: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;
28
32
  const SERVICE_NUMBER = /^0800[\-\s]/;
29
- const DATE_PATTERN_FULL = /^\d{2}[-/.]\d{2}[-/.]\d{4}$/;
33
+ const DATE_PATTERN_FULL = /^\d{2}[-/.]\d{2}[-/.]\d{4}$|^\d{4}[-/.]\d{2}[-/.]\d{2}$/;
30
34
  const PASSPORT_CONTEXT_BEFORE = /(?:Reisepass|passport|passeport|paspoort|Bisheriger\s+Reisepass)\s*(?:Nr\.?|Nummer|nummer|number|n[°o])?\s*[:.]?\s*$/i;
31
35
  const SE_ORG_CONTEXT_BEFORE = /(?:org\.?\s*nr\.?|organisationsnummer|organisationsnr|Bolagsverket|företag)\s*[:.]?\s*$/i;
32
36
  const NUMERIC_TYPES = new Set([types_js_1.EntityType.PHONE, types_js_1.EntityType.NATIONAL_ID, types_js_1.EntityType.SSN, types_js_1.EntityType.TAX_ID, types_js_1.EntityType.POSTAL_CODE]);
@@ -66,23 +70,40 @@ function suppressMath(text, match) {
66
70
  if (!NUMERIC_TYPES.has(match.patternDef.entityType))
67
71
  return false;
68
72
  const [before, after] = getContext(text, match.start, match.end);
73
+ // A country-prefixed postal code is an address, not a subtraction:
74
+ // "A-1010 Wien", "B-2000 Antwerpen", "D-10115 Berlin".
75
+ if (match.patternDef.entityType === types_js_1.EntityType.POSTAL_CODE && COUNTRY_PREFIX_HYPHEN.test(before)) {
76
+ return false;
77
+ }
69
78
  return MATH_BEFORE.test(before) || MATH_AFTER.test(after);
70
79
  }
71
80
  function suppressSequential(_text, match) {
72
81
  const clean = match.text.replace(/[\s.\-]/g, "");
73
82
  return SEQUENTIAL_PATTERNS.test(clean);
74
83
  }
84
+ const RECENT_YEAR = /^(?:19[5-9]\d|20[0-3]\d)$/;
85
+ 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;
86
+ 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;
75
87
  function suppressYearAsPostal(text, match) {
76
88
  if (match.patternDef.entityType !== types_js_1.EntityType.POSTAL_CODE)
77
89
  return false;
78
90
  const clean = match.text.trim();
79
- if (!YEAR_PATTERN.test(clean))
91
+ if (!RECENT_YEAR.test(clean))
92
+ return false;
93
+ const [before, after] = getContext(text, match.start, match.end);
94
+ const context = before + after;
95
+ // Keep as postal code if postal/address context nearby
96
+ if (POSTAL_CONTEXT_NEAR.test(context))
80
97
  return false;
98
+ // Keep if preceded by comma+space (address pattern: "Amsterdam, 2026")
81
99
  const immediateBefore = text.slice(Math.max(0, match.start - 3), match.start);
82
- if (immediateBefore.endsWith(", ") || immediateBefore.endsWith(",\n"))
100
+ if (/,\s*$/.test(immediateBefore))
83
101
  return false;
84
- const [before, after] = getContext(text, match.start, match.end);
85
- return DATE_KEYWORD_NEAR.test(before + after);
102
+ // Suppress if date keyword nearby (birth date, employment date, etc.)
103
+ if (DATE_KEYWORD_NEAR.test(context))
104
+ return true;
105
+ // Suppress: recent years without postal context are almost never postal codes
106
+ return true;
86
107
  }
87
108
  function suppressPhoneAfterIdLabel(text, match) {
88
109
  if (match.patternDef.entityType !== types_js_1.EntityType.PHONE)
@@ -100,13 +121,23 @@ function suppressPhoneDateOverlap(_text, match) {
100
121
  return false;
101
122
  return DATE_PATTERN_FULL.test(match.text.trim());
102
123
  }
124
+ // Standards and classification prefixes that are plate-shaped once a letter and
125
+ // digits follow: "ATC-N06", "ICD-O3". None is a German district code, but the
126
+ // guard is still gated on the absence of a plate cue, so a genuine plate that
127
+ // happens to collide is not lost.
128
+ const STANDARDS_PREFIX = new Set(["ICD", "ISO", "DIN", "IEC", "RFC", "DSM", "ATC", "MDR"]);
129
+ const PLATE_CUE_NEAR = /(?:Kennzeichen|Nummernschild|Kfz|Fahrzeug|amtliche[sn]?\s+Kennz|nummerplaat|plaque\s+d'immatriculation|license\s+plate|number\s+plate)/i;
103
130
  const NOT_CITY_CODES = new Set([
104
131
  "ID", "NR", "NO", "ST", "DR", "MR", "MS", "HR", "FR", "IM", "IN", "OR", "IF", "IS", "IT", "AT", "AD", "AG", "AV",
105
132
  "BE", "DE", "EU", "NL", "LU", "WS", "SS", "IP",
106
133
  ]);
134
+ const CURRENCY_PLATE = /^(?:EUR|USD|GBP|CHF|SEK|NOK|DKK|ISK|CZK|PLN|HUF|RON|BGN|HRK)\s/i;
107
135
  function suppressPlateInCompound(text, match) {
108
136
  if (match.patternDef.entityType !== types_js_1.EntityType.LICENSE_PLATE)
109
137
  return false;
138
+ // Suppress currency + number misread as plate (e.g. "EUR 2")
139
+ if (CURRENCY_PLATE.test(match.text))
140
+ return true;
110
141
  if (match.start >= 3) {
111
142
  const before = text.slice(Math.max(0, match.start - 10), match.start);
112
143
  if (/[A-Za-zÄÖÜäöüß]{2,}-$/.test(before))
@@ -114,6 +145,13 @@ function suppressPlateInCompound(text, match) {
114
145
  }
115
146
  const matched = match.text.trim();
116
147
  const parts = matched.split(/[\s\-]+/);
148
+ // A standards or classification reference, not a plate — unless a plate cue
149
+ // nearby says otherwise.
150
+ if (parts.length > 0 && STANDARDS_PREFIX.has(parts[0].toUpperCase())) {
151
+ const [b, a] = getContext(text, match.start, match.end);
152
+ if (!PLATE_CUE_NEAR.test(b + a))
153
+ return true;
154
+ }
117
155
  if (parts.length > 0 && NOT_CITY_CODES.has(parts[0])) {
118
156
  const afterChar = match.end < text.length ? text[match.end] : "";
119
157
  const beforeChar = match.start > 0 ? text[match.start - 1] : "";
@@ -136,6 +174,31 @@ function suppressPlateInCompound(text, match) {
136
174
  }
137
175
  return false;
138
176
  }
177
+ /**
178
+ * Reject a German plate whose district code is not a real one.
179
+ *
180
+ * The district-code set is closed, which makes it a whitelist rather than the
181
+ * open-ended blocklist of standards prefixes: "REF-A12", "SYS-B3", "KTO-A1"
182
+ * and every other document reference of that shape fail it without needing to
183
+ * be enumerated. Applied as a tier — an unknown code still emits when a plate
184
+ * cue is nearby, so a missing code costs recall only in the absence of any
185
+ * other evidence.
186
+ */
187
+ function suppressDePlateUnknownDistrict(text, match) {
188
+ if (match.patternDef.entityType !== types_js_1.EntityType.LICENSE_PLATE)
189
+ return false;
190
+ if (match.countryCode !== "DE")
191
+ return false;
192
+ const parts = match.text.trim().split(/[\s\-]+/);
193
+ if (parts.length === 0 || !parts[0])
194
+ return false;
195
+ if (deDistricts_js_1.DE_DISTRICT_CODES.has(parts[0].toUpperCase()))
196
+ return false;
197
+ const [before, after] = getContext(text, match.start, match.end);
198
+ if (PLATE_CUE_NEAR.test(before + after))
199
+ return false;
200
+ return true;
201
+ }
139
202
  function suppressNatidAsPassport(text, match) {
140
203
  if (match.patternDef.entityType !== types_js_1.EntityType.NATIONAL_ID)
141
204
  return false;
@@ -178,6 +241,218 @@ function suppressPostalAsHouseNumber(text, match) {
178
241
  }
179
242
  return false;
180
243
  }
244
+ // ── BIC: banking-context cues and heading shapes ────────────────────────
245
+ // \bSWIFT\b also covers SWIFT-Code / SWIFT-BIC / Code SWIFT, and \bBIC\b
246
+ // covers BIC-code / BIC/SWIFT.
247
+ const BIC_KEYWORD = /\b(?:BIC|SWIFT)\b/i;
248
+ const BANK_BLOCK = /\b(?:IBAN|Bankverbindung|Bankgegevens|Bankrekening|Rekening(?:nummer)?|Kontonummer|Konto|Kontoinhaber|Compte|Coordonn[ée]es\s+bancaires|Banque|Account\s+(?:number|holder)|Bankleitzahl|BLZ|Betaalgegevens|Zahlungsdaten)\b/i;
249
+ // A line that is nothing but a BIC/SWIFT label, as used in table and column
250
+ // layouts where the code sits on the following line.
251
+ const BIC_LABEL_LINE = /^\s*(?:BIC|SWIFT|SWIFT[-\s]?BIC|BIC\s?\/\s?SWIFT|SWIFT[-\s]?Code|BIC[-\s]?code|Code\s+SWIFT)\s*:?\s*$/i;
252
+ // An IBAN in the same structural unit: CC + 2 check digits + 2 or more groups.
253
+ const IBAN_SHAPE = /\b[A-Z]{2}\d{2}(?:\s?[A-Z0-9]{4}){2,}/;
254
+ // Largest paragraph still treated as one structural unit. Beyond this the unit
255
+ // falls back to the enclosing line, so a run-on document body cannot lend
256
+ // banking context to a token 2,000 characters away.
257
+ const MAX_UNIT_CHARS = 600;
258
+ function enclosingLine(text, start, end) {
259
+ const lineStart = text.lastIndexOf("\n", start - 1) + 1;
260
+ let lineEnd = text.indexOf("\n", end);
261
+ if (lineEnd === -1)
262
+ lineEnd = text.length;
263
+ return [lineStart, lineEnd];
264
+ }
265
+ /**
266
+ * Return the enclosing line / record / paragraph around a match.
267
+ *
268
+ * The context window for BIC is scoped structurally, not by character count.
269
+ * A tight character window measured on the corpus rejects 97.3% of dictionary
270
+ * false positives but wrongly discards ~316 real-looking codes, because the
271
+ * banking cue often sits further away in the same record — e.g. a CSV row
272
+ * carrying the IBAN in one field and the BIC several fields later.
273
+ */
274
+ function structuralUnit(text, start, end) {
275
+ const [lineStart, lineEnd] = enclosingLine(text, start, end);
276
+ let paraStart = lineStart;
277
+ while (paraStart > 0) {
278
+ const prevEnd = paraStart - 1;
279
+ const prevStart = text.lastIndexOf("\n", prevEnd - 1) + 1;
280
+ if (text.slice(prevStart, prevEnd).trim() === "")
281
+ break;
282
+ paraStart = prevStart;
283
+ }
284
+ let paraEnd = lineEnd;
285
+ while (paraEnd < text.length) {
286
+ const nextStart = paraEnd + 1;
287
+ let nextEnd = text.indexOf("\n", nextStart);
288
+ if (nextEnd === -1)
289
+ nextEnd = text.length;
290
+ if (text.slice(nextStart, nextEnd).trim() === "")
291
+ break;
292
+ paraEnd = nextEnd;
293
+ }
294
+ if (paraEnd - paraStart <= MAX_UNIT_CHARS)
295
+ return text.slice(paraStart, paraEnd);
296
+ return text.slice(lineStart, lineEnd);
297
+ }
298
+ function previousNonblankLine(text, lineStart) {
299
+ let pos = lineStart;
300
+ while (pos > 0) {
301
+ const prevEnd = pos - 1;
302
+ const prevStart = text.lastIndexOf("\n", prevEnd - 1) + 1;
303
+ const line = text.slice(prevStart, prevEnd);
304
+ if (line.trim() !== "")
305
+ return line;
306
+ pos = prevStart;
307
+ }
308
+ return "";
309
+ }
310
+ function escapeRe(s) {
311
+ return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
312
+ }
313
+ /**
314
+ * Does `token` also occur as an ordinary lowercase word in this document?
315
+ *
316
+ * `hospital`/`HOSPITAL`, `gegevens`/`GEGEVENS` — a token that appears in
317
+ * ordinary case elsewhere in the same document is a word, not a bank code.
318
+ * Email and domain contexts are excluded, so `ing.nl` does not vouch for a
319
+ * heading. Measured on the corpus this alone identifies ~78% of the BIC false
320
+ * positives with no dictionaries and no labelled data.
321
+ */
322
+ function occursAsLowercaseWord(text, token) {
323
+ if (!/^[A-Za-z]+$/.test(token))
324
+ return false;
325
+ const lower = token.toLowerCase();
326
+ const capitalised = lower[0].toUpperCase() + lower.slice(1);
327
+ for (const form of [lower, capitalised]) {
328
+ // Cheap pre-check: a plain substring test almost always answers "no".
329
+ if (!text.includes(form))
330
+ continue;
331
+ const re = new RegExp(`\\b${escapeRe(form)}\\b`, "g");
332
+ let m;
333
+ while ((m = re.exec(text)) !== null) {
334
+ const before = m.index > 0 ? text[m.index - 1] : "";
335
+ const after = text.slice(m.index + form.length, m.index + form.length + 8);
336
+ if (before === "@" || (before === "." && m.index >= 2 && /[A-Za-z0-9]/.test(text[m.index - 2])))
337
+ continue;
338
+ if (after.startsWith("@") || /^\.[a-zA-Z]{2,6}\b/.test(after))
339
+ continue;
340
+ return true;
341
+ }
342
+ }
343
+ return false;
344
+ }
345
+ /**
346
+ * Is the candidate positioned as a section heading rather than a value?
347
+ * Headings and shouted words are never bank codes. Lines carrying an explicit
348
+ * BIC/SWIFT keyword are exempt from the all-caps rule, so a genuine
349
+ * `BIC: GEBABEBB` line is not mistaken for a heading.
350
+ */
351
+ function isHeadingShape(text, start, end, token) {
352
+ const [lineStart, lineEnd] = enclosingLine(text, start, end);
353
+ const line = text.slice(lineStart, lineEnd);
354
+ // The token is the entire line — unless the line above is a bare BIC/SWIFT
355
+ // label, which makes this a labelled value in a table layout, not a heading.
356
+ if (line.trim() === token) {
357
+ return !BIC_LABEL_LINE.test(previousNonblankLine(text, lineStart));
358
+ }
359
+ // The token starts its line and is immediately followed by a colon
360
+ if (text.slice(lineStart, start).trim() === "" && text.slice(end, lineEnd).trimStart().startsWith(":")) {
361
+ return true;
362
+ }
363
+ // A shouted line: no lowercase, no digits, and no banking keyword
364
+ if (!/[a-z]/.test(line) && !/\d/.test(line) && !BIC_KEYWORD.test(line))
365
+ return true;
366
+ return false;
367
+ }
368
+ /**
369
+ * Emit a BIC only on registry membership or banking context.
370
+ *
371
+ * 0. the token also occurs as an ordinary lowercase word here -> reject;
372
+ * 1. registry hit (deployment-supplied, then bundled seed prefixes) -> emit;
373
+ * 2. heading / shouted-word shape -> reject;
374
+ * 3. BIC/SWIFT keyword, IBAN or bank block in the structural unit -> emit;
375
+ *
376
+ * and a bare shape match reaching the end with none of the above is never
377
+ * emitted. Gate 0 outranks every tier below it — no genuine BIC is also an
378
+ * ordinary lowercase word — so it is applied on both emitting paths. It scans
379
+ * the whole document, so it runs only on the paths that would otherwise emit.
380
+ */
381
+ function suppressBicWithoutEvidence(text, match) {
382
+ if (match.patternDef.entityType !== types_js_1.EntityType.BIC)
383
+ return false;
384
+ const token = match.text.trim();
385
+ if ((0, bicRegistry_js_1.isRegisteredBic)(token))
386
+ return occursAsLowercaseWord(text, token);
387
+ if (isHeadingShape(text, match.start, match.end, token))
388
+ return true;
389
+ // The token itself is blanked out so it cannot vouch for itself.
390
+ const unit = structuralUnit(text, match.start, match.end).split(token).join(" ".repeat(token.length));
391
+ if (BIC_KEYWORD.test(unit) || IBAN_SHAPE.test(unit) || BANK_BLOCK.test(unit)) {
392
+ return occursAsLowercaseWord(text, token);
393
+ }
394
+ return true;
395
+ }
396
+ // ── Postal code: digits belonging to a longer identifier ────────────────
397
+ // An identifier label immediately before the digits. A postal code is never
398
+ // introduced this way; an SVNr, policy number or service number always is.
399
+ const ID_CUE_BEFORE = /(?:[\w\-]*Nr|[\w\-]*N[°ºo]|[\w\-]*Nummer|[\w\-]*Numero|[\w\-]*Numéro|No|number|num|Kennzahl|Aktenzeichen|Az|e-?card|Polizze|Police|Policen)\.?\s*:?\s*$/i;
400
+ // An international dialling prefix earlier on the same line, with nothing but
401
+ // number punctuation in between: these digits belong to the phone detector.
402
+ const DIALLING_PREFIX_BEFORE = /\+\d{1,3}[\d\s\-().]*$/;
403
+ // A country prefix on a postal code — "A-1010 Wien", "B-2000", "L-1234".
404
+ const COUNTRY_PREFIXED = /(?:^|[^A-Za-z0-9])[A-Z]{1,2}$/;
405
+ /**
406
+ * Suppress bare digit runs that belong to a longer number, not an address.
407
+ *
408
+ * A bare 4- or 5-digit run is the weakest shape in the engine, and when it
409
+ * cuts into a longer identifier the damage is worse than a plain false
410
+ * positive: `SV-Nummer: [POSTAL_CODE] 040390` leaves half an Austrian social
411
+ * security number exposed with no way to label the remainder.
412
+ *
413
+ * Applies only to digits-only matches, so structured forms keep their own
414
+ * behaviour — NL `1234 AB`, PT `1234-567`, LU `L-1234`.
415
+ */
416
+ function suppressPostalInLongerIdentifier(text, match) {
417
+ if (match.patternDef.entityType !== types_js_1.EntityType.POSTAL_CODE)
418
+ return false;
419
+ const clean = match.text.trim();
420
+ if (!/^\d+$/.test(clean))
421
+ return false;
422
+ const beforeChar = match.start > 0 ? text[match.start - 1] : "";
423
+ const afterChar = match.end < text.length ? text[match.end] : "";
424
+ const prevPrev = match.start >= 2 ? text[match.start - 2] : "";
425
+ const nextNext = match.end + 1 < text.length ? text[match.end + 1] : "";
426
+ // Directly glued to more digits
427
+ if (/\d/.test(beforeChar) || /\d/.test(afterChar))
428
+ return true;
429
+ // Joined by identifier punctuation: "0456.2398.71-02", "4471/2025".
430
+ // The punctuation only counts when it actually *joins two digit groups* — a
431
+ // trailing period is ordinary sentence punctuation, and treating it as a
432
+ // separator discards every postal code that ends a sentence
433
+ // ("Domicilio: Palma, 13867. Pagos a ...").
434
+ if ("._/".includes(beforeChar) && /\d/.test(prevPrev))
435
+ return true;
436
+ if ("._/".includes(afterChar) && /\d/.test(nextNext))
437
+ return true;
438
+ if (afterChar === "-" && /\d/.test(nextNext))
439
+ return true;
440
+ if (beforeChar === "-" && !COUNTRY_PREFIXED.test(text.slice(0, match.start - 1)))
441
+ return true;
442
+ // A further digit group on the same line: "1268 040390", "1234 5678 925",
443
+ // "+43 664 8213 907". Horizontal whitespace only — a digit on the *next*
444
+ // line is a separate field, not a continuation.
445
+ if (/^[ \t]+\d/.test(text.slice(match.end, match.end + 4)))
446
+ return true;
447
+ // An identifier label introduces the digits: "DiNr. 4471", "Policen-Nr."
448
+ if (ID_CUE_BEFORE.test(text.slice(Math.max(0, match.start - 40), match.start)))
449
+ return true;
450
+ // Digits after an international dialling prefix belong to PHONE
451
+ const [lineStart] = enclosingLine(text, match.start, match.end);
452
+ if (DIALLING_PREFIX_BEFORE.test(text.slice(lineStart, match.start)))
453
+ return true;
454
+ return false;
455
+ }
181
456
  function suppressRequiresContext(text, match) {
182
457
  if (!match.patternDef.requiresContext || match.patternDef.contextKeywords.length === 0)
183
458
  return false;
@@ -190,9 +465,10 @@ const TYPE_SUPPRESSORS = {
190
465
  [types_js_1.EntityType.NATIONAL_ID]: [suppressCurrency, suppressUnits, suppressReference, suppressLegal, suppressMath, suppressNatidAsPassport, suppressSeNatidAsOrg],
191
466
  [types_js_1.EntityType.SSN]: [suppressCurrency, suppressUnits, suppressReference, suppressMath],
192
467
  [types_js_1.EntityType.TAX_ID]: [suppressCurrency, suppressUnits, suppressReference, suppressMath],
193
- [types_js_1.EntityType.POSTAL_CODE]: [suppressCurrency, suppressUnits, suppressMath, suppressLegal, suppressYearAsPostal, suppressPostalInsideIban, suppressPostalAsHouseNumber],
194
- [types_js_1.EntityType.IBAN]: [suppressReference],
195
- [types_js_1.EntityType.LICENSE_PLATE]: [suppressPlateInCompound],
468
+ [types_js_1.EntityType.POSTAL_CODE]: [suppressCurrency, suppressUnits, suppressMath, suppressLegal, suppressYearAsPostal, suppressPostalInsideIban, suppressPostalAsHouseNumber, suppressPostalInLongerIdentifier],
469
+ [types_js_1.EntityType.BIC]: [suppressBicWithoutEvidence],
470
+ [types_js_1.EntityType.BANK_ACCOUNT]: [suppressReference],
471
+ [types_js_1.EntityType.LICENSE_PLATE]: [suppressPlateInCompound, suppressDePlateUnknownDistrict],
196
472
  [types_js_1.EntityType.CHAMBER_OF_COMMERCE]: [suppressReference],
197
473
  };
198
474
  function shouldSuppress(text, match) {
@@ -1,6 +1,6 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.VALIDATORS = void 0;
3
+ exports.VALIDATORS = exports.ISO_3166_ALPHA2 = exports.IBAN_LENGTHS = void 0;
4
4
  exports.validateIban = validateIban;
5
5
  exports.validateBsn = validateBsn;
6
6
  exports.validateBelgianNn = validateBelgianNn;
@@ -14,6 +14,7 @@ exports.validateGermanTaxId = validateGermanTaxId;
14
14
  exports.validateFrenchNir = validateFrenchNir;
15
15
  exports.validateVin = validateVin;
16
16
  exports.validateBic = validateBic;
17
+ exports.validateE164 = validateE164;
17
18
  exports.validateKvk = validateKvk;
18
19
  exports.validateSwedishPnr = validateSwedishPnr;
19
20
  exports.validateNorwegianFnr = validateNorwegianFnr;
@@ -46,21 +47,45 @@ exports.validateGreekAmka = validateGreekAmka;
46
47
  function clean(candidate, chars = /[\s.\-]/g) {
47
48
  return candidate.replace(chars, "");
48
49
  }
50
+ /**
51
+ * Total IBAN length per country (ISO 13616 registry). Also used to build the
52
+ * country-independent IBAN pattern in rules/countries, so an IBAN is detected
53
+ * on its own structure rather than on the caller's `countries` setting.
54
+ */
55
+ exports.IBAN_LENGTHS = {
56
+ AL: 28, AD: 24, AT: 20, AZ: 28, BH: 22, BY: 28, BE: 16, BA: 20, BR: 29, BG: 22, CR: 22, HR: 21,
57
+ CY: 28, CZ: 24, DK: 18, DO: 28, TL: 23, EE: 20, FO: 18, FI: 18, FR: 27, GE: 22, DE: 22, GI: 23,
58
+ GR: 27, GL: 18, GT: 28, HU: 28, IS: 26, IQ: 23, IE: 22, IL: 23, IT: 27, JO: 30, KZ: 20, XK: 20,
59
+ KW: 30, LV: 21, LB: 28, LI: 21, LT: 20, LU: 20, MT: 31, MR: 27, MU: 30, MC: 27, MD: 24, ME: 22,
60
+ NL: 18, MK: 19, NO: 15, PK: 24, PS: 29, PL: 28, PT: 25, QA: 29, RO: 24, LC: 32, SM: 27, SA: 24,
61
+ RS: 22, SC: 31, SK: 24, SI: 19, ES: 24, SE: 24, CH: 21, TN: 24, TR: 26, UA: 29, AE: 23, GB: 22,
62
+ VA: 22, VG: 24,
63
+ };
64
+ /** ISO 3166-1 alpha-2 codes, plus XK (Kosovo, user-assigned but in banking use). */
65
+ exports.ISO_3166_ALPHA2 = new Set([
66
+ "AD", "AE", "AF", "AG", "AI", "AL", "AM", "AO", "AQ", "AR", "AS", "AT", "AU", "AW", "AX", "AZ",
67
+ "BA", "BB", "BD", "BE", "BF", "BG", "BH", "BI", "BJ", "BL", "BM", "BN", "BO", "BQ", "BR", "BS",
68
+ "BT", "BV", "BW", "BY", "BZ", "CA", "CC", "CD", "CF", "CG", "CH", "CI", "CK", "CL", "CM", "CN",
69
+ "CO", "CR", "CU", "CV", "CW", "CX", "CY", "CZ", "DE", "DJ", "DK", "DM", "DO", "DZ", "EC", "EE",
70
+ "EG", "EH", "ER", "ES", "ET", "FI", "FJ", "FK", "FM", "FO", "FR", "GA", "GB", "GD", "GE", "GF",
71
+ "GG", "GH", "GI", "GL", "GM", "GN", "GP", "GQ", "GR", "GS", "GT", "GU", "GW", "GY", "HK", "HM",
72
+ "HN", "HR", "HT", "HU", "ID", "IE", "IL", "IM", "IN", "IO", "IQ", "IR", "IS", "IT", "JE", "JM",
73
+ "JO", "JP", "KE", "KG", "KH", "KI", "KM", "KN", "KP", "KR", "KW", "KY", "KZ", "LA", "LB", "LC",
74
+ "LI", "LK", "LR", "LS", "LT", "LU", "LV", "LY", "MA", "MC", "MD", "ME", "MF", "MG", "MH", "MK",
75
+ "ML", "MM", "MN", "MO", "MP", "MQ", "MR", "MS", "MT", "MU", "MV", "MW", "MX", "MY", "MZ", "NA",
76
+ "NC", "NE", "NF", "NG", "NI", "NL", "NO", "NP", "NR", "NU", "NZ", "OM", "PA", "PE", "PF", "PG",
77
+ "PH", "PK", "PL", "PM", "PN", "PR", "PS", "PT", "PW", "PY", "QA", "RE", "RO", "RS", "RU", "RW",
78
+ "SA", "SB", "SC", "SD", "SE", "SG", "SH", "SI", "SJ", "SK", "SL", "SM", "SN", "SO", "SR", "SS",
79
+ "ST", "SV", "SX", "SY", "SZ", "TC", "TD", "TF", "TG", "TH", "TJ", "TK", "TL", "TM", "TN", "TO",
80
+ "TR", "TT", "TV", "TW", "TZ", "UA", "UG", "UM", "US", "UY", "UZ", "VA", "VC", "VE", "VG", "VI",
81
+ "VN", "VU", "WF", "WS", "XK", "YE", "YT", "ZA", "ZM", "ZW",
82
+ ]);
49
83
  function validateIban(candidate) {
50
84
  const c = candidate.replace(/[\s\-]/g, "").toUpperCase();
51
85
  if (c.length < 5 || !/^[A-Z]{2}/.test(c) || !/^\d{2}$/.test(c.slice(2, 4)))
52
86
  return false;
53
- const ibanLengths = {
54
- AL: 28, AD: 24, AT: 20, AZ: 28, BH: 22, BY: 28, BE: 16, BA: 20, BR: 29, BG: 22, CR: 22, HR: 21,
55
- CY: 28, CZ: 24, DK: 18, DO: 28, TL: 23, EE: 20, FO: 18, FI: 18, FR: 27, GE: 22, DE: 22, GI: 23,
56
- GR: 27, GL: 18, GT: 28, HU: 28, IS: 26, IQ: 23, IE: 22, IL: 23, IT: 27, JO: 30, KZ: 20, XK: 20,
57
- KW: 30, LV: 21, LB: 28, LI: 21, LT: 20, LU: 20, MT: 31, MR: 27, MU: 30, MC: 27, MD: 24, ME: 22,
58
- NL: 18, MK: 19, NO: 15, PK: 24, PS: 29, PL: 28, PT: 25, QA: 29, RO: 24, LC: 32, SM: 27, SA: 24,
59
- RS: 22, SC: 31, SK: 24, SI: 19, ES: 24, SE: 24, CH: 21, TN: 24, TR: 26, UA: 29, AE: 23, GB: 22,
60
- VA: 22, VG: 24,
61
- };
62
87
  const country = c.slice(0, 2);
63
- const expected = ibanLengths[country];
88
+ const expected = exports.IBAN_LENGTHS[country];
64
89
  if (expected !== undefined && c.length !== expected)
65
90
  return false;
66
91
  const rearranged = c.slice(4) + c.slice(0, 4);
@@ -206,13 +231,22 @@ function validateVin(candidate) {
206
231
  return false;
207
232
  return !/[IOQ]/.test(c);
208
233
  }
234
+ /**
235
+ * BIC/SWIFT structural validation (ISO 9362): BBBB CC LL (PPP).
236
+ *
237
+ * Necessary but **not sufficient** — BIC has no check digit, and characters
238
+ * 5-6 of ordinary uppercase words are frequently valid country codes
239
+ * (`DRINGEND` -> `GE`, `HOSPITAL` -> `IT`). Emission additionally requires a
240
+ * registry hit or banking context; see `suppressBicWithoutEvidence`.
241
+ */
209
242
  function validateBic(candidate) {
210
243
  const c = candidate.replace(/ /g, "").toUpperCase();
211
244
  if (c.length !== 8 && c.length !== 11)
212
245
  return false;
213
246
  if (!/^[A-Z]{4}/.test(c))
214
247
  return false;
215
- if (!/^[A-Z]{2}$/.test(c.slice(4, 6)))
248
+ // Country code must be a real ISO 3166-1 alpha-2 code
249
+ if (!exports.ISO_3166_ALPHA2.has(c.slice(4, 6)))
216
250
  return false;
217
251
  if (!/^[A-Z0-9]{2}$/.test(c.slice(6, 8)))
218
252
  return false;
@@ -220,6 +254,21 @@ function validateBic(candidate) {
220
254
  return false;
221
255
  return true;
222
256
  }
257
+ /**
258
+ * International phone number in E.164 form: `+` then 8-15 digits.
259
+ *
260
+ * A `+` prefix makes a phone number self-identifying, so this backs a
261
+ * country-independent pattern: the per-country patterns each hard-code one
262
+ * grouping and miss the others (`+43 664 8213 907` was invisible because the
263
+ * Austrian pattern expects an unbroken subscriber number).
264
+ */
265
+ function validateE164(candidate) {
266
+ if (!candidate.trimStart().startsWith("+"))
267
+ return false;
268
+ // A trunk prefix written as "(0)" is not part of the E.164 digit count.
269
+ const digits = candidate.replace(/\(\s*0\s*\)/g, "").replace(/\D/g, "");
270
+ return digits.length >= 8 && digits.length <= 15;
271
+ }
223
272
  function validateKvk(candidate) {
224
273
  const c = clean(candidate);
225
274
  return c.length === 8 && /^\d{8}$/.test(c);
@@ -666,6 +715,7 @@ exports.VALIDATORS = {
666
715
  french_nir: validateFrenchNir,
667
716
  vin: validateVin,
668
717
  bic: validateBic,
718
+ e164: validateE164,
669
719
  kvk: validateKvk,
670
720
  swedish_pnr: validateSwedishPnr,
671
721
  norwegian_fnr: validateNorwegianFnr,
package/dist/cjs/sdk.js CHANGED
@@ -17,16 +17,36 @@ class ReferentialMapper {
17
17
  }
18
18
  return this.mapping.get(text);
19
19
  }
20
+ clear() {
21
+ this.counters.clear();
22
+ this.mapping.clear();
23
+ }
20
24
  }
25
+ const DEFAULT_MAX_INPUT_LENGTH = 10_485_760; // ~10 MB of text
21
26
  class EuRedact {
22
27
  engine = new engine_js_1.RuleEngine();
23
28
  cache = new cache_js_1.ResultCache();
24
29
  referentialMapper = new ReferentialMapper();
30
+ maxInputLength;
31
+ constructor(options) {
32
+ this.maxInputLength = options?.maxInputLength ?? DEFAULT_MAX_INPUT_LENGTH;
33
+ }
25
34
  addCustomPattern(name, pattern) {
26
35
  this.engine.addCustomPattern(name, pattern);
27
36
  this.cache.clear();
28
37
  }
38
+ /** Clear the result cache and referential integrity mappings.
39
+ * Call this in long-running processes to free PII from memory. */
40
+ clear() {
41
+ this.cache.clear();
42
+ this.referentialMapper.clear();
43
+ }
29
44
  redact(text, options = {}) {
45
+ if (text.length > this.maxInputLength) {
46
+ throw new Error(`Input text length (${text.length.toLocaleString()} chars) exceeds the maximum ` +
47
+ `(${this.maxInputLength.toLocaleString()} chars). Split the input or ` +
48
+ `increase maxInputLength when constructing EuRedact.`);
49
+ }
30
50
  const { countries = null, mode = "rules", referentialIntegrity = false, detectDates = false, cache = true, } = options;
31
51
  const [normalizedText, offsetMapping] = (0, normalizer_js_1.normalize)(text);
32
52
  const countriesTuple = countries
package/dist/cjs/types.js CHANGED
@@ -1,11 +1,19 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.DetectionSource = exports.EntityType = void 0;
3
+ exports.DetectionSource = exports.LEGACY_TYPE_ALIASES = exports.EntityType = void 0;
4
4
  var EntityType;
5
5
  (function (EntityType) {
6
6
  EntityType["NAME"] = "NAME";
7
7
  EntityType["ADDRESS"] = "ADDRESS";
8
- EntityType["IBAN"] = "IBAN";
8
+ /**
9
+ * Canonical name for a bank account identifier. `IBAN` is kept below as a
10
+ * legacy alias with the same value, so `EntityType.IBAN === EntityType.BANK_ACCOUNT`
11
+ * and existing code keeps working — but the emitted value, and therefore the
12
+ * `[BANK_ACCOUNT]` placeholder written into redacted text, is canonical.
13
+ */
14
+ EntityType["BANK_ACCOUNT"] = "BANK_ACCOUNT";
15
+ /** @deprecated legacy alias of {@link EntityType.BANK_ACCOUNT} */
16
+ EntityType["IBAN"] = "BANK_ACCOUNT";
9
17
  EntityType["BIC"] = "BIC";
10
18
  EntityType["CREDIT_CARD"] = "CREDIT_CARD";
11
19
  EntityType["PHONE"] = "PHONE";
@@ -35,6 +43,14 @@ var EntityType;
35
43
  EntityType["SECRET"] = "SECRET";
36
44
  EntityType["OTHER"] = "OTHER";
37
45
  })(EntityType || (exports.EntityType = EntityType = {}));
46
+ /**
47
+ * Legacy type names accepted on input and mapped to their canonical form.
48
+ * Kept in sync with the pipeline canon (config/entity_types.json →
49
+ * legacy_aliases). The engine never *emits* these names.
50
+ */
51
+ exports.LEGACY_TYPE_ALIASES = {
52
+ IBAN: "BANK_ACCOUNT",
53
+ };
38
54
  var DetectionSource;
39
55
  (function (DetectionSource) {
40
56
  DetectionSource["RULES"] = "rules";
package/dist/esm/cache.js CHANGED
@@ -18,10 +18,10 @@ export class ResultCache {
18
18
  key(text, countries, mode) {
19
19
  const sorted = [...countries].sort();
20
20
  const raw = `${text}|${sorted.join("|")}|${mode}`;
21
- // Use multiple FNV-1a hashes on different slices for reduced collisions
21
+ // Include raw length to reduce collision risk alongside dual FNV-1a hashes
22
22
  const h1 = fnv1aHash(raw);
23
23
  const h2 = fnv1aHash(raw.slice(Math.floor(raw.length / 2)) + raw.slice(0, Math.floor(raw.length / 2)));
24
- return h1 + h2;
24
+ return `${raw.length}:${h1}${h2}`;
25
25
  }
26
26
  get(key) {
27
27
  if (!this.enabled)
@@ -1,10 +1,12 @@
1
- export { EntityType, DetectionSource } from "./types.js";
1
+ export { EntityType, DetectionSource, LEGACY_TYPE_ALIASES } from "./types.js";
2
2
  export type { Detection, RedactResult, PatternDef, CountryConfig } from "./types.js";
3
3
  export { EuRedact, type RedactOptions } from "./sdk.js";
4
4
  export { COUNTRY_CONFIGS } from "./rules/countries/index.js";
5
+ export { setBicRegistry, getBicRegistry, SEED_BIC6_PREFIXES, type BicRegistryProvider } from "./rules/bicRegistry.js";
5
6
  import { type RedactOptions } from "./sdk.js";
6
7
  import type { RedactResult } from "./types.js";
7
8
  export declare function availableCountries(): string[];
8
9
  export declare function redact(text: string, options?: RedactOptions): RedactResult;
9
10
  export declare function addCustomPattern(name: string, pattern: string): void;
11
+ export declare function clear(): void;
10
12
  export declare function redactBatch(texts: string[], options?: RedactOptions): RedactResult[];
package/dist/esm/index.js CHANGED
@@ -1,6 +1,7 @@
1
- export { EntityType, DetectionSource } from "./types.js";
1
+ export { EntityType, DetectionSource, LEGACY_TYPE_ALIASES } from "./types.js";
2
2
  export { EuRedact } from "./sdk.js";
3
3
  export { COUNTRY_CONFIGS } from "./rules/countries/index.js";
4
+ export { setBicRegistry, getBicRegistry, SEED_BIC6_PREFIXES } from "./rules/bicRegistry.js";
4
5
  import { EuRedact } from "./sdk.js";
5
6
  import { COUNTRY_CONFIGS } from "./rules/countries/index.js";
6
7
  let _instance = null;
@@ -18,6 +19,9 @@ export function redact(text, options) {
18
19
  export function addCustomPattern(name, pattern) {
19
20
  getInstance().addCustomPattern(name, pattern);
20
21
  }
22
+ export function clear() {
23
+ getInstance().clear();
24
+ }
21
25
  export function redactBatch(texts, options) {
22
26
  return getInstance().redactBatch(texts, options);
23
27
  }