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 +1 -1
- package/dist/cjs/cache.js +52 -18
- package/dist/cjs/rules/context.js +1 -1
- package/dist/cjs/rules/countries/index.js +14 -1
- package/dist/cjs/rules/engine.js +43 -10
- package/dist/cjs/rules/suppressors.js +169 -36
- package/dist/cjs/rules/validators.js +12 -0
- package/dist/cjs/sdk.js +45 -3
- package/dist/esm/cache.d.ts +5 -1
- package/dist/esm/cache.js +52 -18
- package/dist/esm/rules/context.d.ts +1 -1
- package/dist/esm/rules/context.js +1 -1
- package/dist/esm/rules/countries/index.js +14 -1
- package/dist/esm/rules/engine.js +44 -11
- package/dist/esm/rules/suppressors.d.ts +14 -1
- package/dist/esm/rules/suppressors.js +167 -36
- package/dist/esm/rules/validators.d.ts +12 -0
- package/dist/esm/rules/validators.js +12 -0
- package/dist/esm/sdk.js +45 -3
- package/package.json +8 -3
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@
|
|
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
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
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
|
-
|
|
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
|
-
|
|
24
|
-
//
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
49
|
-
|
|
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@
|
|
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
|
-
|
|
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),
|
package/dist/cjs/rules/engine.js
CHANGED
|
@@ -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
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
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*[
|
|
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
|
-
"
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
491
|
-
|
|
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
|
-
|
|
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)
|