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
|
@@ -9,15 +9,29 @@ function getContext(text, start, end) {
|
|
|
9
9
|
}
|
|
10
10
|
// Unicode-safe word boundary: negative lookahead for any letter (including accented)
|
|
11
11
|
const _UWB = "(?![a-zA-Z\\u00C0-\\u024F\\u0400-\\u04FF])";
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
12
|
+
// Symbols are held apart from the alphabetic codes because a word boundary
|
|
13
|
+
// after "€" is meaningless — the symbol is not a word character. Python used a
|
|
14
|
+
// bare "\b" here and so could never match "1163 €," at all; this port used the
|
|
15
|
+
// lookahead below and did not have that defect. Kept split so the difference
|
|
16
|
+
// cannot come back.
|
|
17
|
+
const CURRENCY_SYMBOL = "€|\\$|£|¥|₺|zł|Kč|лв|kn|Ft|₽";
|
|
18
|
+
const CURRENCY_WORD = "EUR|USD|GBP|CHF|ISK|SEK|NOK|DKK|PLN|CZK|HUF|RON|BGN|HRK|" +
|
|
19
|
+
"euro|euros|dollar|dollars|pond|kronor|kroner|kronur|kr|" +
|
|
20
|
+
"złoty|korun|forint|lei|leva";
|
|
21
|
+
const CURRENCY_AFTER = new RegExp(`^\\s*(?:(?:${CURRENCY_SYMBOL})|(?:${CURRENCY_WORD})${_UWB})`, "i");
|
|
22
|
+
const CURRENCY_COMMA_AFTER = new RegExp(`^[.,]\\d{1,2}\\s*(?:(?:${CURRENCY_SYMBOL})|(?:${CURRENCY_WORD})${_UWB})`, "i");
|
|
23
|
+
const CURRENCY_BEFORE = new RegExp(`(?:(?:${CURRENCY_SYMBOL})|\\b(?:${CURRENCY_WORD}))\\s*$`, "i");
|
|
24
|
+
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;
|
|
16
25
|
// Use Unicode-aware word boundary via \p{L} negative lookahead instead of \b
|
|
17
26
|
// because JS \b is ASCII-only and fails on Unicode letters (e.g. "München" → \bm\b matches M before ü)
|
|
18
27
|
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;
|
|
19
28
|
// Same missing-boundary flaw: "ref" matched the tail of "kortref".
|
|
20
|
-
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*[
|
|
29
|
+
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;
|
|
30
|
+
// "#" before a number marks a reference in every language in scope — a ticket,
|
|
31
|
+
// an order, a line item — and never a postcode or a national identifier.
|
|
32
|
+
const HASH_BEFORE = /#\s*$/;
|
|
33
|
+
// A short uppercase tag hyphenated to the number: "IR-43433", "INC-2024".
|
|
34
|
+
const REF_PREFIX_BEFORE = /(?:^|[\s([])[A-Z]{2,5}-$/;
|
|
21
35
|
const LEGAL_BEFORE = /(?:Art(?:ikel|icle|\.)|§|Artikel|Section|Sectie|Afdeling|paragraaf|Absatz|alinéa|punt|point|Punkt|lid)\s*$/i;
|
|
22
36
|
const MATH_BEFORE = /[=+\-×÷*/]\s*$/;
|
|
23
37
|
const MATH_AFTER = /^\s*[=+\-×÷*/]/;
|
|
@@ -49,7 +63,11 @@ const MONTH_NAMES = "january|february|march|april|may|june|july|august|september
|
|
|
49
63
|
"|gennaio|febbraio|marzo|aprile|maggio|giugno|luglio|agosto|settembre|ottobre|dicembre" +
|
|
50
64
|
"|enero|febrero|abril|mayo|junio|julio|septiembre|octubre|noviembre|diciembre" +
|
|
51
65
|
"|janeiro|fevereiro|março|marco|maio|junho|julho|setembro|outubro|novembro|dezembro" +
|
|
52
|
-
"
|
|
66
|
+
// "desember" (nb/nn/is) was the one December spelling missing from a list
|
|
67
|
+
// that already carried ten others — "1. desember 2025" was a Norwegian
|
|
68
|
+
// postcode because of it.
|
|
69
|
+
"|marts|augusti|desember" +
|
|
70
|
+
"|janúar|febrúar|apríl|maí|júní|júlí|ágúst|nóvember" +
|
|
53
71
|
"|tammikuu|helmikuu|maaliskuu|huhtikuu|toukokuu|kesäkuu|heinäkuu|elokuu|syyskuu|lokakuu|marraskuu|joulukuu" +
|
|
54
72
|
"|jaanuar|veebruar|aprill|juuni|juuli|oktoober|detsember" +
|
|
55
73
|
"|janvāris|februāris|aprīlis|maijs|jūnijs|jūlijs|augusts|septembris|oktobris|novembris|decembris" +
|
|
@@ -74,7 +92,11 @@ const DATE_AFTER = /^[./-]\d{1,2}[./-]\d{1,2}\b/;
|
|
|
74
92
|
* ISO 4217 codes made only of the consonants a Spanish plate accepts, so a money
|
|
75
93
|
* amount reads as a registration. This is why "2297 DKK" was a plate.
|
|
76
94
|
*/
|
|
77
|
-
|
|
95
|
+
// Crypto tickers are the same shape and the same mistake — every surviving
|
|
96
|
+
// plate false positive was "4499 BTC" or a sibling. A ticker is a unit, exactly
|
|
97
|
+
// as an ISO 4217 code is.
|
|
98
|
+
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
|
+
"|BTC|ETH|LTC|XRP|BCH|XLM|XMR|DOT|SOL|ADA|TRX|DOGE|USDT|USDC)";
|
|
78
100
|
const CURRENCY_CODE_TAIL = new RegExp(`\\s${CURRENCY_CODE}$`);
|
|
79
101
|
const CURRENCY_CODE_AFTER = new RegExp(`^\\s*${CURRENCY_CODE}\\b`);
|
|
80
102
|
/** Log-timestamp tails, which the ":"-anchored secret rule picks up. */
|
|
@@ -88,6 +110,32 @@ const NOT_A_SECRET = new RegExp("^(?:af|ap|ca|cn|eu|il|me|sa|us|gov)-(?:north|so
|
|
|
88
110
|
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}$/;
|
|
89
111
|
const BIC_SHAPE = /^[A-Z]{6}[A-Z0-9]{2}(?:[A-Z0-9]{3})?$/;
|
|
90
112
|
const BIC_CUE_BEFORE = /(?:BIC|SWIFT|BIC\/SWIFT)\s*[:=]?\s*\(?\s*$/i;
|
|
113
|
+
/** An email address, exactly. Same argument as the UUID and the BIC. */
|
|
114
|
+
const EXACT_EMAIL = /^[\w.!#$%&'*+/=?^`{|}~-]+@[\w-]+(?:\.[\w-]+)+$/;
|
|
115
|
+
/**
|
|
116
|
+
* A URL, or the tail of one after the scheme has been split off. The secret
|
|
117
|
+
* rules anchor on ":" and "=", so "https://api.sendgrid.com/v3/mail/send" hands
|
|
118
|
+
* them "//api.sendgrid.com/v3/mail/send" and a slash-separated path scores as
|
|
119
|
+
* high-entropy. An endpoint is published documentation, not a credential.
|
|
120
|
+
*/
|
|
121
|
+
const URL_LIKE = /^(?:[a-z][a-z0-9+.-]*:)?\/\/[^\s/]+(?:\/\S*)?$|^[a-z0-9-]+(?:\.[a-z0-9-]+)*\.[a-z]{2,}(?::\d{1,5})?(?:\/\S*)?$/i;
|
|
122
|
+
/**
|
|
123
|
+
* A URL that *carries* a credential is the opposite case, and it is the common
|
|
124
|
+
* one: "mongodb://admin:DFKDKi1eb51OOhuHPYz@rds-main.eu-west-1.rds.amazonaws.com"
|
|
125
|
+
* is a connection string with a live password in it. Suppressing those as "just
|
|
126
|
+
* a URL" lost 347 real secrets, so the endpoint test must not fire when userinfo
|
|
127
|
+
* or a credential-bearing query parameter is present.
|
|
128
|
+
*/
|
|
129
|
+
const URL_WITH_CREDENTIALS = /^(?:[a-z][a-z0-9+.-]*:)?\/\/[^/@\s]*:[^/@\s]*@|[?&](?:api_?key|access_?token|auth|token|secret|password|pwd|sig|signature|credential)=/i;
|
|
130
|
+
/** An LDAP distinguished name: "cn=github-actions,dc=corp,dc=eu". */
|
|
131
|
+
const LDAP_DN = /^(?:cn|ou|dc|uid|o|l|st|c)=[^,=]+(?:,\s*(?:cn|ou|dc|uid|o|l|st|c)=[^,=]+)+$/i;
|
|
132
|
+
/**
|
|
133
|
+
* Lower-case words joined by hyphens, optionally with a short id suffix:
|
|
134
|
+
* "service-account", "data-lake-33e061". The single-word test below already
|
|
135
|
+
* rejects "sozialversicherungsnummer" on the reasoning that a real secret would
|
|
136
|
+
* be mixed case; a hyphenated compound is the same token with a separator.
|
|
137
|
+
*/
|
|
138
|
+
const HYPHENATED_WORDS = /^[a-z]{2,}(?:-[a-z]{2,})+(?:-[0-9a-f]{4,8})?$/;
|
|
91
139
|
/** A dotted quad with every octet in range. */
|
|
92
140
|
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)$/;
|
|
93
141
|
/** Two plausible years joined by a hyphen — a school year, a contract term. */
|
|
@@ -117,7 +165,7 @@ function suppressSecretOverStructured(text, match) {
|
|
|
117
165
|
if (match.patternDef.entityType !== EntityType.SECRET)
|
|
118
166
|
return false;
|
|
119
167
|
const token = coreToken(match.text);
|
|
120
|
-
if (EXACT_UUID.test(token))
|
|
168
|
+
if (EXACT_UUID.test(token) || EXACT_EMAIL.test(token))
|
|
121
169
|
return true;
|
|
122
170
|
if (BIC_SHAPE.test(token)) {
|
|
123
171
|
return BIC_CUE_BEFORE.test(text.slice(Math.max(0, match.start - 24), match.start));
|
|
@@ -142,6 +190,12 @@ function suppressSecretNotASecret(_text, match) {
|
|
|
142
190
|
return true;
|
|
143
191
|
if (NOT_A_SECRET.test(bare))
|
|
144
192
|
return true;
|
|
193
|
+
if (URL_LIKE.test(bare) && !URL_WITH_CREDENTIALS.test(bare))
|
|
194
|
+
return true;
|
|
195
|
+
if (LDAP_DN.test(bare))
|
|
196
|
+
return true;
|
|
197
|
+
if (HYPHENATED_WORDS.test(bare))
|
|
198
|
+
return true;
|
|
145
199
|
const alpha = /^[A-Za-zÀ-ÿ]+$/.test(bare);
|
|
146
200
|
const oneWord = bare === bare.toLowerCase() ||
|
|
147
201
|
(bare.slice(0, 1) === bare.slice(0, 1).toUpperCase() &&
|
|
@@ -202,15 +256,34 @@ function suppressUnits(text, match) {
|
|
|
202
256
|
const [, after] = getContext(text, match.start, match.end);
|
|
203
257
|
return UNIT_AFTER.test(after);
|
|
204
258
|
}
|
|
259
|
+
/**
|
|
260
|
+
* Suppress numbers preceded by reference/invoice/dossier keywords.
|
|
261
|
+
*
|
|
262
|
+
* POSTAL_CODE belongs here for the same reason every other numeric type does,
|
|
263
|
+
* and its absence was an oversight: a five-digit ticket number sits in exactly
|
|
264
|
+
* the shape a German or French postcode occupies, so "Ticket #94730" and
|
|
265
|
+
* "Incident report IR-43433" were masked as addresses.
|
|
266
|
+
*
|
|
267
|
+
* "#" and "XX-" are checked adjacently rather than through the 150-character
|
|
268
|
+
* keyword window. That window is what made the postal rule claim years in
|
|
269
|
+
* dates; widening its reach to fix a different symptom would repeat the error.
|
|
270
|
+
*/
|
|
271
|
+
// Hoisted out of the suppressor bodies: rebuilding these per candidate cost
|
|
272
|
+
// ~260k Set allocations per 1 MB document, and GC was 10.7% of the profile.
|
|
273
|
+
const REFERENCE_TYPES = new Set([EntityType.PHONE, EntityType.NATIONAL_ID, EntityType.SSN, EntityType.TAX_ID, EntityType.IBAN, EntityType.CHAMBER_OF_COMMERCE, EntityType.POSTAL_CODE]);
|
|
274
|
+
const LEGAL_TYPES = new Set([EntityType.PHONE, EntityType.NATIONAL_ID, EntityType.POSTAL_CODE]);
|
|
205
275
|
function suppressReference(text, match) {
|
|
206
|
-
const applicable =
|
|
276
|
+
const applicable = REFERENCE_TYPES;
|
|
207
277
|
if (!applicable.has(match.patternDef.entityType))
|
|
208
278
|
return false;
|
|
209
279
|
const [before] = getContext(text, match.start, match.end);
|
|
210
|
-
|
|
280
|
+
if (REFERENCE_BEFORE.test(before))
|
|
281
|
+
return true;
|
|
282
|
+
const adjacent = text.slice(Math.max(0, match.start - 8), match.start);
|
|
283
|
+
return HASH_BEFORE.test(adjacent) || REF_PREFIX_BEFORE.test(adjacent);
|
|
211
284
|
}
|
|
212
285
|
function suppressLegal(text, match) {
|
|
213
|
-
const applicable =
|
|
286
|
+
const applicable = LEGAL_TYPES;
|
|
214
287
|
if (!applicable.has(match.patternDef.entityType))
|
|
215
288
|
return false;
|
|
216
289
|
const [before] = getContext(text, match.start, match.end);
|
|
@@ -433,8 +506,15 @@ function enclosingLine(text, start, end) {
|
|
|
433
506
|
*/
|
|
434
507
|
function structuralUnit(text, start, end) {
|
|
435
508
|
const [lineStart, lineEnd] = enclosingLine(text, start, end);
|
|
509
|
+
// Both walks stop as soon as the paragraph is too wide to be used, because
|
|
510
|
+
// everything past that point is discarded by the cap below anyway. Applying
|
|
511
|
+
// the cap only to the *result* meant that on a document with no blank lines
|
|
512
|
+
// each walk ran to the top and bottom of the whole text — O(document) per
|
|
513
|
+
// candidate, O(n^2) overall.
|
|
436
514
|
let paraStart = lineStart;
|
|
437
515
|
while (paraStart > 0) {
|
|
516
|
+
if (lineEnd - paraStart > MAX_UNIT_CHARS)
|
|
517
|
+
return text.slice(lineStart, lineEnd);
|
|
438
518
|
const prevEnd = paraStart - 1;
|
|
439
519
|
const prevStart = text.lastIndexOf("\n", prevEnd - 1) + 1;
|
|
440
520
|
if (text.slice(prevStart, prevEnd).trim() === "")
|
|
@@ -443,6 +523,8 @@ function structuralUnit(text, start, end) {
|
|
|
443
523
|
}
|
|
444
524
|
let paraEnd = lineEnd;
|
|
445
525
|
while (paraEnd < text.length) {
|
|
526
|
+
if (paraEnd - paraStart > MAX_UNIT_CHARS)
|
|
527
|
+
return text.slice(lineStart, lineEnd);
|
|
446
528
|
const nextStart = paraEnd + 1;
|
|
447
529
|
let nextEnd = text.indexOf("\n", nextStart);
|
|
448
530
|
if (nextEnd === -1)
|
|
@@ -479,28 +561,73 @@ function escapeRe(s) {
|
|
|
479
561
|
* heading. Measured on the corpus this alone identifies ~78% of the BIC false
|
|
480
562
|
* positives with no dictionaries and no labelled data.
|
|
481
563
|
*/
|
|
482
|
-
|
|
564
|
+
/**
|
|
565
|
+
* Every all-letter word in the document that is not part of an email address
|
|
566
|
+
* or a domain name.
|
|
567
|
+
*
|
|
568
|
+
* `\b<word>\b` can only match a maximal run of word characters, so collecting
|
|
569
|
+
* the runs once answers the question for every candidate. Re-scanning the whole
|
|
570
|
+
* document per candidate made this quadratic: 1.58 s on a 400 KB document
|
|
571
|
+
* dense in bank codes, against ~1 ms to build this set.
|
|
572
|
+
*/
|
|
573
|
+
function buildDocumentWords(text) {
|
|
574
|
+
const words = new Set();
|
|
575
|
+
const re = /[A-Za-z0-9_]+/g;
|
|
576
|
+
let m;
|
|
577
|
+
while ((m = re.exec(text)) !== null) {
|
|
578
|
+
const word = m[0];
|
|
579
|
+
if (!/^[A-Za-z]+$/.test(word))
|
|
580
|
+
continue;
|
|
581
|
+
const i = m.index;
|
|
582
|
+
const before = i > 0 ? text[i - 1] : "";
|
|
583
|
+
if (before === "@")
|
|
584
|
+
continue;
|
|
585
|
+
if (before === "." && i >= 2 && /[A-Za-z0-9]/.test(text[i - 2]))
|
|
586
|
+
continue;
|
|
587
|
+
const after = text.slice(i + word.length, i + word.length + 8);
|
|
588
|
+
if (after.startsWith("@"))
|
|
589
|
+
continue;
|
|
590
|
+
if (/^\.[a-zA-Z]{2,6}\b/.test(after))
|
|
591
|
+
continue;
|
|
592
|
+
words.add(word);
|
|
593
|
+
}
|
|
594
|
+
return words;
|
|
595
|
+
}
|
|
596
|
+
/**
|
|
597
|
+
* Per-document scratch space for suppressors that would otherwise re-derive the
|
|
598
|
+
* same whole-document facts for every candidate.
|
|
599
|
+
*
|
|
600
|
+
* Built lazily and held only for the duration of one `detect()` call, so no
|
|
601
|
+
* document text outlives the call that supplied it.
|
|
602
|
+
*/
|
|
603
|
+
export class SuppressionScratch {
|
|
604
|
+
text;
|
|
605
|
+
words = null;
|
|
606
|
+
constructor(text) {
|
|
607
|
+
this.text = text;
|
|
608
|
+
}
|
|
609
|
+
documentWords() {
|
|
610
|
+
if (this.words === null)
|
|
611
|
+
this.words = buildDocumentWords(this.text);
|
|
612
|
+
return this.words;
|
|
613
|
+
}
|
|
614
|
+
}
|
|
615
|
+
/**
|
|
616
|
+
* Does `token` also occur as an ordinary lowercase word in this document?
|
|
617
|
+
*
|
|
618
|
+
* `hospital`/`HOSPITAL`, `gegevens`/`GEGEVENS` — a token that appears in
|
|
619
|
+
* ordinary case elsewhere in the same document is a word, not a bank code.
|
|
620
|
+
* Email and domain contexts are excluded, so `ing.nl` does not vouch for a
|
|
621
|
+
* heading. Measured on the corpus this alone identifies ~78% of the BIC false
|
|
622
|
+
* positives with no dictionaries and no labelled data.
|
|
623
|
+
*/
|
|
624
|
+
function occursAsLowercaseWord(text, token, scratch) {
|
|
483
625
|
if (!/^[A-Za-z]+$/.test(token))
|
|
484
626
|
return false;
|
|
485
627
|
const lower = token.toLowerCase();
|
|
486
628
|
const capitalised = lower[0].toUpperCase() + lower.slice(1);
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
if (!text.includes(form))
|
|
490
|
-
continue;
|
|
491
|
-
const re = new RegExp(`\\b${escapeRe(form)}\\b`, "g");
|
|
492
|
-
let m;
|
|
493
|
-
while ((m = re.exec(text)) !== null) {
|
|
494
|
-
const before = m.index > 0 ? text[m.index - 1] : "";
|
|
495
|
-
const after = text.slice(m.index + form.length, m.index + form.length + 8);
|
|
496
|
-
if (before === "@" || (before === "." && m.index >= 2 && /[A-Za-z0-9]/.test(text[m.index - 2])))
|
|
497
|
-
continue;
|
|
498
|
-
if (after.startsWith("@") || /^\.[a-zA-Z]{2,6}\b/.test(after))
|
|
499
|
-
continue;
|
|
500
|
-
return true;
|
|
501
|
-
}
|
|
502
|
-
}
|
|
503
|
-
return false;
|
|
629
|
+
const words = scratch ? scratch.documentWords() : buildDocumentWords(text);
|
|
630
|
+
return words.has(lower) || words.has(capitalised);
|
|
504
631
|
}
|
|
505
632
|
/**
|
|
506
633
|
* Is the candidate positioned as a section heading rather than a value?
|
|
@@ -538,25 +665,29 @@ function isHeadingShape(text, start, end, token) {
|
|
|
538
665
|
* ordinary lowercase word — so it is applied on both emitting paths. It scans
|
|
539
666
|
* the whole document, so it runs only on the paths that would otherwise emit.
|
|
540
667
|
*/
|
|
541
|
-
function suppressBicWithoutEvidence(text, match) {
|
|
668
|
+
function suppressBicWithoutEvidence(text, match, scratch) {
|
|
542
669
|
if (match.patternDef.entityType !== EntityType.BIC)
|
|
543
670
|
return false;
|
|
544
671
|
const token = match.text.trim();
|
|
545
672
|
if (isRegisteredBic(token))
|
|
546
|
-
return occursAsLowercaseWord(text, token);
|
|
673
|
+
return occursAsLowercaseWord(text, token, scratch);
|
|
547
674
|
if (isHeadingShape(text, match.start, match.end, token))
|
|
548
675
|
return true;
|
|
549
676
|
// The token itself is blanked out so it cannot vouch for itself.
|
|
550
677
|
const unit = structuralUnit(text, match.start, match.end).split(token).join(" ".repeat(token.length));
|
|
551
678
|
if (BIC_KEYWORD.test(unit) || IBAN_SHAPE.test(unit) || BANK_BLOCK.test(unit)) {
|
|
552
|
-
return occursAsLowercaseWord(text, token);
|
|
679
|
+
return occursAsLowercaseWord(text, token, scratch);
|
|
553
680
|
}
|
|
554
681
|
return true;
|
|
555
682
|
}
|
|
556
683
|
// ── Postal code: digits belonging to a longer identifier ────────────────
|
|
557
684
|
// An identifier label immediately before the digits. A postal code is never
|
|
558
685
|
// introduced this way; an SVNr, policy number or service number always is.
|
|
559
|
-
|
|
686
|
+
// The `[\w\-]*` prefix is factored out of the five alternatives that share it.
|
|
687
|
+
// Repeated per alternative, the engine retried the same unbounded prefix five
|
|
688
|
+
// times per starting position — the most expensive single regex in the engine
|
|
689
|
+
// at 197 ms per 1 MB document. Same language, 8x faster on the worst case.
|
|
690
|
+
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;
|
|
560
691
|
// An international dialling prefix earlier on the same line, with nothing but
|
|
561
692
|
// number punctuation in between: these digits belong to the phone detector.
|
|
562
693
|
const DIALLING_PREFIX_BEFORE = /\+\d{1,3}[\d\s\-().]*$/;
|
|
@@ -661,20 +792,20 @@ const TYPE_SUPPRESSORS = {
|
|
|
661
792
|
[EntityType.NATIONAL_ID]: [suppressCurrency, suppressUnits, suppressReference, suppressLegal, suppressMath, suppressNatidAsPassport, suppressSeNatidAsOrg],
|
|
662
793
|
[EntityType.SSN]: [suppressCurrency, suppressUnits, suppressReference, suppressMath],
|
|
663
794
|
[EntityType.TAX_ID]: [suppressCurrency, suppressUnits, suppressReference, suppressMath, suppressTaxidAsIpAddress],
|
|
664
|
-
[EntityType.POSTAL_CODE]: [suppressCurrency, suppressUnits, suppressMath, suppressLegal, suppressYearAsPostal, suppressPostalInsideIban, suppressPostalAsHouseNumber, suppressPostalInLongerIdentifier],
|
|
795
|
+
[EntityType.POSTAL_CODE]: [suppressCurrency, suppressUnits, suppressMath, suppressLegal, suppressYearAsPostal, suppressPostalInsideIban, suppressPostalAsHouseNumber, suppressPostalInLongerIdentifier, suppressReference],
|
|
665
796
|
[EntityType.BIC]: [suppressBicWithoutEvidence],
|
|
666
797
|
[EntityType.BANK_ACCOUNT]: [suppressReference],
|
|
667
798
|
[EntityType.LICENSE_PLATE]: [suppressPlateInCompound, suppressDePlateUnknownDistrict, suppressPlateAsCurrencyAmount],
|
|
668
799
|
[EntityType.SECRET]: [suppressSecretOverStructured, suppressSecretNotASecret],
|
|
669
800
|
[EntityType.CHAMBER_OF_COMMERCE]: [suppressReference],
|
|
670
801
|
};
|
|
671
|
-
export function shouldSuppress(text, match) {
|
|
802
|
+
export function shouldSuppress(text, match, scratch) {
|
|
672
803
|
if (suppressSequential(text, match))
|
|
673
804
|
return true;
|
|
674
805
|
const typeSups = TYPE_SUPPRESSORS[match.patternDef.entityType];
|
|
675
806
|
if (typeSups) {
|
|
676
807
|
for (const s of typeSups) {
|
|
677
|
-
if (s(text, match))
|
|
808
|
+
if (s(text, match, scratch))
|
|
678
809
|
return true;
|
|
679
810
|
}
|
|
680
811
|
}
|
|
@@ -17,6 +17,18 @@ export declare function validateVatFr(candidate: string): boolean;
|
|
|
17
17
|
export declare function validateVatLu(candidate: string): boolean;
|
|
18
18
|
export declare function validateGermanTaxId(candidate: string): boolean;
|
|
19
19
|
export declare function validateFrenchNir(candidate: string): boolean;
|
|
20
|
+
/**
|
|
21
|
+
* ISO 3779 VIN validation: 17 characters, no I/O/Q. **Shape only.**
|
|
22
|
+
*
|
|
23
|
+
* The position-9 check digit is mandatory for North American VINs (WMI 1-5)
|
|
24
|
+
* and unused elsewhere, and it is deliberately **not** enforced. Measured over
|
|
25
|
+
* the 152,300-document corpus, enforcing it turned 1,502 labelled VINs into
|
|
26
|
+
* misses — every North American VIN whose check digit does not survive being
|
|
27
|
+
* written down. A VIN that fails its checksum is still a VIN sitting in the
|
|
28
|
+
* text; dropping it leaves it unredacted, which is a leak, while keeping it
|
|
29
|
+
* costs at worst an over-masked 17-character token. Matches the Python SDK's
|
|
30
|
+
* `validate_vin`, which carries the full reasoning.
|
|
31
|
+
*/
|
|
20
32
|
export declare function validateVin(candidate: string): boolean;
|
|
21
33
|
/**
|
|
22
34
|
* BIC/SWIFT structural validation (ISO 9362): BBBB CC LL (PPP).
|
|
@@ -179,6 +179,18 @@ export function validateFrenchNir(candidate) {
|
|
|
179
179
|
const check = parseInt(numeric.slice(13, 15));
|
|
180
180
|
return (97 - (first13 % 97)) === check;
|
|
181
181
|
}
|
|
182
|
+
/**
|
|
183
|
+
* ISO 3779 VIN validation: 17 characters, no I/O/Q. **Shape only.**
|
|
184
|
+
*
|
|
185
|
+
* The position-9 check digit is mandatory for North American VINs (WMI 1-5)
|
|
186
|
+
* and unused elsewhere, and it is deliberately **not** enforced. Measured over
|
|
187
|
+
* the 152,300-document corpus, enforcing it turned 1,502 labelled VINs into
|
|
188
|
+
* misses — every North American VIN whose check digit does not survive being
|
|
189
|
+
* written down. A VIN that fails its checksum is still a VIN sitting in the
|
|
190
|
+
* text; dropping it leaves it unredacted, which is a leak, while keeping it
|
|
191
|
+
* costs at worst an over-masked 17-character token. Matches the Python SDK's
|
|
192
|
+
* `validate_vin`, which carries the full reasoning.
|
|
193
|
+
*/
|
|
182
194
|
export function validateVin(candidate) {
|
|
183
195
|
const c = candidate.replace(/[\s\-]/g, "").toUpperCase();
|
|
184
196
|
if (c.length !== 17)
|
package/dist/esm/sdk.js
CHANGED
|
@@ -4,20 +4,44 @@ import { RuleEngine } from "./rules/engine.js";
|
|
|
4
4
|
import { weightsToRanking } from "./rules/evidence.js";
|
|
5
5
|
import { EntityType } from "./types.js";
|
|
6
6
|
const DATE_TYPES = new Set([EntityType.DOB, EntityType.DATE_OF_DEATH]);
|
|
7
|
+
/** Entry count at which a one-time warning is emitted. */
|
|
8
|
+
const MAPPING_WARN_THRESHOLD = 100_000;
|
|
9
|
+
/**
|
|
10
|
+
* Maps real PII values to consistent labels within a session.
|
|
11
|
+
*
|
|
12
|
+
* The mapping is keyed on the **raw PII value** and is never evicted — evicting
|
|
13
|
+
* would hand a previously seen value a second label and quietly break
|
|
14
|
+
* referential integrity. Two consequences worth designing around:
|
|
15
|
+
*
|
|
16
|
+
* - It grows for as long as the process runs. Call `clear()` between workloads;
|
|
17
|
+
* a warning is emitted once the mapping passes `MAPPING_WARN_THRESHOLD`.
|
|
18
|
+
* - Labels are shared by every caller of the same instance, including every
|
|
19
|
+
* caller of the module-level `redact()`. A label repeated across two
|
|
20
|
+
* documents reveals that they contain the same underlying value, so give each
|
|
21
|
+
* tenant its own `EuRedact` instance rather than sharing the module-level one.
|
|
22
|
+
*/
|
|
7
23
|
class ReferentialMapper {
|
|
8
24
|
counters = new Map();
|
|
9
25
|
mapping = new Map();
|
|
26
|
+
warned = false;
|
|
10
27
|
getLabel(text, entityType) {
|
|
11
28
|
if (!this.mapping.has(text)) {
|
|
12
29
|
const count = (this.counters.get(entityType) ?? 0) + 1;
|
|
13
30
|
this.counters.set(entityType, count);
|
|
14
31
|
this.mapping.set(text, `${entityType}_${count}`);
|
|
32
|
+
if (!this.warned && this.mapping.size > MAPPING_WARN_THRESHOLD) {
|
|
33
|
+
this.warned = true;
|
|
34
|
+
console.warn(`[euredact] Referential integrity mapping holds ${this.mapping.size} ` +
|
|
35
|
+
`raw PII values and is never evicted. Call clear() between workloads ` +
|
|
36
|
+
`to release them.`);
|
|
37
|
+
}
|
|
15
38
|
}
|
|
16
39
|
return this.mapping.get(text);
|
|
17
40
|
}
|
|
18
41
|
clear() {
|
|
19
42
|
this.counters.clear();
|
|
20
43
|
this.mapping.clear();
|
|
44
|
+
this.warned = false;
|
|
21
45
|
}
|
|
22
46
|
}
|
|
23
47
|
const DEFAULT_MAX_INPUT_LENGTH = 10_485_760; // ~10 MB of text
|
|
@@ -107,14 +131,32 @@ export class EuRedact {
|
|
|
107
131
|
detections = detections.filter(d => !DATE_TYPES.has(d.entityType));
|
|
108
132
|
}
|
|
109
133
|
detections.sort((a, b) => a.start - b.start || b.end - a.end);
|
|
110
|
-
|
|
134
|
+
// Labels are resolved right-to-left because the referential mapper numbers
|
|
135
|
+
// each entity type in call order, and that order is part of the output
|
|
136
|
+
// contract. The string itself is then assembled in a single forward pass:
|
|
137
|
+
// rebuilding it per detection copied the whole document each time, which is
|
|
138
|
+
// O(document x detections) — 1.68 s of pure copying on a 1 MB document with
|
|
139
|
+
// 15,000 detections, versus 2 ms here.
|
|
140
|
+
const replacements = new Array(detections.length);
|
|
111
141
|
for (let i = detections.length - 1; i >= 0; i--) {
|
|
112
142
|
const det = detections[i];
|
|
113
|
-
|
|
143
|
+
replacements[i] = referentialIntegrity
|
|
114
144
|
? this.referentialMapper.getLabel(det.text, det.entityType)
|
|
115
145
|
: `[${det.entityType}]`;
|
|
116
|
-
redacted = redacted.slice(0, det.start) + replacement + redacted.slice(det.end);
|
|
117
146
|
}
|
|
147
|
+
const parts = [];
|
|
148
|
+
let pos = 0;
|
|
149
|
+
for (let i = 0; i < detections.length; i++) {
|
|
150
|
+
const det = detections[i];
|
|
151
|
+
// Spans reach here deduplicated and non-overlapping; one starting behind
|
|
152
|
+
// the cursor would splice into the middle of a label, so drop it.
|
|
153
|
+
if (det.start < pos)
|
|
154
|
+
continue;
|
|
155
|
+
parts.push(text.slice(pos, det.start), replacements[i]);
|
|
156
|
+
pos = det.end;
|
|
157
|
+
}
|
|
158
|
+
parts.push(text.slice(pos));
|
|
159
|
+
const redacted = parts.join("");
|
|
118
160
|
// Report the inference so it can be audited. Spans in `evidence` are
|
|
119
161
|
// offsets into the normalised text, matching `detections`.
|
|
120
162
|
const inferredCountries = [...weightsToRanking(scores).entries()]
|
package/package.json
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "euredact",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.7",
|
|
4
4
|
"description": "European PII redaction SDK -- fast, zero-dependency, rule-based detection and redaction of personal data across 31 European countries",
|
|
5
|
+
"homepage": "https://euredact.dev",
|
|
5
6
|
"license": "Apache-2.0",
|
|
6
7
|
"type": "module",
|
|
7
8
|
"main": "./dist/cjs/index.js",
|
|
@@ -64,7 +65,11 @@
|
|
|
64
65
|
"dump": "node --import tsx src/__tests__/dump.ts"
|
|
65
66
|
},
|
|
66
67
|
"devDependencies": {
|
|
67
|
-
"
|
|
68
|
-
"tsx": "^4.7.0"
|
|
68
|
+
"@types/node": "22.10.2",
|
|
69
|
+
"tsx": "^4.7.0",
|
|
70
|
+
"typescript": "^5.4.0"
|
|
71
|
+
},
|
|
72
|
+
"bugs": {
|
|
73
|
+
"url": "https://github.com/euredact/euredact/issues"
|
|
69
74
|
}
|
|
70
75
|
}
|