euredact 0.3.5 → 0.3.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -313,7 +313,7 @@ for (const page of pages) {
313
313
  offset += page.length;
314
314
  }
315
315
 
316
- // page 1: "Factuur — IBAN NL91ABNA0417164300, info@jansen.nl"
316
+ // page 1: "Factuur — IBAN NL91ABNA0417164300, info@example.nl"
317
317
  // page 7: "Telefoon 0612345678" -> PHONE (NL), not NATIONAL_ID (DK)
318
318
  ```
319
319
 
package/dist/cjs/cache.js CHANGED
@@ -1,30 +1,44 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.ResultCache = void 0;
4
- // Simple hash function (FNV-1a) - no crypto dependency needed for cache keys
5
- function fnv1aHash(str) {
6
- let hash = 0x811c9dc5;
7
- for (let i = 0; i < str.length; i++) {
8
- hash ^= str.charCodeAt(i);
9
- hash = (hash * 0x01000193) >>> 0;
10
- }
11
- return hash.toString(16).padStart(8, "0");
4
+ const node_crypto_1 = require("node:crypto");
5
+ // Default ceiling on retained characters, not entries. A count-only cap of
6
+ // 1024 entries says nothing about memory: 1024 cached 1 MB documents is a
7
+ // gigabyte of PII-bearing text held live. ~16M characters is a few tens of MB.
8
+ const DEFAULT_MAX_CHARS = 16_000_000;
9
+ /** Approximate retained size of a cached result, in characters. */
10
+ function resultChars(result) {
11
+ let chars = result.redactedText.length;
12
+ for (const d of result.detections)
13
+ chars += d.text.length;
14
+ return chars;
12
15
  }
13
16
  class ResultCache {
14
17
  maxsize;
18
+ maxChars;
15
19
  enabled;
16
20
  store = new Map();
17
- constructor(maxsize = 1024, enabled = true) {
21
+ sizes = new Map();
22
+ chars = 0;
23
+ constructor(maxsize = 1024, enabled = true, maxChars = DEFAULT_MAX_CHARS) {
18
24
  this.maxsize = maxsize;
25
+ this.maxChars = maxChars;
19
26
  this.enabled = enabled;
20
27
  }
21
28
  key(text, countries, mode) {
22
29
  const sorted = [...countries].sort();
23
- const raw = `${text}|${sorted.join("|")}|${mode}`;
24
- // Include raw length to reduce collision risk alongside dual FNV-1a hashes
25
- const h1 = fnv1aHash(raw);
26
- const h2 = fnv1aHash(raw.slice(Math.floor(raw.length / 2)) + raw.slice(0, Math.floor(raw.length / 2)));
27
- return `${raw.length}:${h1}${h2}`;
30
+ // SHA-256, matching the Python implementation. The previous dual FNV-1a
31
+ // was non-cryptographic and collidable by construction: in a shared-process
32
+ // deployment an attacker who can guess a victim's document could submit a
33
+ // same-length colliding one and be served the victim's cached result,
34
+ // detections and raw matched PII included.
35
+ return (0, node_crypto_1.createHash)("sha256")
36
+ .update(text)
37
+ .update("|")
38
+ .update(sorted.join("|"))
39
+ .update("|")
40
+ .update(mode)
41
+ .digest("hex");
28
42
  }
29
43
  get(key) {
30
44
  if (!this.enabled)
@@ -41,16 +55,36 @@ class ResultCache {
41
55
  put(key, result) {
42
56
  if (!this.enabled)
43
57
  return;
44
- this.store.delete(key);
58
+ const size = resultChars(result);
59
+ // A single result bigger than the whole budget would evict everything else
60
+ // and still not fit; skip it rather than empty the cache for one document.
61
+ if (size > this.maxChars) {
62
+ this.evict(key);
63
+ return;
64
+ }
65
+ this.evict(key);
45
66
  this.store.set(key, result);
46
- while (this.store.size > this.maxsize) {
67
+ this.sizes.set(key, size);
68
+ this.chars += size;
69
+ while (this.store.size > this.maxsize || this.chars > this.maxChars) {
47
70
  const first = this.store.keys().next().value;
48
- if (first !== undefined)
49
- this.store.delete(first);
71
+ if (first === undefined)
72
+ break;
73
+ this.evict(first);
50
74
  }
51
75
  }
76
+ evict(key) {
77
+ const size = this.sizes.get(key);
78
+ if (size !== undefined) {
79
+ this.chars -= size;
80
+ this.sizes.delete(key);
81
+ }
82
+ this.store.delete(key);
83
+ }
52
84
  clear() {
53
85
  this.store.clear();
86
+ this.sizes.clear();
87
+ this.chars = 0;
54
88
  }
55
89
  }
56
90
  exports.ResultCache = ResultCache;
@@ -7,7 +7,7 @@
7
7
  * contain no IBAN, no `+CC` number and no ccTLD infers nothing at all, even
8
8
  * when the first page identified the document beyond doubt:
9
9
  *
10
- * page 1 "Factuur — IBAN NL91ABNA0417164300, info@jansen.nl"
10
+ * page 1 "Factuur — IBAN NL91ABNA0417164300, info@example.nl"
11
11
  * page 7 "Telefoon 0612345678" <- Dutch mobile, or Danish CPR?
12
12
  *
13
13
  * Scanned alone, page 7 has nothing to go on. Sharing a context across the two
@@ -160,7 +160,20 @@ const SHARED = {
160
160
  // --- Secret / API Key (assignment-based: KEY=value or KEY: value) ---
161
161
  p(types_js_1.EntityType.SECRET, String.raw `(?<=[:=] )\S{8,}|(?<=[:=])\S{8,}`, "high_entropy", "Assigned secret value", SECRET_CONTEXT, true),
162
162
  // --- Secret / API Key (entropy-based fallback for longer tokens) ---
163
- p(types_js_1.EntityType.SECRET, String.raw `\b[A-Za-z0-9_\-+/]{24,}[A-Za-z0-9_\-+/=]*\b`, "high_entropy", "High-entropy token", SECRET_CONTEXT, true),
163
+ // Only the *trailing* \b is replaced. It could not match when the token run
164
+ // ended on "-", "+" or "/", so the engine backtracked across two
165
+ // overlapping quantified classes: an ordinary dashed rule line ("x-----...")
166
+ // cost O(n^2), 8.3 s for 80 KB. The lookahead simply asserts the run is
167
+ // over, which is what \b was there to express, and it cannot fail after a
168
+ // maximal run — so there is nothing to backtrack into.
169
+ //
170
+ // The leading \b stays. A class lookbehind there widened the span leftwards
171
+ // over any "/" in front, so an authorized_keys entry started "//...",
172
+ // matched the protocol-relative URL shape in the endpoint suppressor, and
173
+ // was dropped — leaving SSH key material in the clear. Base64 padding is
174
+ // now inside the span instead of trailing outside it; that is the only
175
+ // intended difference.
176
+ 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),
164
177
  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),
165
178
  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),
166
179
  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),
@@ -310,6 +310,14 @@ class RuleEngine {
310
310
  const allFailed = failedSpans
311
311
  .map(f => [f.start, f.end])
312
312
  .sort((a, b) => a[0] - b[0] || a[1] - b[1]);
313
+ // Running maximum of the end offsets, so the fragment test below can ask
314
+ // "does any span starting at or before me reach past my end?" in O(1)
315
+ // instead of walking the prefix per candidate.
316
+ const failedStarts = allFailed.map(f => f[0]);
317
+ const failedPrefixMax = new Array(allFailed.length + 1).fill(0);
318
+ for (let i = 0; i < allFailed.length; i++) {
319
+ failedPrefixMax[i + 1] = Math.max(failedPrefixMax[i], allFailed[i][1]);
320
+ }
313
321
  const mergedZones = new Map();
314
322
  for (const [etype, spans] of zonesByType) {
315
323
  spans.sort((a, b) => a[0] - b[0] || a[1] - b[1]);
@@ -332,8 +340,11 @@ class RuleEngine {
332
340
  mergedZones.set(etype, { starts, ends });
333
341
  }
334
342
  const candidates = [];
343
+ // Whole-document facts a suppressor may need, derived at most once per call
344
+ // and discarded with it.
345
+ const scratch = new suppressors_js_1.SuppressionScratch(text);
335
346
  for (const { match, hasValidValidator } of validated) {
336
- if ((0, suppressors_js_1.shouldSuppress)(text, match))
347
+ if ((0, suppressors_js_1.shouldSuppress)(text, match, scratch))
337
348
  continue;
338
349
  // A validator-less match inside a failed-validation span of its own type
339
350
  // is demoted below every other candidate rather than deleted. Demotion
@@ -361,13 +372,31 @@ class RuleEngine {
361
372
  // default — the generic phone pattern kept claiming the tail of the
362
373
  // rejected national number.
363
374
  if (match.patternDef.validator === null && !demoted) {
375
+ // Upper bound of the spans starting at or before match.start.
376
+ let lo = 0, hi = failedStarts.length;
377
+ while (lo < hi) {
378
+ const mid = (lo + hi) >>> 1;
379
+ if (failedStarts[mid] <= match.start)
380
+ lo = mid + 1;
381
+ else
382
+ hi = mid;
383
+ }
364
384
  let fragment = false;
365
- for (const [zs, ze] of allFailed) {
366
- if (zs > match.start)
367
- break;
368
- if (match.end <= ze && (match.start > zs || match.end < ze)) {
369
- fragment = true;
370
- break;
385
+ const reach = failedPrefixMax[lo];
386
+ if (reach > match.end) {
387
+ // Something starting no later than us ends strictly later: we are a
388
+ // proper subset of it.
389
+ fragment = true;
390
+ }
391
+ else if (reach === match.end) {
392
+ // Same end offset — a proper subset only if some span with that end
393
+ // starts strictly earlier. Scanning backwards finds the enclosing
394
+ // span first, and the equal case is rare.
395
+ for (let i = lo - 1; i >= 0; i--) {
396
+ if (allFailed[i][1] === match.end && allFailed[i][0] < match.start) {
397
+ fragment = true;
398
+ break;
399
+ }
371
400
  }
372
401
  }
373
402
  if (fragment)
@@ -418,6 +447,7 @@ class RuleEngine {
418
447
  length: match.end - match.start,
419
448
  countryScore: countryScores.get(match.countryCode) ?? 0,
420
449
  inScope: declared === null || declared.has(match.countryCode) ? 1 : 0,
450
+ tier: 0,
421
451
  det: {
422
452
  entityType: match.patternDef.entityType,
423
453
  start: match.start,
@@ -432,7 +462,7 @@ class RuleEngine {
432
462
  }
433
463
  for (const d of (0, structural_js_1.detectStructuralDob)(text)) {
434
464
  candidates.push({ blindPriority: 1, cue: 0, priority: 1, length: d.end - d.start,
435
- countryScore: 0, inScope: 1, det: d });
465
+ countryScore: 0, inScope: 1, tier: 0, det: d });
436
466
  }
437
467
  const detections = this.deduplicate(candidates);
438
468
  detections.sort((a, b) => a.start - b.start || b.end - a.end);
@@ -466,7 +496,10 @@ class RuleEngine {
466
496
  if (seen === undefined || c.blindPriority > seen)
467
497
  spanTier.set(key, c.blindPriority);
468
498
  }
469
- const tierOf = (c) => spanTier.get(`${c.det.start}:${c.det.end}`);
499
+ // Resolved once per candidate rather than inside the comparator, which ran
500
+ // it O(n log n) times and rebuilt the key string on every comparison.
501
+ for (const c of candidates)
502
+ c.tier = spanTier.get(`${c.det.start}:${c.det.end}`);
470
503
  // Span length outranks *everything*, including validation.
471
504
  //
472
505
  // A shorter candidate winning re-cuts a longer one and leaves the
@@ -492,7 +525,7 @@ class RuleEngine {
492
525
  // country-blind up to and including `start`, and (length, start) already
493
526
  // identifies a span uniquely.
494
527
  const sorted = [...candidates].sort((a, b) => b.length - a.length || // longest first } country-blind
495
- tierOf(b) - tierOf(a) || // best tier here } — decides
528
+ b.tier - a.tier || // best tier here } — decides
496
529
  a.det.start - b.det.start || // leftmost } WHICH span
497
530
  b.cue - a.cue || // local cue } country-aware
498
531
  b.priority - a.priority || // country priority } — decides the
@@ -1,5 +1,6 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.SuppressionScratch = void 0;
3
4
  exports.shouldSuppress = shouldSuppress;
4
5
  const types_js_1 = require("../types.js");
5
6
  const bicRegistry_js_1 = require("./bicRegistry.js");
@@ -12,15 +13,29 @@ function getContext(text, start, end) {
12
13
  }
13
14
  // Unicode-safe word boundary: negative lookahead for any letter (including accented)
14
15
  const _UWB = "(?![a-zA-Z\\u00C0-\\u024F\\u0400-\\u04FF])";
15
- const CURRENCY_AFTER = new RegExp(`^\\s*(?:EUR|€|\\$|USD|GBP|£|CHF|ISK|SEK|NOK|DKK|euro|euros|dollar|dollars|pond|kronor|kroner|kr)${_UWB}`, "i");
16
- const CURRENCY_COMMA_AFTER = new RegExp(`^[.,]\\d{1,2}\\s*(?:EUR|€|\\$|USD|GBP|£|CHF|ISK|SEK|NOK|DKK|euro|euros|kr(?:onor|oner)?|pond)${_UWB}`, "i");
17
- const CURRENCY_BEFORE = /(?:EUR|€|\$|USD|GBP|£|CHF|ISK|SEK|NOK|DKK)\s*$/;
18
- const AMOUNT_LABEL_BEFORE = /(?:Montant|Beløb|Summa|Summe|Bedrag|Amount|Total|TTC|inkl|Upphæð)\s*:?\s*$/i;
16
+ // Symbols are held apart from the alphabetic codes because a word boundary
17
+ // after "€" is meaningless — the symbol is not a word character. Python used a
18
+ // bare "\b" here and so could never match "1163 €," at all; this port used the
19
+ // lookahead below and did not have that defect. Kept split so the difference
20
+ // cannot come back.
21
+ const CURRENCY_SYMBOL = "€|\\$|£|¥|₺|zł|Kč|лв|kn|Ft|₽";
22
+ const CURRENCY_WORD = "EUR|USD|GBP|CHF|ISK|SEK|NOK|DKK|PLN|CZK|HUF|RON|BGN|HRK|" +
23
+ "euro|euros|dollar|dollars|pond|kronor|kroner|kronur|kr|" +
24
+ "złoty|korun|forint|lei|leva";
25
+ const CURRENCY_AFTER = new RegExp(`^\\s*(?:(?:${CURRENCY_SYMBOL})|(?:${CURRENCY_WORD})${_UWB})`, "i");
26
+ const CURRENCY_COMMA_AFTER = new RegExp(`^[.,]\\d{1,2}\\s*(?:(?:${CURRENCY_SYMBOL})|(?:${CURRENCY_WORD})${_UWB})`, "i");
27
+ const CURRENCY_BEFORE = new RegExp(`(?:(?:${CURRENCY_SYMBOL})|\\b(?:${CURRENCY_WORD}))\\s*$`, "i");
28
+ const AMOUNT_LABEL_BEFORE = /\b(?:Montant|Beløb|Summa|Summe|Bedrag|Amount|Total|TTC|inkl|Upphæð|Importe|Importo|Valore|Wartość|Kwota|Částka|Összeg|Sum|Beloop|Prix|Preis|Price|Loyer|Miete|Huur|Rent|Betrag)\s*(?:\w+\s*)?:?\s*$/i;
19
29
  // Use Unicode-aware word boundary via \p{L} negative lookahead instead of \b
20
30
  // because JS \b is ASCII-only and fails on Unicode letters (e.g. "München" → \bm\b matches M before ü)
21
31
  const UNIT_AFTER = /^\s*(?:kg|km|cm|mm|m[²³]|ml|mg|GB|MB|KB|TB|%|jaar|maanden|weken|dagen|uur|minuten|seconden|stuks|pcs|pieces|ans|mois|semaines|jours|heures|Jahre|Monate|Wochen|Tage|Stunden)(?![a-zA-Z\u00C0-\u024F\u0400-\u04FF])/i;
22
32
  // Same missing-boundary flaw: "ref" matched the tail of "kortref".
23
- const REFERENCE_BEFORE = /\b(?:dossier|ref\.?|referentie|reference|référence|factuurnummer|invoice\s*(?:nr|number|no)?|bestelnummer|order\s*(?:nr|number|no)?|kenmerk|ordernummer|Aktenzeichen|numéro\s*de\s*(?:dossier|facture|commande)|bestellnummer|Rechnungsnummer|artikelnr|article\s*no|contract\s*(?:nr|number|no)?|pagina|page|Seite|blz\.?|Facture\s*n[°o]?|Faktura\s*n[°or]\.?|Lasku\s*n[°or]o?\.?|Rechnung\s*(?:Nr|n[°o])?|faktura\s*(?:nr|n[°o])?|bestilling\s*(?:nr|n[°o])?|bestelling\s*n[°or]\.?|Reikningur\s*nr|Factuur\s*n[ro]?\.?|Nota\s*n[ro]?\.?)\s*[:.]?\s*$/i;
33
+ const REFERENCE_BEFORE = /\b(?:dossier|ref\.?|referentie|reference|référence|factuurnummer|invoice\s*(?:nr|number|no)?|bestelnummer|order\s*(?:nr|number|no)?|kenmerk|ordernummer|Aktenzeichen|numéro\s*de\s*(?:dossier|facture|commande)|bestellnummer|Rechnungsnummer|artikelnr|article\s*no|contract\s*(?:nr|number|no)?|pagina|page|Seite|blz\.?|Facture\s*n[°o]?|Faktura\s*n[°or]\.?|Lasku\s*n[°or]o?\.?|Rechnung\s*(?:Nr|n[°o])?|faktura\s*(?:nr|n[°o])?|bestilling\s*(?:nr|n[°o])?|bestelling\s*n[°or]\.?|Reikningur\s*nr|ticket|incident\s*(?:report|nr|no)?|case\s*(?:nr|no|id)?|zaaknummer|meldingsnummer|Vorgangsnummer|Vorgang|Störungsmeldung|saksnummer|ärendenummer|sagsnummer|asianumero|Factuur\s*n[ro]?\.?|Nota\s*n[ro]?\.?)\s*[:.#]?\s*$/i;
34
+ // "#" before a number marks a reference in every language in scope — a ticket,
35
+ // an order, a line item — and never a postcode or a national identifier.
36
+ const HASH_BEFORE = /#\s*$/;
37
+ // A short uppercase tag hyphenated to the number: "IR-43433", "INC-2024".
38
+ const REF_PREFIX_BEFORE = /(?:^|[\s([])[A-Z]{2,5}-$/;
24
39
  const LEGAL_BEFORE = /(?:Art(?:ikel|icle|\.)|§|Artikel|Section|Sectie|Afdeling|paragraaf|Absatz|alinéa|punt|point|Punkt|lid)\s*$/i;
25
40
  const MATH_BEFORE = /[=+\-×÷*/]\s*$/;
26
41
  const MATH_AFTER = /^\s*[=+\-×÷*/]/;
@@ -52,7 +67,11 @@ const MONTH_NAMES = "january|february|march|april|may|june|july|august|september
52
67
  "|gennaio|febbraio|marzo|aprile|maggio|giugno|luglio|agosto|settembre|ottobre|dicembre" +
53
68
  "|enero|febrero|abril|mayo|junio|julio|septiembre|octubre|noviembre|diciembre" +
54
69
  "|janeiro|fevereiro|março|marco|maio|junho|julho|setembro|outubro|novembro|dezembro" +
55
- "|marts|augusti" +
70
+ // "desember" (nb/nn/is) was the one December spelling missing from a list
71
+ // that already carried ten others — "1. desember 2025" was a Norwegian
72
+ // postcode because of it.
73
+ "|marts|augusti|desember" +
74
+ "|janúar|febrúar|apríl|maí|júní|júlí|ágúst|nóvember" +
56
75
  "|tammikuu|helmikuu|maaliskuu|huhtikuu|toukokuu|kesäkuu|heinäkuu|elokuu|syyskuu|lokakuu|marraskuu|joulukuu" +
57
76
  "|jaanuar|veebruar|aprill|juuni|juuli|oktoober|detsember" +
58
77
  "|janvāris|februāris|aprīlis|maijs|jūnijs|jūlijs|augusts|septembris|oktobris|novembris|decembris" +
@@ -77,7 +96,11 @@ const DATE_AFTER = /^[./-]\d{1,2}[./-]\d{1,2}\b/;
77
96
  * ISO 4217 codes made only of the consonants a Spanish plate accepts, so a money
78
97
  * amount reads as a registration. This is why "2297 DKK" was a plate.
79
98
  */
80
- const CURRENCY_CODE = "(?:DKK|SEK|NOK|ISK|CZK|PLN|HUF|RON|BGN|HRK|CHF|GBP|TRY|RSD|MKD|BYN|KZT|CNY|JPY|KRW|ZAR|BRL|MXN|CLP|COP|PLZ|SKK|TRL)";
99
+ // Crypto tickers are the same shape and the same mistake — every surviving
100
+ // plate false positive was "4499 BTC" or a sibling. A ticker is a unit, exactly
101
+ // as an ISO 4217 code is.
102
+ const CURRENCY_CODE = "(?:DKK|SEK|NOK|ISK|CZK|PLN|HUF|RON|BGN|HRK|CHF|GBP|TRY|RSD|MKD|BYN|KZT|CNY|JPY|KRW|ZAR|BRL|MXN|CLP|COP|PLZ|SKK|TRL" +
103
+ "|BTC|ETH|LTC|XRP|BCH|XLM|XMR|DOT|SOL|ADA|TRX|DOGE|USDT|USDC)";
81
104
  const CURRENCY_CODE_TAIL = new RegExp(`\\s${CURRENCY_CODE}$`);
82
105
  const CURRENCY_CODE_AFTER = new RegExp(`^\\s*${CURRENCY_CODE}\\b`);
83
106
  /** Log-timestamp tails, which the ":"-anchored secret rule picks up. */
@@ -91,6 +114,32 @@ const NOT_A_SECRET = new RegExp("^(?:af|ap|ca|cn|eu|il|me|sa|us|gov)-(?:north|so
91
114
  const EXACT_UUID = /^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$/;
92
115
  const BIC_SHAPE = /^[A-Z]{6}[A-Z0-9]{2}(?:[A-Z0-9]{3})?$/;
93
116
  const BIC_CUE_BEFORE = /(?:BIC|SWIFT|BIC\/SWIFT)\s*[:=]?\s*\(?\s*$/i;
117
+ /** An email address, exactly. Same argument as the UUID and the BIC. */
118
+ const EXACT_EMAIL = /^[\w.!#$%&'*+/=?^`{|}~-]+@[\w-]+(?:\.[\w-]+)+$/;
119
+ /**
120
+ * A URL, or the tail of one after the scheme has been split off. The secret
121
+ * rules anchor on ":" and "=", so "https://api.sendgrid.com/v3/mail/send" hands
122
+ * them "//api.sendgrid.com/v3/mail/send" and a slash-separated path scores as
123
+ * high-entropy. An endpoint is published documentation, not a credential.
124
+ */
125
+ const URL_LIKE = /^(?:[a-z][a-z0-9+.-]*:)?\/\/[^\s/]+(?:\/\S*)?$|^[a-z0-9-]+(?:\.[a-z0-9-]+)*\.[a-z]{2,}(?::\d{1,5})?(?:\/\S*)?$/i;
126
+ /**
127
+ * A URL that *carries* a credential is the opposite case, and it is the common
128
+ * one: "mongodb://admin:DFKDKi1eb51OOhuHPYz@rds-main.eu-west-1.rds.amazonaws.com"
129
+ * is a connection string with a live password in it. Suppressing those as "just
130
+ * a URL" lost 347 real secrets, so the endpoint test must not fire when userinfo
131
+ * or a credential-bearing query parameter is present.
132
+ */
133
+ const URL_WITH_CREDENTIALS = /^(?:[a-z][a-z0-9+.-]*:)?\/\/[^/@\s]*:[^/@\s]*@|[?&](?:api_?key|access_?token|auth|token|secret|password|pwd|sig|signature|credential)=/i;
134
+ /** An LDAP distinguished name: "cn=github-actions,dc=corp,dc=eu". */
135
+ const LDAP_DN = /^(?:cn|ou|dc|uid|o|l|st|c)=[^,=]+(?:,\s*(?:cn|ou|dc|uid|o|l|st|c)=[^,=]+)+$/i;
136
+ /**
137
+ * Lower-case words joined by hyphens, optionally with a short id suffix:
138
+ * "service-account", "data-lake-33e061". The single-word test below already
139
+ * rejects "sozialversicherungsnummer" on the reasoning that a real secret would
140
+ * be mixed case; a hyphenated compound is the same token with a separator.
141
+ */
142
+ const HYPHENATED_WORDS = /^[a-z]{2,}(?:-[a-z]{2,})+(?:-[0-9a-f]{4,8})?$/;
94
143
  /** A dotted quad with every octet in range. */
95
144
  const DOTTED_QUAD = /^(?:(?:25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)\.){3}(?:25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)$/;
96
145
  /** Two plausible years joined by a hyphen — a school year, a contract term. */
@@ -120,7 +169,7 @@ function suppressSecretOverStructured(text, match) {
120
169
  if (match.patternDef.entityType !== types_js_1.EntityType.SECRET)
121
170
  return false;
122
171
  const token = coreToken(match.text);
123
- if (EXACT_UUID.test(token))
172
+ if (EXACT_UUID.test(token) || EXACT_EMAIL.test(token))
124
173
  return true;
125
174
  if (BIC_SHAPE.test(token)) {
126
175
  return BIC_CUE_BEFORE.test(text.slice(Math.max(0, match.start - 24), match.start));
@@ -145,6 +194,12 @@ function suppressSecretNotASecret(_text, match) {
145
194
  return true;
146
195
  if (NOT_A_SECRET.test(bare))
147
196
  return true;
197
+ if (URL_LIKE.test(bare) && !URL_WITH_CREDENTIALS.test(bare))
198
+ return true;
199
+ if (LDAP_DN.test(bare))
200
+ return true;
201
+ if (HYPHENATED_WORDS.test(bare))
202
+ return true;
148
203
  const alpha = /^[A-Za-zÀ-ÿ]+$/.test(bare);
149
204
  const oneWord = bare === bare.toLowerCase() ||
150
205
  (bare.slice(0, 1) === bare.slice(0, 1).toUpperCase() &&
@@ -205,15 +260,34 @@ function suppressUnits(text, match) {
205
260
  const [, after] = getContext(text, match.start, match.end);
206
261
  return UNIT_AFTER.test(after);
207
262
  }
263
+ /**
264
+ * Suppress numbers preceded by reference/invoice/dossier keywords.
265
+ *
266
+ * POSTAL_CODE belongs here for the same reason every other numeric type does,
267
+ * and its absence was an oversight: a five-digit ticket number sits in exactly
268
+ * the shape a German or French postcode occupies, so "Ticket #94730" and
269
+ * "Incident report IR-43433" were masked as addresses.
270
+ *
271
+ * "#" and "XX-" are checked adjacently rather than through the 150-character
272
+ * keyword window. That window is what made the postal rule claim years in
273
+ * dates; widening its reach to fix a different symptom would repeat the error.
274
+ */
275
+ // Hoisted out of the suppressor bodies: rebuilding these per candidate cost
276
+ // ~260k Set allocations per 1 MB document, and GC was 10.7% of the profile.
277
+ const REFERENCE_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.IBAN, types_js_1.EntityType.CHAMBER_OF_COMMERCE, types_js_1.EntityType.POSTAL_CODE]);
278
+ const LEGAL_TYPES = new Set([types_js_1.EntityType.PHONE, types_js_1.EntityType.NATIONAL_ID, types_js_1.EntityType.POSTAL_CODE]);
208
279
  function suppressReference(text, match) {
209
- const applicable = 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.IBAN, types_js_1.EntityType.CHAMBER_OF_COMMERCE]);
280
+ const applicable = REFERENCE_TYPES;
210
281
  if (!applicable.has(match.patternDef.entityType))
211
282
  return false;
212
283
  const [before] = getContext(text, match.start, match.end);
213
- return REFERENCE_BEFORE.test(before);
284
+ if (REFERENCE_BEFORE.test(before))
285
+ return true;
286
+ const adjacent = text.slice(Math.max(0, match.start - 8), match.start);
287
+ return HASH_BEFORE.test(adjacent) || REF_PREFIX_BEFORE.test(adjacent);
214
288
  }
215
289
  function suppressLegal(text, match) {
216
- const applicable = new Set([types_js_1.EntityType.PHONE, types_js_1.EntityType.NATIONAL_ID, types_js_1.EntityType.POSTAL_CODE]);
290
+ const applicable = LEGAL_TYPES;
217
291
  if (!applicable.has(match.patternDef.entityType))
218
292
  return false;
219
293
  const [before] = getContext(text, match.start, match.end);
@@ -436,8 +510,15 @@ function enclosingLine(text, start, end) {
436
510
  */
437
511
  function structuralUnit(text, start, end) {
438
512
  const [lineStart, lineEnd] = enclosingLine(text, start, end);
513
+ // Both walks stop as soon as the paragraph is too wide to be used, because
514
+ // everything past that point is discarded by the cap below anyway. Applying
515
+ // the cap only to the *result* meant that on a document with no blank lines
516
+ // each walk ran to the top and bottom of the whole text — O(document) per
517
+ // candidate, O(n^2) overall.
439
518
  let paraStart = lineStart;
440
519
  while (paraStart > 0) {
520
+ if (lineEnd - paraStart > MAX_UNIT_CHARS)
521
+ return text.slice(lineStart, lineEnd);
441
522
  const prevEnd = paraStart - 1;
442
523
  const prevStart = text.lastIndexOf("\n", prevEnd - 1) + 1;
443
524
  if (text.slice(prevStart, prevEnd).trim() === "")
@@ -446,6 +527,8 @@ function structuralUnit(text, start, end) {
446
527
  }
447
528
  let paraEnd = lineEnd;
448
529
  while (paraEnd < text.length) {
530
+ if (paraEnd - paraStart > MAX_UNIT_CHARS)
531
+ return text.slice(lineStart, lineEnd);
449
532
  const nextStart = paraEnd + 1;
450
533
  let nextEnd = text.indexOf("\n", nextStart);
451
534
  if (nextEnd === -1)
@@ -482,28 +565,74 @@ function escapeRe(s) {
482
565
  * heading. Measured on the corpus this alone identifies ~78% of the BIC false
483
566
  * positives with no dictionaries and no labelled data.
484
567
  */
485
- function occursAsLowercaseWord(text, token) {
568
+ /**
569
+ * Every all-letter word in the document that is not part of an email address
570
+ * or a domain name.
571
+ *
572
+ * `\b<word>\b` can only match a maximal run of word characters, so collecting
573
+ * the runs once answers the question for every candidate. Re-scanning the whole
574
+ * document per candidate made this quadratic: 1.58 s on a 400 KB document
575
+ * dense in bank codes, against ~1 ms to build this set.
576
+ */
577
+ function buildDocumentWords(text) {
578
+ const words = new Set();
579
+ const re = /[A-Za-z0-9_]+/g;
580
+ let m;
581
+ while ((m = re.exec(text)) !== null) {
582
+ const word = m[0];
583
+ if (!/^[A-Za-z]+$/.test(word))
584
+ continue;
585
+ const i = m.index;
586
+ const before = i > 0 ? text[i - 1] : "";
587
+ if (before === "@")
588
+ continue;
589
+ if (before === "." && i >= 2 && /[A-Za-z0-9]/.test(text[i - 2]))
590
+ continue;
591
+ const after = text.slice(i + word.length, i + word.length + 8);
592
+ if (after.startsWith("@"))
593
+ continue;
594
+ if (/^\.[a-zA-Z]{2,6}\b/.test(after))
595
+ continue;
596
+ words.add(word);
597
+ }
598
+ return words;
599
+ }
600
+ /**
601
+ * Per-document scratch space for suppressors that would otherwise re-derive the
602
+ * same whole-document facts for every candidate.
603
+ *
604
+ * Built lazily and held only for the duration of one `detect()` call, so no
605
+ * document text outlives the call that supplied it.
606
+ */
607
+ class SuppressionScratch {
608
+ text;
609
+ words = null;
610
+ constructor(text) {
611
+ this.text = text;
612
+ }
613
+ documentWords() {
614
+ if (this.words === null)
615
+ this.words = buildDocumentWords(this.text);
616
+ return this.words;
617
+ }
618
+ }
619
+ exports.SuppressionScratch = SuppressionScratch;
620
+ /**
621
+ * Does `token` also occur as an ordinary lowercase word in this document?
622
+ *
623
+ * `hospital`/`HOSPITAL`, `gegevens`/`GEGEVENS` — a token that appears in
624
+ * ordinary case elsewhere in the same document is a word, not a bank code.
625
+ * Email and domain contexts are excluded, so `ing.nl` does not vouch for a
626
+ * heading. Measured on the corpus this alone identifies ~78% of the BIC false
627
+ * positives with no dictionaries and no labelled data.
628
+ */
629
+ function occursAsLowercaseWord(text, token, scratch) {
486
630
  if (!/^[A-Za-z]+$/.test(token))
487
631
  return false;
488
632
  const lower = token.toLowerCase();
489
633
  const capitalised = lower[0].toUpperCase() + lower.slice(1);
490
- for (const form of [lower, capitalised]) {
491
- // Cheap pre-check: a plain substring test almost always answers "no".
492
- if (!text.includes(form))
493
- continue;
494
- const re = new RegExp(`\\b${escapeRe(form)}\\b`, "g");
495
- let m;
496
- while ((m = re.exec(text)) !== null) {
497
- const before = m.index > 0 ? text[m.index - 1] : "";
498
- const after = text.slice(m.index + form.length, m.index + form.length + 8);
499
- if (before === "@" || (before === "." && m.index >= 2 && /[A-Za-z0-9]/.test(text[m.index - 2])))
500
- continue;
501
- if (after.startsWith("@") || /^\.[a-zA-Z]{2,6}\b/.test(after))
502
- continue;
503
- return true;
504
- }
505
- }
506
- return false;
634
+ const words = scratch ? scratch.documentWords() : buildDocumentWords(text);
635
+ return words.has(lower) || words.has(capitalised);
507
636
  }
508
637
  /**
509
638
  * Is the candidate positioned as a section heading rather than a value?
@@ -541,25 +670,29 @@ function isHeadingShape(text, start, end, token) {
541
670
  * ordinary lowercase word — so it is applied on both emitting paths. It scans
542
671
  * the whole document, so it runs only on the paths that would otherwise emit.
543
672
  */
544
- function suppressBicWithoutEvidence(text, match) {
673
+ function suppressBicWithoutEvidence(text, match, scratch) {
545
674
  if (match.patternDef.entityType !== types_js_1.EntityType.BIC)
546
675
  return false;
547
676
  const token = match.text.trim();
548
677
  if ((0, bicRegistry_js_1.isRegisteredBic)(token))
549
- return occursAsLowercaseWord(text, token);
678
+ return occursAsLowercaseWord(text, token, scratch);
550
679
  if (isHeadingShape(text, match.start, match.end, token))
551
680
  return true;
552
681
  // The token itself is blanked out so it cannot vouch for itself.
553
682
  const unit = structuralUnit(text, match.start, match.end).split(token).join(" ".repeat(token.length));
554
683
  if (BIC_KEYWORD.test(unit) || IBAN_SHAPE.test(unit) || BANK_BLOCK.test(unit)) {
555
- return occursAsLowercaseWord(text, token);
684
+ return occursAsLowercaseWord(text, token, scratch);
556
685
  }
557
686
  return true;
558
687
  }
559
688
  // ── Postal code: digits belonging to a longer identifier ────────────────
560
689
  // An identifier label immediately before the digits. A postal code is never
561
690
  // introduced this way; an SVNr, policy number or service number always is.
562
- 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;
691
+ // The `[\w\-]*` prefix is factored out of the five alternatives that share it.
692
+ // Repeated per alternative, the engine retried the same unbounded prefix five
693
+ // times per starting position — the most expensive single regex in the engine
694
+ // at 197 ms per 1 MB document. Same language, 8x faster on the worst case.
695
+ 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;
563
696
  // An international dialling prefix earlier on the same line, with nothing but
564
697
  // number punctuation in between: these digits belong to the phone detector.
565
698
  const DIALLING_PREFIX_BEFORE = /\+\d{1,3}[\d\s\-().]*$/;
@@ -664,20 +797,20 @@ const TYPE_SUPPRESSORS = {
664
797
  [types_js_1.EntityType.NATIONAL_ID]: [suppressCurrency, suppressUnits, suppressReference, suppressLegal, suppressMath, suppressNatidAsPassport, suppressSeNatidAsOrg],
665
798
  [types_js_1.EntityType.SSN]: [suppressCurrency, suppressUnits, suppressReference, suppressMath],
666
799
  [types_js_1.EntityType.TAX_ID]: [suppressCurrency, suppressUnits, suppressReference, suppressMath, suppressTaxidAsIpAddress],
667
- [types_js_1.EntityType.POSTAL_CODE]: [suppressCurrency, suppressUnits, suppressMath, suppressLegal, suppressYearAsPostal, suppressPostalInsideIban, suppressPostalAsHouseNumber, suppressPostalInLongerIdentifier],
800
+ [types_js_1.EntityType.POSTAL_CODE]: [suppressCurrency, suppressUnits, suppressMath, suppressLegal, suppressYearAsPostal, suppressPostalInsideIban, suppressPostalAsHouseNumber, suppressPostalInLongerIdentifier, suppressReference],
668
801
  [types_js_1.EntityType.BIC]: [suppressBicWithoutEvidence],
669
802
  [types_js_1.EntityType.BANK_ACCOUNT]: [suppressReference],
670
803
  [types_js_1.EntityType.LICENSE_PLATE]: [suppressPlateInCompound, suppressDePlateUnknownDistrict, suppressPlateAsCurrencyAmount],
671
804
  [types_js_1.EntityType.SECRET]: [suppressSecretOverStructured, suppressSecretNotASecret],
672
805
  [types_js_1.EntityType.CHAMBER_OF_COMMERCE]: [suppressReference],
673
806
  };
674
- function shouldSuppress(text, match) {
807
+ function shouldSuppress(text, match, scratch) {
675
808
  if (suppressSequential(text, match))
676
809
  return true;
677
810
  const typeSups = TYPE_SUPPRESSORS[match.patternDef.entityType];
678
811
  if (typeSups) {
679
812
  for (const s of typeSups) {
680
- if (s(text, match))
813
+ if (s(text, match, scratch))
681
814
  return true;
682
815
  }
683
816
  }
@@ -225,6 +225,18 @@ function validateFrenchNir(candidate) {
225
225
  const check = parseInt(numeric.slice(13, 15));
226
226
  return (97 - (first13 % 97)) === check;
227
227
  }
228
+ /**
229
+ * ISO 3779 VIN validation: 17 characters, no I/O/Q. **Shape only.**
230
+ *
231
+ * The position-9 check digit is mandatory for North American VINs (WMI 1-5)
232
+ * and unused elsewhere, and it is deliberately **not** enforced. Measured over
233
+ * the 152,300-document corpus, enforcing it turned 1,502 labelled VINs into
234
+ * misses — every North American VIN whose check digit does not survive being
235
+ * written down. A VIN that fails its checksum is still a VIN sitting in the
236
+ * text; dropping it leaves it unredacted, which is a leak, while keeping it
237
+ * costs at worst an over-masked 17-character token. Matches the Python SDK's
238
+ * `validate_vin`, which carries the full reasoning.
239
+ */
228
240
  function validateVin(candidate) {
229
241
  const c = candidate.replace(/[\s\-]/g, "").toUpperCase();
230
242
  if (c.length !== 17)