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.
@@ -0,0 +1,92 @@
1
+ "use strict";
2
+ /**
3
+ * German vehicle registration district codes (Unterscheidungszeichen).
4
+ *
5
+ * The set is closed: codes are fixed by the Fahrzeug-Zulassungsverordnung and
6
+ * maintained by the Kraftfahrt-Bundesamt, changing only when a district is
7
+ * created, merged, or a historic code is reinstated. That makes membership a
8
+ * usable precision signal — unlike the open-ended blocklist of standards
9
+ * prefixes it replaces, which only ever catches acronyms someone thought of.
10
+ *
11
+ * Used as a **tier**, not a filter: code on this list -> emit; code absent but
12
+ * a plate cue nearby -> emit; neither -> reject. So a missing code costs
13
+ * recall only where there is also no cue. Some entries are also standards
14
+ * prefixes — DIN is Dinslaken as well as the standards body — which is why the
15
+ * standards guard is kept alongside this one.
16
+ */
17
+ Object.defineProperty(exports, "__esModule", { value: true });
18
+ exports.DE_DISTRICT_CODES = void 0;
19
+ exports.DE_DISTRICT_CODES = new Set([
20
+ "A", "AA", "AB", "ABG", "AC", "AE", "AH", "AIB", "AIC", "AK", "AL", "ALF",
21
+ "ALS", "ALZ", "AM", "AN", "ANA", "ANG", "ANK", "AP", "APD", "AR", "ARN",
22
+ "ART", "AS", "ASD", "ASL", "ASZ", "AT", "AU", "AUR", "AW", "AZ", "AZE",
23
+ "AÖ", "B", "BA", "BAD", "BAR", "BB", "BBG", "BBL", "BC", "BCH", "BD",
24
+ "BE", "BED", "BEI", "BEL", "BER", "BF", "BG", "BGD", "BGL", "BH", "BI",
25
+ "BID", "BIN", "BIR", "BIT", "BIW", "BK", "BKS", "BL", "BLB", "BLK", "BM",
26
+ "BN", "BNA", "BO", "BOG", "BOH", "BOR", "BOT", "BP", "BR", "BRA", "BRB",
27
+ "BRG", "BRI", "BRK", "BRL", "BRV", "BS", "BSB", "BSK", "BT", "BTF", "BU",
28
+ "BUL", "BUR", "BW", "BWL", "BYL", "BZ", "BZA", "BÖ", "BÜD", "BÜS", "BÜZ",
29
+ "C", "CA", "CAS", "CB", "CE", "CHA", "CHL", "CLP", "CLZ", "CO", "COC",
30
+ "COE", "CR", "CT", "CUX", "CW", "D", "DA", "DAH", "DAN", "DAU", "DB",
31
+ "DBR", "DD", "DE", "DEG", "DEL", "DG", "DGF", "DH", "DI", "DIL", "DIN",
32
+ "DIZ", "DKB", "DL", "DLG", "DM", "DN", "DO", "DON", "DS", "DT", "DU",
33
+ "DUD", "DW", "DZ", "DÜW", "E", "EA", "EB", "EBE", "EBN", "EBS", "ECK",
34
+ "ED", "EE", "EF", "EG", "EH", "EHI", "EI", "EIC", "EIH", "EIL", "EIN",
35
+ "EIS", "EL", "EM", "EMD", "EMS", "EN", "ER", "ERB", "ERH", "ERK", "ES",
36
+ "ESA", "ESB", "ESW", "EU", "EUT", "EW", "F", "FAL", "FB", "FD", "FDB",
37
+ "FDS", "FEU", "FF", "FFB", "FG", "FH", "FI", "FKB", "FL", "FLÖ", "FN",
38
+ "FO", "FOR", "FR", "FRG", "FRI", "FRW", "FS", "FT", "FTL", "FW", "FZ",
39
+ "FÜ", "FÜS", "G", "GA", "GAN", "GAP", "GC", "GD", "GDB", "GE", "GEL",
40
+ "GEM", "GEO", "GER", "GF", "GG", "GHA", "GHC", "GI", "GK", "GL", "GLA",
41
+ "GM", "GMN", "GN", "GNT", "GOA", "GOH", "GP", "GR", "GRA", "GRH", "GRI",
42
+ "GRM", "GRS", "GRZ", "GS", "GT", "GTH", "GUB", "GUN", "GV", "GVM", "GW",
43
+ "GZ", "GÖ", "GÜ", "H", "HA", "HAB", "HAL", "HAM", "HAS", "HB", "HBN",
44
+ "HBS", "HC", "HCH", "HD", "HDH", "HDL", "HE", "HEB", "HEF", "HEI", "HEL",
45
+ "HER", "HET", "HF", "HG", "HGN", "HGW", "HH", "HHM", "HI", "HIG", "HIP",
46
+ "HK", "HL", "HM", "HMU", "HN", "HO", "HOG", "HOH", "HOL", "HOM", "HOR",
47
+ "HOT", "HP", "HR", "HRO", "HS", "HSK", "HST", "HU", "HUS", "HV", "HVL",
48
+ "HW", "HWI", "HX", "HY", "HZ", "HÖS", "HÜN", "IGB", "IK", "IL", "ILL",
49
+ "IN", "IS", "IZ", "J", "JB", "JE", "JEV", "JL", "JÜL", "K", "KA", "KAR",
50
+ "KB", "KC", "KE", "KEH", "KEL", "KEM", "KF", "KG", "KH", "KI", "KIB",
51
+ "KK", "KL", "KLE", "KLT", "KLZ", "KM", "KN", "KO", "KOZ", "KR", "KRU",
52
+ "KS", "KT", "KU", "KUS", "KW", "KY", "KYF", "KÖN", "KÖT", "KÜN", "L",
53
+ "LA", "LAN", "LAT", "LAU", "LB", "LBS", "LBZ", "LC", "LD", "LDK", "LDS",
54
+ "LE", "LEO", "LER", "LEV", "LF", "LG", "LH", "LI", "LIB", "LIF", "LIN",
55
+ "LIP", "LK", "LL", "LM", "LN", "LOH", "LOS", "LP", "LR", "LS", "LSA",
56
+ "LSN", "LSZ", "LU", "LUK", "LWL", "LÖ", "LÖB", "LÜD", "LÜN", "M", "MA",
57
+ "MAB", "MAI", "MAK", "MAL", "MAR", "MB", "MC", "MD", "ME", "MED", "MEG",
58
+ "MEI", "MEK", "MEL", "MEP", "MER", "MES", "MET", "MG", "MGH", "MGN", "MH",
59
+ "MHL", "MI", "MIL", "MK", "MKK", "ML", "MM", "MN", "MO", "MOD", "MOL",
60
+ "MON", "MOS", "MQ", "MR", "MS", "MSP", "MST", "MT", "MTK", "MTL", "MVL",
61
+ "MW", "MY", "MYK", "MZ", "MZG", "MÜ", "MÜB", "MÜL", "MÜN", "MÜR", "N",
62
+ "NAB", "NAI", "NAU", "NB", "ND", "NDH", "NE", "NEA", "NEB", "NEC", "NEN",
63
+ "NES", "NEU", "NEW", "NF", "NH", "NI", "NIB", "NK", "NL", "NM", "NMB",
64
+ "NMS", "NOH", "NOL", "NOM", "NOR", "NP", "NR", "NRW", "NRÜ", "NT", "NU",
65
+ "NVP", "NW", "NWM", "NY", "NZ", "NÖ", "OA", "OAL", "OB", "OBB", "OBG",
66
+ "OC", "OCH", "OD", "OE", "OF", "OG", "OH", "OHA", "OHV", "OHZ", "OK",
67
+ "OL", "OLD", "OP", "OPR", "OR", "OS", "OSL", "OTT", "OTW", "OVI", "OVL",
68
+ "OVP", "OZ", "P", "PA", "PAF", "PAN", "PAR", "PB", "PCH", "PE", "PEG",
69
+ "PER", "PF", "PI", "PIR", "PK", "PL", "PLÖ", "PM", "PN", "PR", "PRÜ",
70
+ "PS", "PW", "PZ", "QFT", "QLB", "R", "RA", "RC", "RD", "RDG", "RE", "REG",
71
+ "REH", "REI", "RG", "RH", "RI", "RID", "RIE", "RL", "RM", "RN", "RO",
72
+ "ROD", "ROF", "ROH", "ROK", "ROL", "ROS", "ROT", "ROW", "RPL", "RS",
73
+ "RSL", "RT", "RU", "RV", "RW", "RWL", "RY", "RZ", "RÜD", "RÜG", "S", "SA",
74
+ "SAB", "SAD", "SAL", "SAN", "SAO", "SAW", "SB", "SBG", "SBK", "SC", "SCZ",
75
+ "SDH", "SDL", "SDT", "SE", "SEB", "SEE", "SEF", "SEL", "SF", "SFA", "SFB",
76
+ "SFT", "SG", "SGH", "SH", "SHA", "SHG", "SHK", "SHL", "SHS", "SI", "SIG",
77
+ "SIM", "SK", "SL", "SLE", "SLF", "SLG", "SLK", "SLN", "SLS", "SLZ", "SLÜ",
78
+ "SM", "SMÜ", "SN", "SNH", "SO", "SOB", "SOG", "SOK", "SOL", "SON", "SP",
79
+ "SPB", "SPN", "SPR", "SR", "SRB", "SRO", "ST", "STA", "STB", "STD", "STE",
80
+ "STH", "STL", "STO", "SU", "SUL", "SW", "SWA", "SWZ", "SY", "SZ", "SZB",
81
+ "SÄK", "SÖM", "SÜW", "TBB", "TE", "TF", "TG", "TGO", "THL", "THW", "TIR",
82
+ "TO", "TP", "TR", "TS", "TT", "TUT", "TÖL", "TÖN", "TÜ", "UE", "UEM",
83
+ "UER", "UFF", "UH", "UL", "UM", "UN", "USI", "V", "VAI", "VB", "VEC",
84
+ "VER", "VIB", "VIE", "VIT", "VK", "VL", "VOF", "VOH", "VR", "VS", "W",
85
+ "WA", "WAF", "WAK", "WAM", "WAN", "WAR", "WAT", "WB", "WBS", "WD", "WDA",
86
+ "WE", "WEB", "WEG", "WEI", "WEL", "WEM", "WEN", "WER", "WES", "WF", "WG",
87
+ "WHV", "WI", "WIL", "WIS", "WIT", "WIZ", "WK", "WL", "WLG", "WM", "WMS",
88
+ "WN", "WND", "WO", "WOB", "WOH", "WOL", "WOR", "WOS", "WR", "WRN", "WS",
89
+ "WSF", "WST", "WSW", "WT", "WTL", "WTM", "WUG", "WUM", "WUN", "WUR", "WW",
90
+ "WZ", "WZL", "WÜ", "X", "Y", "Z", "ZE", "ZEL", "ZI", "ZIG", "ZP", "ZR",
91
+ "ZS", "ZW", "ZZ", "ÖHR", "ÜB",
92
+ ]);
@@ -1,15 +1,66 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.RuleEngine = void 0;
3
+ exports.RuleEngine = exports.COUNTRY_CODE_ALIASES = void 0;
4
+ exports.resolveCountryCode = resolveCountryCode;
4
5
  const types_js_1 = require("../types.js");
5
6
  const matchers_js_1 = require("./matchers.js");
6
7
  const suppressors_js_1 = require("./suppressors.js");
7
8
  const structural_js_1 = require("./structural.js");
8
9
  const index_js_1 = require("./countries/index.js");
10
+ /**
11
+ * Heuristic to detect nested quantifiers that cause catastrophic backtracking.
12
+ * After stripping escaped chars and character classes, look for an unbounded
13
+ * quantifier (+/*) closing a group that is itself quantified (+/*).
14
+ */
15
+ const NESTED_QUANTIFIER_RE = /[+*]\)[+*]/;
16
+ function validateCustomPattern(pattern) {
17
+ try {
18
+ new RegExp(pattern, "gu");
19
+ }
20
+ catch (e) {
21
+ throw new Error(`Invalid regex pattern: ${e instanceof Error ? e.message : e}`);
22
+ }
23
+ const stripped = pattern
24
+ .replace(/\\./g, "X")
25
+ .replace(/\[(?:[^\]\\]|\\.)*\]/g, "X");
26
+ if (NESTED_QUANTIFIER_RE.test(stripped)) {
27
+ throw new Error("Pattern contains nested quantifiers (e.g. (a+)+) which can cause " +
28
+ "catastrophic backtracking (ReDoS). Restructure the pattern to " +
29
+ "avoid quantifiers inside quantified groups.");
30
+ }
31
+ }
32
+ /**
33
+ * The country modules use the EU/VAT convention (UK, EL) while the public
34
+ * contract is ISO 3166-1 alpha-2 (GB, GR). Accept both spellings so a caller
35
+ * passing correct ISO codes is not punished for it.
36
+ */
37
+ exports.COUNTRY_CODE_ALIASES = {
38
+ GB: "UK",
39
+ GR: "EL",
40
+ UK: "UK",
41
+ EL: "EL",
42
+ };
43
+ /** Map a caller-supplied code to a registered one, or null if unknown. */
44
+ function resolveCountryCode(countryCode) {
45
+ let code = countryCode.trim().toUpperCase();
46
+ code = exports.COUNTRY_CODE_ALIASES[code] ?? code;
47
+ return code in index_js_1.COUNTRY_CONFIGS && code !== "SHARED" ? code : null;
48
+ }
49
+ const warnedCountries = new Set();
50
+ function warnUnknownCountry(code) {
51
+ if (warnedCountries.has(code))
52
+ return;
53
+ warnedCountries.add(code);
54
+ const available = Object.keys(index_js_1.COUNTRY_CONFIGS).filter(c => c !== "SHARED").sort();
55
+ console.warn(`[euredact] Unknown country code: ${JSON.stringify(code)}. Continuing with ` +
56
+ `shared country-independent patterns only (email, IBAN, international ` +
57
+ `phone, credit card, ...). Available: ${available.join(", ")}`);
58
+ }
9
59
  class RuleEngine {
10
60
  matcher = new matchers_js_1.MultiPatternMatcher();
11
61
  loadedCountries = new Set();
12
62
  addCustomPattern(name, pattern) {
63
+ validateCustomPattern(pattern);
13
64
  const pdef = {
14
65
  entityType: name, pattern, validator: null,
15
66
  description: "", contextKeywords: [], requiresContext: false,
@@ -25,7 +76,15 @@ class RuleEngine {
25
76
  else {
26
77
  configs = [];
27
78
  for (const c of countryCodes) {
28
- const code = c.toUpperCase();
79
+ const code = resolveCountryCode(c);
80
+ if (code === null) {
81
+ // Detection continues with the shared, country-independent
82
+ // patterns. Throwing on an unknown locale invites callers to wrap
83
+ // the call in try/catch and skip redaction — failing open, with
84
+ // unredacted PII in the output.
85
+ warnUnknownCountry(c);
86
+ continue;
87
+ }
29
88
  const config = index_js_1.COUNTRY_CONFIGS[code];
30
89
  if (config)
31
90
  configs.push(config);
@@ -47,38 +106,87 @@ class RuleEngine {
47
106
  }
48
107
  detect(text, countryCodes) {
49
108
  this.loadCountries(countryCodes);
50
- let rawMatches = this.matcher.scan(text);
109
+ // Scope the scan to requested countries for efficiency
110
+ // Resolve through the alias table so `countries: ["GB"]` actually scans
111
+ // the patterns registered under "UK".
112
+ let scanCodes = null;
51
113
  if (countryCodes !== null) {
52
- const codesUpper = new Set(countryCodes.map(c => c.toUpperCase()));
53
- rawMatches = rawMatches.filter(m => codesUpper.has(m.countryCode) || m.countryCode === "SHARED" || m.countryCode === "CUSTOM");
114
+ scanCodes = new Set();
115
+ for (const c of countryCodes) {
116
+ const code = resolveCountryCode(c);
117
+ if (code !== null)
118
+ scanCodes.add(code);
119
+ }
120
+ scanCodes.add("SHARED");
121
+ scanCodes.add("CUSTOM");
54
122
  }
123
+ let rawMatches = this.matcher.scan(text, scanCodes);
55
124
  // Matches that have a validator but fail it create suppression zones:
56
125
  // the span is recognisably a specific entity (e.g. IBAN-shaped) so
57
126
  // overlapping regex-only matches (license plate, phone) are false
58
127
  // positives and must be suppressed.
59
128
  const validated = [];
60
- const suppressionZones = [];
129
+ const rawZones = [];
61
130
  for (const m of rawMatches) {
62
- if (this.matcher.validate(m)) {
63
- validated.push(m);
131
+ const isValid = this.matcher.validate(m);
132
+ if (isValid) {
133
+ validated.push({ match: m, hasValidValidator: m.patternDef.validator !== null });
64
134
  }
65
135
  else if (m.patternDef.validator !== null && !m.patternDef.requiresContext) {
66
- suppressionZones.push([m.start, m.end]);
136
+ rawZones.push([m.start, m.end]);
137
+ }
138
+ }
139
+ // Merge overlapping suppression zones for O(log n) containment checks
140
+ let zoneStarts = [];
141
+ let zoneEnds = [];
142
+ if (rawZones.length > 0) {
143
+ rawZones.sort((a, b) => a[0] - b[0] || a[1] - b[1]);
144
+ let [ms, me] = rawZones[0];
145
+ for (let i = 1; i < rawZones.length; i++) {
146
+ const [zs, ze] = rawZones[i];
147
+ if (zs <= me) {
148
+ me = Math.max(me, ze);
149
+ }
150
+ else {
151
+ zoneStarts.push(ms);
152
+ zoneEnds.push(me);
153
+ ms = zs;
154
+ me = ze;
155
+ }
67
156
  }
157
+ zoneStarts.push(ms);
158
+ zoneEnds.push(me);
68
159
  }
69
160
  // Build candidates with priority: validated (3) > custom (2) > regex-only (1)
70
161
  const candidates = [];
71
- for (const match of validated) {
162
+ for (const { match, hasValidValidator } of validated) {
72
163
  if ((0, suppressors_js_1.shouldSuppress)(text, match))
73
164
  continue;
74
- if (match.patternDef.validator === null && suppressionZones.length > 0) {
75
- if (suppressionZones.some(([zStart, zEnd]) => match.start >= zStart && match.end <= zEnd)) {
76
- continue;
165
+ // O(log n) binary-search containment check on merged zones
166
+ if (match.patternDef.validator === null && zoneStarts.length > 0) {
167
+ let lo = 0, hi = zoneStarts.length;
168
+ while (lo < hi) {
169
+ const mid = (lo + hi) >>> 1;
170
+ if (zoneStarts[mid] <= match.start)
171
+ lo = mid + 1;
172
+ else
173
+ hi = mid;
77
174
  }
175
+ const idx = lo - 1;
176
+ if (idx >= 0 && zoneEnds[idx] >= match.end)
177
+ continue;
78
178
  }
79
- const hasValidator = match.patternDef.validator !== null;
80
- const isValid = this.matcher.validate(match);
81
- const priority = (hasValidator && isValid) ? 3 : match.countryCode === "CUSTOM" ? 2 : 1;
179
+ // A bare digit run is the weakest evidence in the engine and must never
180
+ // re-cut a span a structured detector claims (PHONE, SSN, NATIONAL_ID,
181
+ // BANK_ACCOUNT, VAT...). Postal codes take unclaimed spans only,
182
+ // whatever the span lengths.
183
+ const priority = hasValidValidator
184
+ ? 3
185
+ : match.countryCode === "CUSTOM"
186
+ ? 2
187
+ : match.patternDef.entityType === types_js_1.EntityType.POSTAL_CODE
188
+ ? 0
189
+ : 1;
82
190
  const spanLength = match.end - match.start;
83
191
  const score = priority * 1_000_000 + spanLength;
84
192
  candidates.push({
@@ -109,16 +217,20 @@ class RuleEngine {
109
217
  return [];
110
218
  const sorted = [...candidates].sort((a, b) => b.score - a.score);
111
219
  const result = [];
220
+ const occupied = new Set();
112
221
  for (const { det } of sorted) {
113
222
  let overlaps = false;
114
- for (const kept of result) {
115
- if (det.start < kept.end && det.end > kept.start) {
223
+ for (let p = det.start; p < det.end; p++) {
224
+ if (occupied.has(p)) {
116
225
  overlaps = true;
117
226
  break;
118
227
  }
119
228
  }
120
- if (!overlaps)
229
+ if (!overlaps) {
230
+ for (let p = det.start; p < det.end; p++)
231
+ occupied.add(p);
121
232
  result.push(det);
233
+ }
122
234
  }
123
235
  return result;
124
236
  }
@@ -22,11 +22,13 @@ class MultiPatternMatcher {
22
22
  compile() {
23
23
  this.compiled = true;
24
24
  }
25
- scan(text) {
25
+ scan(text, countryCodes = null) {
26
26
  if (!this.compiled)
27
27
  this.compile();
28
28
  const matches = [];
29
29
  for (const { regex, patternDef, countryCode } of this.patterns) {
30
+ if (countryCodes !== null && !countryCodes.has(countryCode))
31
+ continue;
30
32
  regex.lastIndex = 0;
31
33
  let m;
32
34
  while ((m = regex.exec(text)) !== null) {