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.
@@ -1,12 +1,62 @@
1
- import { DetectionSource } from "../types.js";
1
+ import { DetectionSource, EntityType } from "../types.js";
2
2
  import { MultiPatternMatcher } from "./matchers.js";
3
3
  import { shouldSuppress } from "./suppressors.js";
4
4
  import { detectStructuralDob } from "./structural.js";
5
5
  import { COUNTRY_CONFIGS } from "./countries/index.js";
6
+ /**
7
+ * Heuristic to detect nested quantifiers that cause catastrophic backtracking.
8
+ * After stripping escaped chars and character classes, look for an unbounded
9
+ * quantifier (+/*) closing a group that is itself quantified (+/*).
10
+ */
11
+ const NESTED_QUANTIFIER_RE = /[+*]\)[+*]/;
12
+ function validateCustomPattern(pattern) {
13
+ try {
14
+ new RegExp(pattern, "gu");
15
+ }
16
+ catch (e) {
17
+ throw new Error(`Invalid regex pattern: ${e instanceof Error ? e.message : e}`);
18
+ }
19
+ const stripped = pattern
20
+ .replace(/\\./g, "X")
21
+ .replace(/\[(?:[^\]\\]|\\.)*\]/g, "X");
22
+ if (NESTED_QUANTIFIER_RE.test(stripped)) {
23
+ throw new Error("Pattern contains nested quantifiers (e.g. (a+)+) which can cause " +
24
+ "catastrophic backtracking (ReDoS). Restructure the pattern to " +
25
+ "avoid quantifiers inside quantified groups.");
26
+ }
27
+ }
28
+ /**
29
+ * The country modules use the EU/VAT convention (UK, EL) while the public
30
+ * contract is ISO 3166-1 alpha-2 (GB, GR). Accept both spellings so a caller
31
+ * passing correct ISO codes is not punished for it.
32
+ */
33
+ export const COUNTRY_CODE_ALIASES = {
34
+ GB: "UK",
35
+ GR: "EL",
36
+ UK: "UK",
37
+ EL: "EL",
38
+ };
39
+ /** Map a caller-supplied code to a registered one, or null if unknown. */
40
+ export function resolveCountryCode(countryCode) {
41
+ let code = countryCode.trim().toUpperCase();
42
+ code = COUNTRY_CODE_ALIASES[code] ?? code;
43
+ return code in COUNTRY_CONFIGS && code !== "SHARED" ? code : null;
44
+ }
45
+ const warnedCountries = new Set();
46
+ function warnUnknownCountry(code) {
47
+ if (warnedCountries.has(code))
48
+ return;
49
+ warnedCountries.add(code);
50
+ const available = Object.keys(COUNTRY_CONFIGS).filter(c => c !== "SHARED").sort();
51
+ console.warn(`[euredact] Unknown country code: ${JSON.stringify(code)}. Continuing with ` +
52
+ `shared country-independent patterns only (email, IBAN, international ` +
53
+ `phone, credit card, ...). Available: ${available.join(", ")}`);
54
+ }
6
55
  export class RuleEngine {
7
56
  matcher = new MultiPatternMatcher();
8
57
  loadedCountries = new Set();
9
58
  addCustomPattern(name, pattern) {
59
+ validateCustomPattern(pattern);
10
60
  const pdef = {
11
61
  entityType: name, pattern, validator: null,
12
62
  description: "", contextKeywords: [], requiresContext: false,
@@ -22,7 +72,15 @@ export class RuleEngine {
22
72
  else {
23
73
  configs = [];
24
74
  for (const c of countryCodes) {
25
- const code = c.toUpperCase();
75
+ const code = resolveCountryCode(c);
76
+ if (code === null) {
77
+ // Detection continues with the shared, country-independent
78
+ // patterns. Throwing on an unknown locale invites callers to wrap
79
+ // the call in try/catch and skip redaction — failing open, with
80
+ // unredacted PII in the output.
81
+ warnUnknownCountry(c);
82
+ continue;
83
+ }
26
84
  const config = COUNTRY_CONFIGS[code];
27
85
  if (config)
28
86
  configs.push(config);
@@ -44,38 +102,87 @@ export class RuleEngine {
44
102
  }
45
103
  detect(text, countryCodes) {
46
104
  this.loadCountries(countryCodes);
47
- let rawMatches = this.matcher.scan(text);
105
+ // Scope the scan to requested countries for efficiency
106
+ // Resolve through the alias table so `countries: ["GB"]` actually scans
107
+ // the patterns registered under "UK".
108
+ let scanCodes = null;
48
109
  if (countryCodes !== null) {
49
- const codesUpper = new Set(countryCodes.map(c => c.toUpperCase()));
50
- rawMatches = rawMatches.filter(m => codesUpper.has(m.countryCode) || m.countryCode === "SHARED" || m.countryCode === "CUSTOM");
110
+ scanCodes = new Set();
111
+ for (const c of countryCodes) {
112
+ const code = resolveCountryCode(c);
113
+ if (code !== null)
114
+ scanCodes.add(code);
115
+ }
116
+ scanCodes.add("SHARED");
117
+ scanCodes.add("CUSTOM");
51
118
  }
119
+ let rawMatches = this.matcher.scan(text, scanCodes);
52
120
  // Matches that have a validator but fail it create suppression zones:
53
121
  // the span is recognisably a specific entity (e.g. IBAN-shaped) so
54
122
  // overlapping regex-only matches (license plate, phone) are false
55
123
  // positives and must be suppressed.
56
124
  const validated = [];
57
- const suppressionZones = [];
125
+ const rawZones = [];
58
126
  for (const m of rawMatches) {
59
- if (this.matcher.validate(m)) {
60
- validated.push(m);
127
+ const isValid = this.matcher.validate(m);
128
+ if (isValid) {
129
+ validated.push({ match: m, hasValidValidator: m.patternDef.validator !== null });
61
130
  }
62
131
  else if (m.patternDef.validator !== null && !m.patternDef.requiresContext) {
63
- suppressionZones.push([m.start, m.end]);
132
+ rawZones.push([m.start, m.end]);
133
+ }
134
+ }
135
+ // Merge overlapping suppression zones for O(log n) containment checks
136
+ let zoneStarts = [];
137
+ let zoneEnds = [];
138
+ if (rawZones.length > 0) {
139
+ rawZones.sort((a, b) => a[0] - b[0] || a[1] - b[1]);
140
+ let [ms, me] = rawZones[0];
141
+ for (let i = 1; i < rawZones.length; i++) {
142
+ const [zs, ze] = rawZones[i];
143
+ if (zs <= me) {
144
+ me = Math.max(me, ze);
145
+ }
146
+ else {
147
+ zoneStarts.push(ms);
148
+ zoneEnds.push(me);
149
+ ms = zs;
150
+ me = ze;
151
+ }
64
152
  }
153
+ zoneStarts.push(ms);
154
+ zoneEnds.push(me);
65
155
  }
66
156
  // Build candidates with priority: validated (3) > custom (2) > regex-only (1)
67
157
  const candidates = [];
68
- for (const match of validated) {
158
+ for (const { match, hasValidValidator } of validated) {
69
159
  if (shouldSuppress(text, match))
70
160
  continue;
71
- if (match.patternDef.validator === null && suppressionZones.length > 0) {
72
- if (suppressionZones.some(([zStart, zEnd]) => match.start >= zStart && match.end <= zEnd)) {
73
- continue;
161
+ // O(log n) binary-search containment check on merged zones
162
+ if (match.patternDef.validator === null && zoneStarts.length > 0) {
163
+ let lo = 0, hi = zoneStarts.length;
164
+ while (lo < hi) {
165
+ const mid = (lo + hi) >>> 1;
166
+ if (zoneStarts[mid] <= match.start)
167
+ lo = mid + 1;
168
+ else
169
+ hi = mid;
74
170
  }
171
+ const idx = lo - 1;
172
+ if (idx >= 0 && zoneEnds[idx] >= match.end)
173
+ continue;
75
174
  }
76
- const hasValidator = match.patternDef.validator !== null;
77
- const isValid = this.matcher.validate(match);
78
- const priority = (hasValidator && isValid) ? 3 : match.countryCode === "CUSTOM" ? 2 : 1;
175
+ // A bare digit run is the weakest evidence in the engine and must never
176
+ // re-cut a span a structured detector claims (PHONE, SSN, NATIONAL_ID,
177
+ // BANK_ACCOUNT, VAT...). Postal codes take unclaimed spans only,
178
+ // whatever the span lengths.
179
+ const priority = hasValidValidator
180
+ ? 3
181
+ : match.countryCode === "CUSTOM"
182
+ ? 2
183
+ : match.patternDef.entityType === EntityType.POSTAL_CODE
184
+ ? 0
185
+ : 1;
79
186
  const spanLength = match.end - match.start;
80
187
  const score = priority * 1_000_000 + spanLength;
81
188
  candidates.push({
@@ -106,16 +213,20 @@ export class RuleEngine {
106
213
  return [];
107
214
  const sorted = [...candidates].sort((a, b) => b.score - a.score);
108
215
  const result = [];
216
+ const occupied = new Set();
109
217
  for (const { det } of sorted) {
110
218
  let overlaps = false;
111
- for (const kept of result) {
112
- if (det.start < kept.end && det.end > kept.start) {
219
+ for (let p = det.start; p < det.end; p++) {
220
+ if (occupied.has(p)) {
113
221
  overlaps = true;
114
222
  break;
115
223
  }
116
224
  }
117
- if (!overlaps)
225
+ if (!overlaps) {
226
+ for (let p = det.start; p < det.end; p++)
227
+ occupied.add(p);
118
228
  result.push(det);
229
+ }
119
230
  }
120
231
  return result;
121
232
  }
@@ -7,6 +7,6 @@ export declare class MultiPatternMatcher {
7
7
  addPattern(patternDef: PatternDef, countryCode: string): void;
8
8
  addCountry(config: CountryConfig): void;
9
9
  compile(): void;
10
- scan(text: string): RawMatch[];
10
+ scan(text: string, countryCodes?: Set<string> | null): RawMatch[];
11
11
  validate(match: RawMatch): boolean;
12
12
  }
@@ -19,11 +19,13 @@ export class MultiPatternMatcher {
19
19
  compile() {
20
20
  this.compiled = true;
21
21
  }
22
- scan(text) {
22
+ scan(text, countryCodes = null) {
23
23
  if (!this.compiled)
24
24
  this.compile();
25
25
  const matches = [];
26
26
  for (const { regex, patternDef, countryCode } of this.patterns) {
27
+ if (countryCodes !== null && !countryCodes.has(countryCode))
28
+ continue;
27
29
  regex.lastIndex = 0;
28
30
  let m;
29
31
  while ((m = regex.exec(text)) !== null) {
@@ -1,4 +1,6 @@
1
1
  import { EntityType } from "../types.js";
2
+ import { isRegisteredBic } from "./bicRegistry.js";
3
+ import { DE_DISTRICT_CODES } from "./deDistricts.js";
2
4
  const CONTEXT_CHARS = 150;
3
5
  function getContext(text, start, end) {
4
6
  const ctxStart = Math.max(0, start - CONTEXT_CHARS);
@@ -18,12 +20,14 @@ const REFERENCE_BEFORE = /(?:dossier|ref\.?|referentie|reference|référence|fac
18
20
  const LEGAL_BEFORE = /(?:Art(?:ikel|icle|\.)|§|Artikel|Section|Sectie|Afdeling|paragraaf|Absatz|alinéa|punt|point|Punkt|lid)\s*$/i;
19
21
  const MATH_BEFORE = /[=+\-×÷*/]\s*$/;
20
22
  const MATH_AFTER = /^\s*[=+\-×÷*/]/;
23
+ // "A-1010", "B-2000", "L-1234", "CH-8000", "D-10115": a country prefix on a
24
+ // postal code, not a minus sign.
25
+ const COUNTRY_PREFIX_HYPHEN = /(?:^|[^A-Za-z0-9])[A-Z]{1,2}-\s*$/;
21
26
  const SEQUENTIAL_PATTERNS = /^(?:0{6,}|1234567890?|0123456789|9876543210?|1111111111?|000000000|123456789)$/;
22
- const YEAR_PATTERN = /^(?:19[4-9]\d|20[0-3]\d)$/;
23
- 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;
27
+ // YEAR_PATTERN kept for reference but replaced by RECENT_YEAR in suppressYearAsPostal
24
28
  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;
25
29
  const SERVICE_NUMBER = /^0800[\-\s]/;
26
- const DATE_PATTERN_FULL = /^\d{2}[-/.]\d{2}[-/.]\d{4}$/;
30
+ const DATE_PATTERN_FULL = /^\d{2}[-/.]\d{2}[-/.]\d{4}$|^\d{4}[-/.]\d{2}[-/.]\d{2}$/;
27
31
  const PASSPORT_CONTEXT_BEFORE = /(?:Reisepass|passport|passeport|paspoort|Bisheriger\s+Reisepass)\s*(?:Nr\.?|Nummer|nummer|number|n[°o])?\s*[:.]?\s*$/i;
28
32
  const SE_ORG_CONTEXT_BEFORE = /(?:org\.?\s*nr\.?|organisationsnummer|organisationsnr|Bolagsverket|företag)\s*[:.]?\s*$/i;
29
33
  const NUMERIC_TYPES = new Set([EntityType.PHONE, EntityType.NATIONAL_ID, EntityType.SSN, EntityType.TAX_ID, EntityType.POSTAL_CODE]);
@@ -63,23 +67,40 @@ function suppressMath(text, match) {
63
67
  if (!NUMERIC_TYPES.has(match.patternDef.entityType))
64
68
  return false;
65
69
  const [before, after] = getContext(text, match.start, match.end);
70
+ // A country-prefixed postal code is an address, not a subtraction:
71
+ // "A-1010 Wien", "B-2000 Antwerpen", "D-10115 Berlin".
72
+ if (match.patternDef.entityType === EntityType.POSTAL_CODE && COUNTRY_PREFIX_HYPHEN.test(before)) {
73
+ return false;
74
+ }
66
75
  return MATH_BEFORE.test(before) || MATH_AFTER.test(after);
67
76
  }
68
77
  function suppressSequential(_text, match) {
69
78
  const clean = match.text.replace(/[\s.\-]/g, "");
70
79
  return SEQUENTIAL_PATTERNS.test(clean);
71
80
  }
81
+ const RECENT_YEAR = /^(?:19[5-9]\d|20[0-3]\d)$/;
82
+ 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;
83
+ 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;
72
84
  function suppressYearAsPostal(text, match) {
73
85
  if (match.patternDef.entityType !== EntityType.POSTAL_CODE)
74
86
  return false;
75
87
  const clean = match.text.trim();
76
- if (!YEAR_PATTERN.test(clean))
88
+ if (!RECENT_YEAR.test(clean))
89
+ return false;
90
+ const [before, after] = getContext(text, match.start, match.end);
91
+ const context = before + after;
92
+ // Keep as postal code if postal/address context nearby
93
+ if (POSTAL_CONTEXT_NEAR.test(context))
77
94
  return false;
95
+ // Keep if preceded by comma+space (address pattern: "Amsterdam, 2026")
78
96
  const immediateBefore = text.slice(Math.max(0, match.start - 3), match.start);
79
- if (immediateBefore.endsWith(", ") || immediateBefore.endsWith(",\n"))
97
+ if (/,\s*$/.test(immediateBefore))
80
98
  return false;
81
- const [before, after] = getContext(text, match.start, match.end);
82
- return DATE_KEYWORD_NEAR.test(before + after);
99
+ // Suppress if date keyword nearby (birth date, employment date, etc.)
100
+ if (DATE_KEYWORD_NEAR.test(context))
101
+ return true;
102
+ // Suppress: recent years without postal context are almost never postal codes
103
+ return true;
83
104
  }
84
105
  function suppressPhoneAfterIdLabel(text, match) {
85
106
  if (match.patternDef.entityType !== EntityType.PHONE)
@@ -97,13 +118,23 @@ function suppressPhoneDateOverlap(_text, match) {
97
118
  return false;
98
119
  return DATE_PATTERN_FULL.test(match.text.trim());
99
120
  }
121
+ // Standards and classification prefixes that are plate-shaped once a letter and
122
+ // digits follow: "ATC-N06", "ICD-O3". None is a German district code, but the
123
+ // guard is still gated on the absence of a plate cue, so a genuine plate that
124
+ // happens to collide is not lost.
125
+ const STANDARDS_PREFIX = new Set(["ICD", "ISO", "DIN", "IEC", "RFC", "DSM", "ATC", "MDR"]);
126
+ const PLATE_CUE_NEAR = /(?:Kennzeichen|Nummernschild|Kfz|Fahrzeug|amtliche[sn]?\s+Kennz|nummerplaat|plaque\s+d'immatriculation|license\s+plate|number\s+plate)/i;
100
127
  const NOT_CITY_CODES = new Set([
101
128
  "ID", "NR", "NO", "ST", "DR", "MR", "MS", "HR", "FR", "IM", "IN", "OR", "IF", "IS", "IT", "AT", "AD", "AG", "AV",
102
129
  "BE", "DE", "EU", "NL", "LU", "WS", "SS", "IP",
103
130
  ]);
131
+ const CURRENCY_PLATE = /^(?:EUR|USD|GBP|CHF|SEK|NOK|DKK|ISK|CZK|PLN|HUF|RON|BGN|HRK)\s/i;
104
132
  function suppressPlateInCompound(text, match) {
105
133
  if (match.patternDef.entityType !== EntityType.LICENSE_PLATE)
106
134
  return false;
135
+ // Suppress currency + number misread as plate (e.g. "EUR 2")
136
+ if (CURRENCY_PLATE.test(match.text))
137
+ return true;
107
138
  if (match.start >= 3) {
108
139
  const before = text.slice(Math.max(0, match.start - 10), match.start);
109
140
  if (/[A-Za-zÄÖÜäöüß]{2,}-$/.test(before))
@@ -111,6 +142,13 @@ function suppressPlateInCompound(text, match) {
111
142
  }
112
143
  const matched = match.text.trim();
113
144
  const parts = matched.split(/[\s\-]+/);
145
+ // A standards or classification reference, not a plate — unless a plate cue
146
+ // nearby says otherwise.
147
+ if (parts.length > 0 && STANDARDS_PREFIX.has(parts[0].toUpperCase())) {
148
+ const [b, a] = getContext(text, match.start, match.end);
149
+ if (!PLATE_CUE_NEAR.test(b + a))
150
+ return true;
151
+ }
114
152
  if (parts.length > 0 && NOT_CITY_CODES.has(parts[0])) {
115
153
  const afterChar = match.end < text.length ? text[match.end] : "";
116
154
  const beforeChar = match.start > 0 ? text[match.start - 1] : "";
@@ -133,6 +171,31 @@ function suppressPlateInCompound(text, match) {
133
171
  }
134
172
  return false;
135
173
  }
174
+ /**
175
+ * Reject a German plate whose district code is not a real one.
176
+ *
177
+ * The district-code set is closed, which makes it a whitelist rather than the
178
+ * open-ended blocklist of standards prefixes: "REF-A12", "SYS-B3", "KTO-A1"
179
+ * and every other document reference of that shape fail it without needing to
180
+ * be enumerated. Applied as a tier — an unknown code still emits when a plate
181
+ * cue is nearby, so a missing code costs recall only in the absence of any
182
+ * other evidence.
183
+ */
184
+ function suppressDePlateUnknownDistrict(text, match) {
185
+ if (match.patternDef.entityType !== EntityType.LICENSE_PLATE)
186
+ return false;
187
+ if (match.countryCode !== "DE")
188
+ return false;
189
+ const parts = match.text.trim().split(/[\s\-]+/);
190
+ if (parts.length === 0 || !parts[0])
191
+ return false;
192
+ if (DE_DISTRICT_CODES.has(parts[0].toUpperCase()))
193
+ return false;
194
+ const [before, after] = getContext(text, match.start, match.end);
195
+ if (PLATE_CUE_NEAR.test(before + after))
196
+ return false;
197
+ return true;
198
+ }
136
199
  function suppressNatidAsPassport(text, match) {
137
200
  if (match.patternDef.entityType !== EntityType.NATIONAL_ID)
138
201
  return false;
@@ -175,6 +238,218 @@ function suppressPostalAsHouseNumber(text, match) {
175
238
  }
176
239
  return false;
177
240
  }
241
+ // ── BIC: banking-context cues and heading shapes ────────────────────────
242
+ // \bSWIFT\b also covers SWIFT-Code / SWIFT-BIC / Code SWIFT, and \bBIC\b
243
+ // covers BIC-code / BIC/SWIFT.
244
+ const BIC_KEYWORD = /\b(?:BIC|SWIFT)\b/i;
245
+ 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;
246
+ // A line that is nothing but a BIC/SWIFT label, as used in table and column
247
+ // layouts where the code sits on the following line.
248
+ 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;
249
+ // An IBAN in the same structural unit: CC + 2 check digits + 2 or more groups.
250
+ const IBAN_SHAPE = /\b[A-Z]{2}\d{2}(?:\s?[A-Z0-9]{4}){2,}/;
251
+ // Largest paragraph still treated as one structural unit. Beyond this the unit
252
+ // falls back to the enclosing line, so a run-on document body cannot lend
253
+ // banking context to a token 2,000 characters away.
254
+ const MAX_UNIT_CHARS = 600;
255
+ function enclosingLine(text, start, end) {
256
+ const lineStart = text.lastIndexOf("\n", start - 1) + 1;
257
+ let lineEnd = text.indexOf("\n", end);
258
+ if (lineEnd === -1)
259
+ lineEnd = text.length;
260
+ return [lineStart, lineEnd];
261
+ }
262
+ /**
263
+ * Return the enclosing line / record / paragraph around a match.
264
+ *
265
+ * The context window for BIC is scoped structurally, not by character count.
266
+ * A tight character window measured on the corpus rejects 97.3% of dictionary
267
+ * false positives but wrongly discards ~316 real-looking codes, because the
268
+ * banking cue often sits further away in the same record — e.g. a CSV row
269
+ * carrying the IBAN in one field and the BIC several fields later.
270
+ */
271
+ function structuralUnit(text, start, end) {
272
+ const [lineStart, lineEnd] = enclosingLine(text, start, end);
273
+ let paraStart = lineStart;
274
+ while (paraStart > 0) {
275
+ const prevEnd = paraStart - 1;
276
+ const prevStart = text.lastIndexOf("\n", prevEnd - 1) + 1;
277
+ if (text.slice(prevStart, prevEnd).trim() === "")
278
+ break;
279
+ paraStart = prevStart;
280
+ }
281
+ let paraEnd = lineEnd;
282
+ while (paraEnd < text.length) {
283
+ const nextStart = paraEnd + 1;
284
+ let nextEnd = text.indexOf("\n", nextStart);
285
+ if (nextEnd === -1)
286
+ nextEnd = text.length;
287
+ if (text.slice(nextStart, nextEnd).trim() === "")
288
+ break;
289
+ paraEnd = nextEnd;
290
+ }
291
+ if (paraEnd - paraStart <= MAX_UNIT_CHARS)
292
+ return text.slice(paraStart, paraEnd);
293
+ return text.slice(lineStart, lineEnd);
294
+ }
295
+ function previousNonblankLine(text, lineStart) {
296
+ let pos = lineStart;
297
+ while (pos > 0) {
298
+ const prevEnd = pos - 1;
299
+ const prevStart = text.lastIndexOf("\n", prevEnd - 1) + 1;
300
+ const line = text.slice(prevStart, prevEnd);
301
+ if (line.trim() !== "")
302
+ return line;
303
+ pos = prevStart;
304
+ }
305
+ return "";
306
+ }
307
+ function escapeRe(s) {
308
+ return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
309
+ }
310
+ /**
311
+ * Does `token` also occur as an ordinary lowercase word in this document?
312
+ *
313
+ * `hospital`/`HOSPITAL`, `gegevens`/`GEGEVENS` — a token that appears in
314
+ * ordinary case elsewhere in the same document is a word, not a bank code.
315
+ * Email and domain contexts are excluded, so `ing.nl` does not vouch for a
316
+ * heading. Measured on the corpus this alone identifies ~78% of the BIC false
317
+ * positives with no dictionaries and no labelled data.
318
+ */
319
+ function occursAsLowercaseWord(text, token) {
320
+ if (!/^[A-Za-z]+$/.test(token))
321
+ return false;
322
+ const lower = token.toLowerCase();
323
+ const capitalised = lower[0].toUpperCase() + lower.slice(1);
324
+ for (const form of [lower, capitalised]) {
325
+ // Cheap pre-check: a plain substring test almost always answers "no".
326
+ if (!text.includes(form))
327
+ continue;
328
+ const re = new RegExp(`\\b${escapeRe(form)}\\b`, "g");
329
+ let m;
330
+ while ((m = re.exec(text)) !== null) {
331
+ const before = m.index > 0 ? text[m.index - 1] : "";
332
+ const after = text.slice(m.index + form.length, m.index + form.length + 8);
333
+ if (before === "@" || (before === "." && m.index >= 2 && /[A-Za-z0-9]/.test(text[m.index - 2])))
334
+ continue;
335
+ if (after.startsWith("@") || /^\.[a-zA-Z]{2,6}\b/.test(after))
336
+ continue;
337
+ return true;
338
+ }
339
+ }
340
+ return false;
341
+ }
342
+ /**
343
+ * Is the candidate positioned as a section heading rather than a value?
344
+ * Headings and shouted words are never bank codes. Lines carrying an explicit
345
+ * BIC/SWIFT keyword are exempt from the all-caps rule, so a genuine
346
+ * `BIC: GEBABEBB` line is not mistaken for a heading.
347
+ */
348
+ function isHeadingShape(text, start, end, token) {
349
+ const [lineStart, lineEnd] = enclosingLine(text, start, end);
350
+ const line = text.slice(lineStart, lineEnd);
351
+ // The token is the entire line — unless the line above is a bare BIC/SWIFT
352
+ // label, which makes this a labelled value in a table layout, not a heading.
353
+ if (line.trim() === token) {
354
+ return !BIC_LABEL_LINE.test(previousNonblankLine(text, lineStart));
355
+ }
356
+ // The token starts its line and is immediately followed by a colon
357
+ if (text.slice(lineStart, start).trim() === "" && text.slice(end, lineEnd).trimStart().startsWith(":")) {
358
+ return true;
359
+ }
360
+ // A shouted line: no lowercase, no digits, and no banking keyword
361
+ if (!/[a-z]/.test(line) && !/\d/.test(line) && !BIC_KEYWORD.test(line))
362
+ return true;
363
+ return false;
364
+ }
365
+ /**
366
+ * Emit a BIC only on registry membership or banking context.
367
+ *
368
+ * 0. the token also occurs as an ordinary lowercase word here -> reject;
369
+ * 1. registry hit (deployment-supplied, then bundled seed prefixes) -> emit;
370
+ * 2. heading / shouted-word shape -> reject;
371
+ * 3. BIC/SWIFT keyword, IBAN or bank block in the structural unit -> emit;
372
+ *
373
+ * and a bare shape match reaching the end with none of the above is never
374
+ * emitted. Gate 0 outranks every tier below it — no genuine BIC is also an
375
+ * ordinary lowercase word — so it is applied on both emitting paths. It scans
376
+ * the whole document, so it runs only on the paths that would otherwise emit.
377
+ */
378
+ function suppressBicWithoutEvidence(text, match) {
379
+ if (match.patternDef.entityType !== EntityType.BIC)
380
+ return false;
381
+ const token = match.text.trim();
382
+ if (isRegisteredBic(token))
383
+ return occursAsLowercaseWord(text, token);
384
+ if (isHeadingShape(text, match.start, match.end, token))
385
+ return true;
386
+ // The token itself is blanked out so it cannot vouch for itself.
387
+ const unit = structuralUnit(text, match.start, match.end).split(token).join(" ".repeat(token.length));
388
+ if (BIC_KEYWORD.test(unit) || IBAN_SHAPE.test(unit) || BANK_BLOCK.test(unit)) {
389
+ return occursAsLowercaseWord(text, token);
390
+ }
391
+ return true;
392
+ }
393
+ // ── Postal code: digits belonging to a longer identifier ────────────────
394
+ // An identifier label immediately before the digits. A postal code is never
395
+ // introduced this way; an SVNr, policy number or service number always is.
396
+ 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;
397
+ // An international dialling prefix earlier on the same line, with nothing but
398
+ // number punctuation in between: these digits belong to the phone detector.
399
+ const DIALLING_PREFIX_BEFORE = /\+\d{1,3}[\d\s\-().]*$/;
400
+ // A country prefix on a postal code — "A-1010 Wien", "B-2000", "L-1234".
401
+ const COUNTRY_PREFIXED = /(?:^|[^A-Za-z0-9])[A-Z]{1,2}$/;
402
+ /**
403
+ * Suppress bare digit runs that belong to a longer number, not an address.
404
+ *
405
+ * A bare 4- or 5-digit run is the weakest shape in the engine, and when it
406
+ * cuts into a longer identifier the damage is worse than a plain false
407
+ * positive: `SV-Nummer: [POSTAL_CODE] 040390` leaves half an Austrian social
408
+ * security number exposed with no way to label the remainder.
409
+ *
410
+ * Applies only to digits-only matches, so structured forms keep their own
411
+ * behaviour — NL `1234 AB`, PT `1234-567`, LU `L-1234`.
412
+ */
413
+ function suppressPostalInLongerIdentifier(text, match) {
414
+ if (match.patternDef.entityType !== EntityType.POSTAL_CODE)
415
+ return false;
416
+ const clean = match.text.trim();
417
+ if (!/^\d+$/.test(clean))
418
+ return false;
419
+ const beforeChar = match.start > 0 ? text[match.start - 1] : "";
420
+ const afterChar = match.end < text.length ? text[match.end] : "";
421
+ const prevPrev = match.start >= 2 ? text[match.start - 2] : "";
422
+ const nextNext = match.end + 1 < text.length ? text[match.end + 1] : "";
423
+ // Directly glued to more digits
424
+ if (/\d/.test(beforeChar) || /\d/.test(afterChar))
425
+ return true;
426
+ // Joined by identifier punctuation: "0456.2398.71-02", "4471/2025".
427
+ // The punctuation only counts when it actually *joins two digit groups* — a
428
+ // trailing period is ordinary sentence punctuation, and treating it as a
429
+ // separator discards every postal code that ends a sentence
430
+ // ("Domicilio: Palma, 13867. Pagos a ...").
431
+ if ("._/".includes(beforeChar) && /\d/.test(prevPrev))
432
+ return true;
433
+ if ("._/".includes(afterChar) && /\d/.test(nextNext))
434
+ return true;
435
+ if (afterChar === "-" && /\d/.test(nextNext))
436
+ return true;
437
+ if (beforeChar === "-" && !COUNTRY_PREFIXED.test(text.slice(0, match.start - 1)))
438
+ return true;
439
+ // A further digit group on the same line: "1268 040390", "1234 5678 925",
440
+ // "+43 664 8213 907". Horizontal whitespace only — a digit on the *next*
441
+ // line is a separate field, not a continuation.
442
+ if (/^[ \t]+\d/.test(text.slice(match.end, match.end + 4)))
443
+ return true;
444
+ // An identifier label introduces the digits: "DiNr. 4471", "Policen-Nr."
445
+ if (ID_CUE_BEFORE.test(text.slice(Math.max(0, match.start - 40), match.start)))
446
+ return true;
447
+ // Digits after an international dialling prefix belong to PHONE
448
+ const [lineStart] = enclosingLine(text, match.start, match.end);
449
+ if (DIALLING_PREFIX_BEFORE.test(text.slice(lineStart, match.start)))
450
+ return true;
451
+ return false;
452
+ }
178
453
  function suppressRequiresContext(text, match) {
179
454
  if (!match.patternDef.requiresContext || match.patternDef.contextKeywords.length === 0)
180
455
  return false;
@@ -187,9 +462,10 @@ const TYPE_SUPPRESSORS = {
187
462
  [EntityType.NATIONAL_ID]: [suppressCurrency, suppressUnits, suppressReference, suppressLegal, suppressMath, suppressNatidAsPassport, suppressSeNatidAsOrg],
188
463
  [EntityType.SSN]: [suppressCurrency, suppressUnits, suppressReference, suppressMath],
189
464
  [EntityType.TAX_ID]: [suppressCurrency, suppressUnits, suppressReference, suppressMath],
190
- [EntityType.POSTAL_CODE]: [suppressCurrency, suppressUnits, suppressMath, suppressLegal, suppressYearAsPostal, suppressPostalInsideIban, suppressPostalAsHouseNumber],
191
- [EntityType.IBAN]: [suppressReference],
192
- [EntityType.LICENSE_PLATE]: [suppressPlateInCompound],
465
+ [EntityType.POSTAL_CODE]: [suppressCurrency, suppressUnits, suppressMath, suppressLegal, suppressYearAsPostal, suppressPostalInsideIban, suppressPostalAsHouseNumber, suppressPostalInLongerIdentifier],
466
+ [EntityType.BIC]: [suppressBicWithoutEvidence],
467
+ [EntityType.BANK_ACCOUNT]: [suppressReference],
468
+ [EntityType.LICENSE_PLATE]: [suppressPlateInCompound, suppressDePlateUnknownDistrict],
193
469
  [EntityType.CHAMBER_OF_COMMERCE]: [suppressReference],
194
470
  };
195
471
  export function shouldSuppress(text, match) {
@@ -1,3 +1,11 @@
1
+ /**
2
+ * Total IBAN length per country (ISO 13616 registry). Also used to build the
3
+ * country-independent IBAN pattern in rules/countries, so an IBAN is detected
4
+ * on its own structure rather than on the caller's `countries` setting.
5
+ */
6
+ export declare const IBAN_LENGTHS: Record<string, number>;
7
+ /** ISO 3166-1 alpha-2 codes, plus XK (Kosovo, user-assigned but in banking use). */
8
+ export declare const ISO_3166_ALPHA2: ReadonlySet<string>;
1
9
  export declare function validateIban(candidate: string): boolean;
2
10
  export declare function validateBsn(candidate: string): boolean;
3
11
  export declare function validateBelgianNn(candidate: string): boolean;
@@ -10,7 +18,24 @@ export declare function validateVatLu(candidate: string): boolean;
10
18
  export declare function validateGermanTaxId(candidate: string): boolean;
11
19
  export declare function validateFrenchNir(candidate: string): boolean;
12
20
  export declare function validateVin(candidate: string): boolean;
21
+ /**
22
+ * BIC/SWIFT structural validation (ISO 9362): BBBB CC LL (PPP).
23
+ *
24
+ * Necessary but **not sufficient** — BIC has no check digit, and characters
25
+ * 5-6 of ordinary uppercase words are frequently valid country codes
26
+ * (`DRINGEND` -> `GE`, `HOSPITAL` -> `IT`). Emission additionally requires a
27
+ * registry hit or banking context; see `suppressBicWithoutEvidence`.
28
+ */
13
29
  export declare function validateBic(candidate: string): boolean;
30
+ /**
31
+ * International phone number in E.164 form: `+` then 8-15 digits.
32
+ *
33
+ * A `+` prefix makes a phone number self-identifying, so this backs a
34
+ * country-independent pattern: the per-country patterns each hard-code one
35
+ * grouping and miss the others (`+43 664 8213 907` was invisible because the
36
+ * Austrian pattern expects an unbroken subscriber number).
37
+ */
38
+ export declare function validateE164(candidate: string): boolean;
14
39
  export declare function validateKvk(candidate: string): boolean;
15
40
  export declare function validateSwedishPnr(candidate: string): boolean;
16
41
  export declare function validateNorwegianFnr(candidate: string): boolean;