euredact 0.3.6 → 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 +90 -26
- 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 +88 -26
- 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 +4 -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");
|
|
@@ -271,8 +272,12 @@ function suppressUnits(text, match) {
|
|
|
271
272
|
* keyword window. That window is what made the postal rule claim years in
|
|
272
273
|
* dates; widening its reach to fix a different symptom would repeat the error.
|
|
273
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]);
|
|
274
279
|
function suppressReference(text, match) {
|
|
275
|
-
const applicable =
|
|
280
|
+
const applicable = REFERENCE_TYPES;
|
|
276
281
|
if (!applicable.has(match.patternDef.entityType))
|
|
277
282
|
return false;
|
|
278
283
|
const [before] = getContext(text, match.start, match.end);
|
|
@@ -282,7 +287,7 @@ function suppressReference(text, match) {
|
|
|
282
287
|
return HASH_BEFORE.test(adjacent) || REF_PREFIX_BEFORE.test(adjacent);
|
|
283
288
|
}
|
|
284
289
|
function suppressLegal(text, match) {
|
|
285
|
-
const applicable =
|
|
290
|
+
const applicable = LEGAL_TYPES;
|
|
286
291
|
if (!applicable.has(match.patternDef.entityType))
|
|
287
292
|
return false;
|
|
288
293
|
const [before] = getContext(text, match.start, match.end);
|
|
@@ -505,8 +510,15 @@ function enclosingLine(text, start, end) {
|
|
|
505
510
|
*/
|
|
506
511
|
function structuralUnit(text, start, end) {
|
|
507
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.
|
|
508
518
|
let paraStart = lineStart;
|
|
509
519
|
while (paraStart > 0) {
|
|
520
|
+
if (lineEnd - paraStart > MAX_UNIT_CHARS)
|
|
521
|
+
return text.slice(lineStart, lineEnd);
|
|
510
522
|
const prevEnd = paraStart - 1;
|
|
511
523
|
const prevStart = text.lastIndexOf("\n", prevEnd - 1) + 1;
|
|
512
524
|
if (text.slice(prevStart, prevEnd).trim() === "")
|
|
@@ -515,6 +527,8 @@ function structuralUnit(text, start, end) {
|
|
|
515
527
|
}
|
|
516
528
|
let paraEnd = lineEnd;
|
|
517
529
|
while (paraEnd < text.length) {
|
|
530
|
+
if (paraEnd - paraStart > MAX_UNIT_CHARS)
|
|
531
|
+
return text.slice(lineStart, lineEnd);
|
|
518
532
|
const nextStart = paraEnd + 1;
|
|
519
533
|
let nextEnd = text.indexOf("\n", nextStart);
|
|
520
534
|
if (nextEnd === -1)
|
|
@@ -551,28 +565,74 @@ function escapeRe(s) {
|
|
|
551
565
|
* heading. Measured on the corpus this alone identifies ~78% of the BIC false
|
|
552
566
|
* positives with no dictionaries and no labelled data.
|
|
553
567
|
*/
|
|
554
|
-
|
|
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) {
|
|
555
630
|
if (!/^[A-Za-z]+$/.test(token))
|
|
556
631
|
return false;
|
|
557
632
|
const lower = token.toLowerCase();
|
|
558
633
|
const capitalised = lower[0].toUpperCase() + lower.slice(1);
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
if (!text.includes(form))
|
|
562
|
-
continue;
|
|
563
|
-
const re = new RegExp(`\\b${escapeRe(form)}\\b`, "g");
|
|
564
|
-
let m;
|
|
565
|
-
while ((m = re.exec(text)) !== null) {
|
|
566
|
-
const before = m.index > 0 ? text[m.index - 1] : "";
|
|
567
|
-
const after = text.slice(m.index + form.length, m.index + form.length + 8);
|
|
568
|
-
if (before === "@" || (before === "." && m.index >= 2 && /[A-Za-z0-9]/.test(text[m.index - 2])))
|
|
569
|
-
continue;
|
|
570
|
-
if (after.startsWith("@") || /^\.[a-zA-Z]{2,6}\b/.test(after))
|
|
571
|
-
continue;
|
|
572
|
-
return true;
|
|
573
|
-
}
|
|
574
|
-
}
|
|
575
|
-
return false;
|
|
634
|
+
const words = scratch ? scratch.documentWords() : buildDocumentWords(text);
|
|
635
|
+
return words.has(lower) || words.has(capitalised);
|
|
576
636
|
}
|
|
577
637
|
/**
|
|
578
638
|
* Is the candidate positioned as a section heading rather than a value?
|
|
@@ -610,25 +670,29 @@ function isHeadingShape(text, start, end, token) {
|
|
|
610
670
|
* ordinary lowercase word — so it is applied on both emitting paths. It scans
|
|
611
671
|
* the whole document, so it runs only on the paths that would otherwise emit.
|
|
612
672
|
*/
|
|
613
|
-
function suppressBicWithoutEvidence(text, match) {
|
|
673
|
+
function suppressBicWithoutEvidence(text, match, scratch) {
|
|
614
674
|
if (match.patternDef.entityType !== types_js_1.EntityType.BIC)
|
|
615
675
|
return false;
|
|
616
676
|
const token = match.text.trim();
|
|
617
677
|
if ((0, bicRegistry_js_1.isRegisteredBic)(token))
|
|
618
|
-
return occursAsLowercaseWord(text, token);
|
|
678
|
+
return occursAsLowercaseWord(text, token, scratch);
|
|
619
679
|
if (isHeadingShape(text, match.start, match.end, token))
|
|
620
680
|
return true;
|
|
621
681
|
// The token itself is blanked out so it cannot vouch for itself.
|
|
622
682
|
const unit = structuralUnit(text, match.start, match.end).split(token).join(" ".repeat(token.length));
|
|
623
683
|
if (BIC_KEYWORD.test(unit) || IBAN_SHAPE.test(unit) || BANK_BLOCK.test(unit)) {
|
|
624
|
-
return occursAsLowercaseWord(text, token);
|
|
684
|
+
return occursAsLowercaseWord(text, token, scratch);
|
|
625
685
|
}
|
|
626
686
|
return true;
|
|
627
687
|
}
|
|
628
688
|
// ── Postal code: digits belonging to a longer identifier ────────────────
|
|
629
689
|
// An identifier label immediately before the digits. A postal code is never
|
|
630
690
|
// introduced this way; an SVNr, policy number or service number always is.
|
|
631
|
-
|
|
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;
|
|
632
696
|
// An international dialling prefix earlier on the same line, with nothing but
|
|
633
697
|
// number punctuation in between: these digits belong to the phone detector.
|
|
634
698
|
const DIALLING_PREFIX_BEFORE = /\+\d{1,3}[\d\s\-().]*$/;
|
|
@@ -740,13 +804,13 @@ const TYPE_SUPPRESSORS = {
|
|
|
740
804
|
[types_js_1.EntityType.SECRET]: [suppressSecretOverStructured, suppressSecretNotASecret],
|
|
741
805
|
[types_js_1.EntityType.CHAMBER_OF_COMMERCE]: [suppressReference],
|
|
742
806
|
};
|
|
743
|
-
function shouldSuppress(text, match) {
|
|
807
|
+
function shouldSuppress(text, match, scratch) {
|
|
744
808
|
if (suppressSequential(text, match))
|
|
745
809
|
return true;
|
|
746
810
|
const typeSups = TYPE_SUPPRESSORS[match.patternDef.entityType];
|
|
747
811
|
if (typeSups) {
|
|
748
812
|
for (const s of typeSups) {
|
|
749
|
-
if (s(text, match))
|
|
813
|
+
if (s(text, match, scratch))
|
|
750
814
|
return true;
|
|
751
815
|
}
|
|
752
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)
|
package/dist/cjs/sdk.js
CHANGED
|
@@ -7,20 +7,44 @@ const engine_js_1 = require("./rules/engine.js");
|
|
|
7
7
|
const evidence_js_1 = require("./rules/evidence.js");
|
|
8
8
|
const types_js_1 = require("./types.js");
|
|
9
9
|
const DATE_TYPES = new Set([types_js_1.EntityType.DOB, types_js_1.EntityType.DATE_OF_DEATH]);
|
|
10
|
+
/** Entry count at which a one-time warning is emitted. */
|
|
11
|
+
const MAPPING_WARN_THRESHOLD = 100_000;
|
|
12
|
+
/**
|
|
13
|
+
* Maps real PII values to consistent labels within a session.
|
|
14
|
+
*
|
|
15
|
+
* The mapping is keyed on the **raw PII value** and is never evicted — evicting
|
|
16
|
+
* would hand a previously seen value a second label and quietly break
|
|
17
|
+
* referential integrity. Two consequences worth designing around:
|
|
18
|
+
*
|
|
19
|
+
* - It grows for as long as the process runs. Call `clear()` between workloads;
|
|
20
|
+
* a warning is emitted once the mapping passes `MAPPING_WARN_THRESHOLD`.
|
|
21
|
+
* - Labels are shared by every caller of the same instance, including every
|
|
22
|
+
* caller of the module-level `redact()`. A label repeated across two
|
|
23
|
+
* documents reveals that they contain the same underlying value, so give each
|
|
24
|
+
* tenant its own `EuRedact` instance rather than sharing the module-level one.
|
|
25
|
+
*/
|
|
10
26
|
class ReferentialMapper {
|
|
11
27
|
counters = new Map();
|
|
12
28
|
mapping = new Map();
|
|
29
|
+
warned = false;
|
|
13
30
|
getLabel(text, entityType) {
|
|
14
31
|
if (!this.mapping.has(text)) {
|
|
15
32
|
const count = (this.counters.get(entityType) ?? 0) + 1;
|
|
16
33
|
this.counters.set(entityType, count);
|
|
17
34
|
this.mapping.set(text, `${entityType}_${count}`);
|
|
35
|
+
if (!this.warned && this.mapping.size > MAPPING_WARN_THRESHOLD) {
|
|
36
|
+
this.warned = true;
|
|
37
|
+
console.warn(`[euredact] Referential integrity mapping holds ${this.mapping.size} ` +
|
|
38
|
+
`raw PII values and is never evicted. Call clear() between workloads ` +
|
|
39
|
+
`to release them.`);
|
|
40
|
+
}
|
|
18
41
|
}
|
|
19
42
|
return this.mapping.get(text);
|
|
20
43
|
}
|
|
21
44
|
clear() {
|
|
22
45
|
this.counters.clear();
|
|
23
46
|
this.mapping.clear();
|
|
47
|
+
this.warned = false;
|
|
24
48
|
}
|
|
25
49
|
}
|
|
26
50
|
const DEFAULT_MAX_INPUT_LENGTH = 10_485_760; // ~10 MB of text
|
|
@@ -110,14 +134,32 @@ class EuRedact {
|
|
|
110
134
|
detections = detections.filter(d => !DATE_TYPES.has(d.entityType));
|
|
111
135
|
}
|
|
112
136
|
detections.sort((a, b) => a.start - b.start || b.end - a.end);
|
|
113
|
-
|
|
137
|
+
// Labels are resolved right-to-left because the referential mapper numbers
|
|
138
|
+
// each entity type in call order, and that order is part of the output
|
|
139
|
+
// contract. The string itself is then assembled in a single forward pass:
|
|
140
|
+
// rebuilding it per detection copied the whole document each time, which is
|
|
141
|
+
// O(document x detections) — 1.68 s of pure copying on a 1 MB document with
|
|
142
|
+
// 15,000 detections, versus 2 ms here.
|
|
143
|
+
const replacements = new Array(detections.length);
|
|
114
144
|
for (let i = detections.length - 1; i >= 0; i--) {
|
|
115
145
|
const det = detections[i];
|
|
116
|
-
|
|
146
|
+
replacements[i] = referentialIntegrity
|
|
117
147
|
? this.referentialMapper.getLabel(det.text, det.entityType)
|
|
118
148
|
: `[${det.entityType}]`;
|
|
119
|
-
redacted = redacted.slice(0, det.start) + replacement + redacted.slice(det.end);
|
|
120
149
|
}
|
|
150
|
+
const parts = [];
|
|
151
|
+
let pos = 0;
|
|
152
|
+
for (let i = 0; i < detections.length; i++) {
|
|
153
|
+
const det = detections[i];
|
|
154
|
+
// Spans reach here deduplicated and non-overlapping; one starting behind
|
|
155
|
+
// the cursor would splice into the middle of a label, so drop it.
|
|
156
|
+
if (det.start < pos)
|
|
157
|
+
continue;
|
|
158
|
+
parts.push(text.slice(pos, det.start), replacements[i]);
|
|
159
|
+
pos = det.end;
|
|
160
|
+
}
|
|
161
|
+
parts.push(text.slice(pos));
|
|
162
|
+
const redacted = parts.join("");
|
|
121
163
|
// Report the inference so it can be audited. Spans in `evidence` are
|
|
122
164
|
// offsets into the normalised text, matching `detections`.
|
|
123
165
|
const inferredCountries = [...(0, evidence_js_1.weightsToRanking)(scores).entries()]
|
package/dist/esm/cache.d.ts
CHANGED
|
@@ -1,11 +1,15 @@
|
|
|
1
1
|
import type { RedactResult } from "./types.js";
|
|
2
2
|
export declare class ResultCache {
|
|
3
3
|
private maxsize;
|
|
4
|
+
private maxChars;
|
|
4
5
|
private enabled;
|
|
5
6
|
private store;
|
|
6
|
-
|
|
7
|
+
private sizes;
|
|
8
|
+
private chars;
|
|
9
|
+
constructor(maxsize?: number, enabled?: boolean, maxChars?: number);
|
|
7
10
|
key(text: string, countries: string[], mode: string): string;
|
|
8
11
|
get(key: string): RedactResult | null;
|
|
9
12
|
put(key: string, result: RedactResult): void;
|
|
13
|
+
private evict;
|
|
10
14
|
clear(): void;
|
|
11
15
|
}
|
package/dist/esm/cache.js
CHANGED
|
@@ -1,27 +1,41 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
// Default ceiling on retained characters, not entries. A count-only cap of
|
|
3
|
+
// 1024 entries says nothing about memory: 1024 cached 1 MB documents is a
|
|
4
|
+
// gigabyte of PII-bearing text held live. ~16M characters is a few tens of MB.
|
|
5
|
+
const DEFAULT_MAX_CHARS = 16_000_000;
|
|
6
|
+
/** Approximate retained size of a cached result, in characters. */
|
|
7
|
+
function resultChars(result) {
|
|
8
|
+
let chars = result.redactedText.length;
|
|
9
|
+
for (const d of result.detections)
|
|
10
|
+
chars += d.text.length;
|
|
11
|
+
return chars;
|
|
9
12
|
}
|
|
10
13
|
export class ResultCache {
|
|
11
14
|
maxsize;
|
|
15
|
+
maxChars;
|
|
12
16
|
enabled;
|
|
13
17
|
store = new Map();
|
|
14
|
-
|
|
18
|
+
sizes = new Map();
|
|
19
|
+
chars = 0;
|
|
20
|
+
constructor(maxsize = 1024, enabled = true, maxChars = DEFAULT_MAX_CHARS) {
|
|
15
21
|
this.maxsize = maxsize;
|
|
22
|
+
this.maxChars = maxChars;
|
|
16
23
|
this.enabled = enabled;
|
|
17
24
|
}
|
|
18
25
|
key(text, countries, mode) {
|
|
19
26
|
const sorted = [...countries].sort();
|
|
20
|
-
|
|
21
|
-
//
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
27
|
+
// SHA-256, matching the Python implementation. The previous dual FNV-1a
|
|
28
|
+
// was non-cryptographic and collidable by construction: in a shared-process
|
|
29
|
+
// deployment an attacker who can guess a victim's document could submit a
|
|
30
|
+
// same-length colliding one and be served the victim's cached result,
|
|
31
|
+
// detections and raw matched PII included.
|
|
32
|
+
return createHash("sha256")
|
|
33
|
+
.update(text)
|
|
34
|
+
.update("|")
|
|
35
|
+
.update(sorted.join("|"))
|
|
36
|
+
.update("|")
|
|
37
|
+
.update(mode)
|
|
38
|
+
.digest("hex");
|
|
25
39
|
}
|
|
26
40
|
get(key) {
|
|
27
41
|
if (!this.enabled)
|
|
@@ -38,15 +52,35 @@ export class ResultCache {
|
|
|
38
52
|
put(key, result) {
|
|
39
53
|
if (!this.enabled)
|
|
40
54
|
return;
|
|
41
|
-
|
|
55
|
+
const size = resultChars(result);
|
|
56
|
+
// A single result bigger than the whole budget would evict everything else
|
|
57
|
+
// and still not fit; skip it rather than empty the cache for one document.
|
|
58
|
+
if (size > this.maxChars) {
|
|
59
|
+
this.evict(key);
|
|
60
|
+
return;
|
|
61
|
+
}
|
|
62
|
+
this.evict(key);
|
|
42
63
|
this.store.set(key, result);
|
|
43
|
-
|
|
64
|
+
this.sizes.set(key, size);
|
|
65
|
+
this.chars += size;
|
|
66
|
+
while (this.store.size > this.maxsize || this.chars > this.maxChars) {
|
|
44
67
|
const first = this.store.keys().next().value;
|
|
45
|
-
if (first
|
|
46
|
-
|
|
68
|
+
if (first === undefined)
|
|
69
|
+
break;
|
|
70
|
+
this.evict(first);
|
|
47
71
|
}
|
|
48
72
|
}
|
|
73
|
+
evict(key) {
|
|
74
|
+
const size = this.sizes.get(key);
|
|
75
|
+
if (size !== undefined) {
|
|
76
|
+
this.chars -= size;
|
|
77
|
+
this.sizes.delete(key);
|
|
78
|
+
}
|
|
79
|
+
this.store.delete(key);
|
|
80
|
+
}
|
|
49
81
|
clear() {
|
|
50
82
|
this.store.clear();
|
|
83
|
+
this.sizes.clear();
|
|
84
|
+
this.chars = 0;
|
|
51
85
|
}
|
|
52
86
|
}
|
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
* contain no IBAN, no `+CC` number and no ccTLD infers nothing at all, even
|
|
7
7
|
* when the first page identified the document beyond doubt:
|
|
8
8
|
*
|
|
9
|
-
* page 1 "Factuur — IBAN NL91ABNA0417164300, info@
|
|
9
|
+
* page 1 "Factuur — IBAN NL91ABNA0417164300, info@example.nl"
|
|
10
10
|
* page 7 "Telefoon 0612345678" <- Dutch mobile, or Danish CPR?
|
|
11
11
|
*
|
|
12
12
|
* Scanned alone, page 7 has nothing to go on. Sharing a context across the two
|
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
* contain no IBAN, no `+CC` number and no ccTLD infers nothing at all, even
|
|
7
7
|
* when the first page identified the document beyond doubt:
|
|
8
8
|
*
|
|
9
|
-
* page 1 "Factuur — IBAN NL91ABNA0417164300, info@
|
|
9
|
+
* page 1 "Factuur — IBAN NL91ABNA0417164300, info@example.nl"
|
|
10
10
|
* page 7 "Telefoon 0612345678" <- Dutch mobile, or Danish CPR?
|
|
11
11
|
*
|
|
12
12
|
* Scanned alone, page 7 has nothing to go on. Sharing a context across the two
|
|
@@ -157,7 +157,20 @@ const SHARED = {
|
|
|
157
157
|
// --- Secret / API Key (assignment-based: KEY=value or KEY: value) ---
|
|
158
158
|
p(EntityType.SECRET, String.raw `(?<=[:=] )\S{8,}|(?<=[:=])\S{8,}`, "high_entropy", "Assigned secret value", SECRET_CONTEXT, true),
|
|
159
159
|
// --- Secret / API Key (entropy-based fallback for longer tokens) ---
|
|
160
|
-
|
|
160
|
+
// Only the *trailing* \b is replaced. It could not match when the token run
|
|
161
|
+
// ended on "-", "+" or "/", so the engine backtracked across two
|
|
162
|
+
// overlapping quantified classes: an ordinary dashed rule line ("x-----...")
|
|
163
|
+
// cost O(n^2), 8.3 s for 80 KB. The lookahead simply asserts the run is
|
|
164
|
+
// over, which is what \b was there to express, and it cannot fail after a
|
|
165
|
+
// maximal run — so there is nothing to backtrack into.
|
|
166
|
+
//
|
|
167
|
+
// The leading \b stays. A class lookbehind there widened the span leftwards
|
|
168
|
+
// over any "/" in front, so an authorized_keys entry started "//...",
|
|
169
|
+
// matched the protocol-relative URL shape in the endpoint suppressor, and
|
|
170
|
+
// was dropped — leaving SSH key material in the clear. Base64 padding is
|
|
171
|
+
// now inside the span instead of trailing outside it; that is the only
|
|
172
|
+
// intended difference.
|
|
173
|
+
p(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),
|
|
161
174
|
p(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),
|
|
162
175
|
p(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),
|
|
163
176
|
p(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/esm/rules/engine.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { DetectionSource, EntityType } from "../types.js";
|
|
2
2
|
import { MultiPatternMatcher } from "./matchers.js";
|
|
3
|
-
import { shouldSuppress } from "./suppressors.js";
|
|
3
|
+
import { shouldSuppress, SuppressionScratch } from "./suppressors.js";
|
|
4
4
|
import { detectStructuralDob } from "./structural.js";
|
|
5
5
|
import { COUNTRY_CONFIGS } from "./countries/index.js";
|
|
6
6
|
import * as evidenceMod from "./evidence.js";
|
|
@@ -273,6 +273,14 @@ export class RuleEngine {
|
|
|
273
273
|
const allFailed = failedSpans
|
|
274
274
|
.map(f => [f.start, f.end])
|
|
275
275
|
.sort((a, b) => a[0] - b[0] || a[1] - b[1]);
|
|
276
|
+
// Running maximum of the end offsets, so the fragment test below can ask
|
|
277
|
+
// "does any span starting at or before me reach past my end?" in O(1)
|
|
278
|
+
// instead of walking the prefix per candidate.
|
|
279
|
+
const failedStarts = allFailed.map(f => f[0]);
|
|
280
|
+
const failedPrefixMax = new Array(allFailed.length + 1).fill(0);
|
|
281
|
+
for (let i = 0; i < allFailed.length; i++) {
|
|
282
|
+
failedPrefixMax[i + 1] = Math.max(failedPrefixMax[i], allFailed[i][1]);
|
|
283
|
+
}
|
|
276
284
|
const mergedZones = new Map();
|
|
277
285
|
for (const [etype, spans] of zonesByType) {
|
|
278
286
|
spans.sort((a, b) => a[0] - b[0] || a[1] - b[1]);
|
|
@@ -295,8 +303,11 @@ export class RuleEngine {
|
|
|
295
303
|
mergedZones.set(etype, { starts, ends });
|
|
296
304
|
}
|
|
297
305
|
const candidates = [];
|
|
306
|
+
// Whole-document facts a suppressor may need, derived at most once per call
|
|
307
|
+
// and discarded with it.
|
|
308
|
+
const scratch = new SuppressionScratch(text);
|
|
298
309
|
for (const { match, hasValidValidator } of validated) {
|
|
299
|
-
if (shouldSuppress(text, match))
|
|
310
|
+
if (shouldSuppress(text, match, scratch))
|
|
300
311
|
continue;
|
|
301
312
|
// A validator-less match inside a failed-validation span of its own type
|
|
302
313
|
// is demoted below every other candidate rather than deleted. Demotion
|
|
@@ -324,13 +335,31 @@ export class RuleEngine {
|
|
|
324
335
|
// default — the generic phone pattern kept claiming the tail of the
|
|
325
336
|
// rejected national number.
|
|
326
337
|
if (match.patternDef.validator === null && !demoted) {
|
|
338
|
+
// Upper bound of the spans starting at or before match.start.
|
|
339
|
+
let lo = 0, hi = failedStarts.length;
|
|
340
|
+
while (lo < hi) {
|
|
341
|
+
const mid = (lo + hi) >>> 1;
|
|
342
|
+
if (failedStarts[mid] <= match.start)
|
|
343
|
+
lo = mid + 1;
|
|
344
|
+
else
|
|
345
|
+
hi = mid;
|
|
346
|
+
}
|
|
327
347
|
let fragment = false;
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
348
|
+
const reach = failedPrefixMax[lo];
|
|
349
|
+
if (reach > match.end) {
|
|
350
|
+
// Something starting no later than us ends strictly later: we are a
|
|
351
|
+
// proper subset of it.
|
|
352
|
+
fragment = true;
|
|
353
|
+
}
|
|
354
|
+
else if (reach === match.end) {
|
|
355
|
+
// Same end offset — a proper subset only if some span with that end
|
|
356
|
+
// starts strictly earlier. Scanning backwards finds the enclosing
|
|
357
|
+
// span first, and the equal case is rare.
|
|
358
|
+
for (let i = lo - 1; i >= 0; i--) {
|
|
359
|
+
if (allFailed[i][1] === match.end && allFailed[i][0] < match.start) {
|
|
360
|
+
fragment = true;
|
|
361
|
+
break;
|
|
362
|
+
}
|
|
334
363
|
}
|
|
335
364
|
}
|
|
336
365
|
if (fragment)
|
|
@@ -381,6 +410,7 @@ export class RuleEngine {
|
|
|
381
410
|
length: match.end - match.start,
|
|
382
411
|
countryScore: countryScores.get(match.countryCode) ?? 0,
|
|
383
412
|
inScope: declared === null || declared.has(match.countryCode) ? 1 : 0,
|
|
413
|
+
tier: 0,
|
|
384
414
|
det: {
|
|
385
415
|
entityType: match.patternDef.entityType,
|
|
386
416
|
start: match.start,
|
|
@@ -395,7 +425,7 @@ export class RuleEngine {
|
|
|
395
425
|
}
|
|
396
426
|
for (const d of detectStructuralDob(text)) {
|
|
397
427
|
candidates.push({ blindPriority: 1, cue: 0, priority: 1, length: d.end - d.start,
|
|
398
|
-
countryScore: 0, inScope: 1, det: d });
|
|
428
|
+
countryScore: 0, inScope: 1, tier: 0, det: d });
|
|
399
429
|
}
|
|
400
430
|
const detections = this.deduplicate(candidates);
|
|
401
431
|
detections.sort((a, b) => a.start - b.start || b.end - a.end);
|
|
@@ -429,7 +459,10 @@ export class RuleEngine {
|
|
|
429
459
|
if (seen === undefined || c.blindPriority > seen)
|
|
430
460
|
spanTier.set(key, c.blindPriority);
|
|
431
461
|
}
|
|
432
|
-
|
|
462
|
+
// Resolved once per candidate rather than inside the comparator, which ran
|
|
463
|
+
// it O(n log n) times and rebuilt the key string on every comparison.
|
|
464
|
+
for (const c of candidates)
|
|
465
|
+
c.tier = spanTier.get(`${c.det.start}:${c.det.end}`);
|
|
433
466
|
// Span length outranks *everything*, including validation.
|
|
434
467
|
//
|
|
435
468
|
// A shorter candidate winning re-cuts a longer one and leaves the
|
|
@@ -455,7 +488,7 @@ export class RuleEngine {
|
|
|
455
488
|
// country-blind up to and including `start`, and (length, start) already
|
|
456
489
|
// identifies a span uniquely.
|
|
457
490
|
const sorted = [...candidates].sort((a, b) => b.length - a.length || // longest first } country-blind
|
|
458
|
-
|
|
491
|
+
b.tier - a.tier || // best tier here } — decides
|
|
459
492
|
a.det.start - b.det.start || // leftmost } WHICH span
|
|
460
493
|
b.cue - a.cue || // local cue } country-aware
|
|
461
494
|
b.priority - a.priority || // country priority } — decides the
|
|
@@ -6,4 +6,17 @@ export interface RawMatch {
|
|
|
6
6
|
patternDef: PatternDef;
|
|
7
7
|
countryCode: string;
|
|
8
8
|
}
|
|
9
|
-
|
|
9
|
+
/**
|
|
10
|
+
* Per-document scratch space for suppressors that would otherwise re-derive the
|
|
11
|
+
* same whole-document facts for every candidate.
|
|
12
|
+
*
|
|
13
|
+
* Built lazily and held only for the duration of one `detect()` call, so no
|
|
14
|
+
* document text outlives the call that supplied it.
|
|
15
|
+
*/
|
|
16
|
+
export declare class SuppressionScratch {
|
|
17
|
+
private readonly text;
|
|
18
|
+
private words;
|
|
19
|
+
constructor(text: string);
|
|
20
|
+
documentWords(): Set<string>;
|
|
21
|
+
}
|
|
22
|
+
export declare function shouldSuppress(text: string, match: RawMatch, scratch?: SuppressionScratch): boolean;
|
|
@@ -268,8 +268,12 @@ function suppressUnits(text, match) {
|
|
|
268
268
|
* keyword window. That window is what made the postal rule claim years in
|
|
269
269
|
* dates; widening its reach to fix a different symptom would repeat the error.
|
|
270
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]);
|
|
271
275
|
function suppressReference(text, match) {
|
|
272
|
-
const applicable =
|
|
276
|
+
const applicable = REFERENCE_TYPES;
|
|
273
277
|
if (!applicable.has(match.patternDef.entityType))
|
|
274
278
|
return false;
|
|
275
279
|
const [before] = getContext(text, match.start, match.end);
|
|
@@ -279,7 +283,7 @@ function suppressReference(text, match) {
|
|
|
279
283
|
return HASH_BEFORE.test(adjacent) || REF_PREFIX_BEFORE.test(adjacent);
|
|
280
284
|
}
|
|
281
285
|
function suppressLegal(text, match) {
|
|
282
|
-
const applicable =
|
|
286
|
+
const applicable = LEGAL_TYPES;
|
|
283
287
|
if (!applicable.has(match.patternDef.entityType))
|
|
284
288
|
return false;
|
|
285
289
|
const [before] = getContext(text, match.start, match.end);
|
|
@@ -502,8 +506,15 @@ function enclosingLine(text, start, end) {
|
|
|
502
506
|
*/
|
|
503
507
|
function structuralUnit(text, start, end) {
|
|
504
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.
|
|
505
514
|
let paraStart = lineStart;
|
|
506
515
|
while (paraStart > 0) {
|
|
516
|
+
if (lineEnd - paraStart > MAX_UNIT_CHARS)
|
|
517
|
+
return text.slice(lineStart, lineEnd);
|
|
507
518
|
const prevEnd = paraStart - 1;
|
|
508
519
|
const prevStart = text.lastIndexOf("\n", prevEnd - 1) + 1;
|
|
509
520
|
if (text.slice(prevStart, prevEnd).trim() === "")
|
|
@@ -512,6 +523,8 @@ function structuralUnit(text, start, end) {
|
|
|
512
523
|
}
|
|
513
524
|
let paraEnd = lineEnd;
|
|
514
525
|
while (paraEnd < text.length) {
|
|
526
|
+
if (paraEnd - paraStart > MAX_UNIT_CHARS)
|
|
527
|
+
return text.slice(lineStart, lineEnd);
|
|
515
528
|
const nextStart = paraEnd + 1;
|
|
516
529
|
let nextEnd = text.indexOf("\n", nextStart);
|
|
517
530
|
if (nextEnd === -1)
|
|
@@ -548,28 +561,73 @@ function escapeRe(s) {
|
|
|
548
561
|
* heading. Measured on the corpus this alone identifies ~78% of the BIC false
|
|
549
562
|
* positives with no dictionaries and no labelled data.
|
|
550
563
|
*/
|
|
551
|
-
|
|
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) {
|
|
552
625
|
if (!/^[A-Za-z]+$/.test(token))
|
|
553
626
|
return false;
|
|
554
627
|
const lower = token.toLowerCase();
|
|
555
628
|
const capitalised = lower[0].toUpperCase() + lower.slice(1);
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
if (!text.includes(form))
|
|
559
|
-
continue;
|
|
560
|
-
const re = new RegExp(`\\b${escapeRe(form)}\\b`, "g");
|
|
561
|
-
let m;
|
|
562
|
-
while ((m = re.exec(text)) !== null) {
|
|
563
|
-
const before = m.index > 0 ? text[m.index - 1] : "";
|
|
564
|
-
const after = text.slice(m.index + form.length, m.index + form.length + 8);
|
|
565
|
-
if (before === "@" || (before === "." && m.index >= 2 && /[A-Za-z0-9]/.test(text[m.index - 2])))
|
|
566
|
-
continue;
|
|
567
|
-
if (after.startsWith("@") || /^\.[a-zA-Z]{2,6}\b/.test(after))
|
|
568
|
-
continue;
|
|
569
|
-
return true;
|
|
570
|
-
}
|
|
571
|
-
}
|
|
572
|
-
return false;
|
|
629
|
+
const words = scratch ? scratch.documentWords() : buildDocumentWords(text);
|
|
630
|
+
return words.has(lower) || words.has(capitalised);
|
|
573
631
|
}
|
|
574
632
|
/**
|
|
575
633
|
* Is the candidate positioned as a section heading rather than a value?
|
|
@@ -607,25 +665,29 @@ function isHeadingShape(text, start, end, token) {
|
|
|
607
665
|
* ordinary lowercase word — so it is applied on both emitting paths. It scans
|
|
608
666
|
* the whole document, so it runs only on the paths that would otherwise emit.
|
|
609
667
|
*/
|
|
610
|
-
function suppressBicWithoutEvidence(text, match) {
|
|
668
|
+
function suppressBicWithoutEvidence(text, match, scratch) {
|
|
611
669
|
if (match.patternDef.entityType !== EntityType.BIC)
|
|
612
670
|
return false;
|
|
613
671
|
const token = match.text.trim();
|
|
614
672
|
if (isRegisteredBic(token))
|
|
615
|
-
return occursAsLowercaseWord(text, token);
|
|
673
|
+
return occursAsLowercaseWord(text, token, scratch);
|
|
616
674
|
if (isHeadingShape(text, match.start, match.end, token))
|
|
617
675
|
return true;
|
|
618
676
|
// The token itself is blanked out so it cannot vouch for itself.
|
|
619
677
|
const unit = structuralUnit(text, match.start, match.end).split(token).join(" ".repeat(token.length));
|
|
620
678
|
if (BIC_KEYWORD.test(unit) || IBAN_SHAPE.test(unit) || BANK_BLOCK.test(unit)) {
|
|
621
|
-
return occursAsLowercaseWord(text, token);
|
|
679
|
+
return occursAsLowercaseWord(text, token, scratch);
|
|
622
680
|
}
|
|
623
681
|
return true;
|
|
624
682
|
}
|
|
625
683
|
// ── Postal code: digits belonging to a longer identifier ────────────────
|
|
626
684
|
// An identifier label immediately before the digits. A postal code is never
|
|
627
685
|
// introduced this way; an SVNr, policy number or service number always is.
|
|
628
|
-
|
|
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;
|
|
629
691
|
// An international dialling prefix earlier on the same line, with nothing but
|
|
630
692
|
// number punctuation in between: these digits belong to the phone detector.
|
|
631
693
|
const DIALLING_PREFIX_BEFORE = /\+\d{1,3}[\d\s\-().]*$/;
|
|
@@ -737,13 +799,13 @@ const TYPE_SUPPRESSORS = {
|
|
|
737
799
|
[EntityType.SECRET]: [suppressSecretOverStructured, suppressSecretNotASecret],
|
|
738
800
|
[EntityType.CHAMBER_OF_COMMERCE]: [suppressReference],
|
|
739
801
|
};
|
|
740
|
-
export function shouldSuppress(text, match) {
|
|
802
|
+
export function shouldSuppress(text, match, scratch) {
|
|
741
803
|
if (suppressSequential(text, match))
|
|
742
804
|
return true;
|
|
743
805
|
const typeSups = TYPE_SUPPRESSORS[match.patternDef.entityType];
|
|
744
806
|
if (typeSups) {
|
|
745
807
|
for (const s of typeSups) {
|
|
746
|
-
if (s(text, match))
|
|
808
|
+
if (s(text, match, scratch))
|
|
747
809
|
return true;
|
|
748
810
|
}
|
|
749
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,6 +1,6 @@
|
|
|
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
5
|
"homepage": "https://euredact.dev",
|
|
6
6
|
"license": "Apache-2.0",
|
|
@@ -65,8 +65,9 @@
|
|
|
65
65
|
"dump": "node --import tsx src/__tests__/dump.ts"
|
|
66
66
|
},
|
|
67
67
|
"devDependencies": {
|
|
68
|
-
"
|
|
69
|
-
"tsx": "^4.7.0"
|
|
68
|
+
"@types/node": "22.10.2",
|
|
69
|
+
"tsx": "^4.7.0",
|
|
70
|
+
"typescript": "^5.4.0"
|
|
70
71
|
},
|
|
71
72
|
"bugs": {
|
|
72
73
|
"url": "https://github.com/euredact/euredact/issues"
|