euredact 0.3.6 → 0.3.8
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 +30 -3
- 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/cues.js +168 -0
- package/dist/cjs/rules/engine.js +124 -34
- package/dist/cjs/rules/suppressors.js +101 -38
- package/dist/cjs/rules/validators.js +12 -0
- package/dist/cjs/sdk.js +45 -3
- package/dist/cjs/types.js +10 -0
- 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/cues.d.ts +91 -0
- package/dist/esm/rules/cues.js +163 -0
- package/dist/esm/rules/engine.js +125 -35
- package/dist/esm/rules/suppressors.d.ts +14 -1
- package/dist/esm/rules/suppressors.js +99 -38
- 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/dist/esm/types.d.ts +10 -0
- package/dist/esm/types.js +10 -0
- package/package.json +4 -3
|
@@ -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");
|
|
@@ -43,11 +44,16 @@ const MATH_AFTER = /^\s*[=+\-×÷*/]/;
|
|
|
43
44
|
const COUNTRY_PREFIX_HYPHEN = /(?:^|[^A-Za-z0-9])[A-Z]{1,2}-\s*$/;
|
|
44
45
|
const SEQUENTIAL_PATTERNS = /^(?:0{6,}|1234567890?|0123456789|9876543210?|1111111111?|000000000|123456789)$/;
|
|
45
46
|
// YEAR_PATTERN kept for reference but replaced by RECENT_YEAR in suppressYearAsPostal
|
|
46
|
-
//
|
|
47
|
-
//
|
|
48
|
-
//
|
|
49
|
-
//
|
|
50
|
-
|
|
47
|
+
// `ID_LABEL_BEFORE` and `suppressPhoneAfterIdLabel` lived here. Their labels are
|
|
48
|
+
// now typed entries in `cues.ts`, and a phone-shaped span behind one is
|
|
49
|
+
// *relabelled* rather than dropped.
|
|
50
|
+
//
|
|
51
|
+
// Dropping was the wrong verb. The span is found either way, so removing the
|
|
52
|
+
// claim decided only whether the value was masked — and the answer was "no":
|
|
53
|
+
// "Rijksregisternummer: 85.03.19-284.73" produced no detection at all, a
|
|
54
|
+
// redaction library printing in full an identifier it had recognised and
|
|
55
|
+
// rejected. The 653-miss word-boundary lesson recorded here moved with the
|
|
56
|
+
// labels; see the module comment in `cues.ts`.
|
|
51
57
|
const SERVICE_NUMBER = /^0800[\-\s]/;
|
|
52
58
|
const DATE_PATTERN_FULL = /^\d{2}[-/.]\d{2}[-/.]\d{4}$|^\d{4}[-/.]\d{2}[-/.]\d{2}$/;
|
|
53
59
|
const PASSPORT_CONTEXT_BEFORE = /(?:Reisepass|passport|passeport|paspoort|Bisheriger\s+Reisepass)\s*(?:Nr\.?|Nummer|nummer|number|n[°o])?\s*[:.]?\s*$/i;
|
|
@@ -271,8 +277,12 @@ function suppressUnits(text, match) {
|
|
|
271
277
|
* keyword window. That window is what made the postal rule claim years in
|
|
272
278
|
* dates; widening its reach to fix a different symptom would repeat the error.
|
|
273
279
|
*/
|
|
280
|
+
// Hoisted out of the suppressor bodies: rebuilding these per candidate cost
|
|
281
|
+
// ~260k Set allocations per 1 MB document, and GC was 10.7% of the profile.
|
|
282
|
+
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]);
|
|
283
|
+
const LEGAL_TYPES = new Set([types_js_1.EntityType.PHONE, types_js_1.EntityType.NATIONAL_ID, types_js_1.EntityType.POSTAL_CODE]);
|
|
274
284
|
function suppressReference(text, match) {
|
|
275
|
-
const applicable =
|
|
285
|
+
const applicable = REFERENCE_TYPES;
|
|
276
286
|
if (!applicable.has(match.patternDef.entityType))
|
|
277
287
|
return false;
|
|
278
288
|
const [before] = getContext(text, match.start, match.end);
|
|
@@ -282,7 +292,7 @@ function suppressReference(text, match) {
|
|
|
282
292
|
return HASH_BEFORE.test(adjacent) || REF_PREFIX_BEFORE.test(adjacent);
|
|
283
293
|
}
|
|
284
294
|
function suppressLegal(text, match) {
|
|
285
|
-
const applicable =
|
|
295
|
+
const applicable = LEGAL_TYPES;
|
|
286
296
|
if (!applicable.has(match.patternDef.entityType))
|
|
287
297
|
return false;
|
|
288
298
|
const [before] = getContext(text, match.start, match.end);
|
|
@@ -337,12 +347,6 @@ function suppressYearAsPostal(text, match) {
|
|
|
337
347
|
// Suppress: recent years without postal context are almost never postal codes
|
|
338
348
|
return true;
|
|
339
349
|
}
|
|
340
|
-
function suppressPhoneAfterIdLabel(text, match) {
|
|
341
|
-
if (match.patternDef.entityType !== types_js_1.EntityType.PHONE)
|
|
342
|
-
return false;
|
|
343
|
-
const [before] = getContext(text, match.start, match.end);
|
|
344
|
-
return ID_LABEL_BEFORE.test(before);
|
|
345
|
-
}
|
|
346
350
|
function suppressPhoneServiceNumber(_text, match) {
|
|
347
351
|
if (match.patternDef.entityType !== types_js_1.EntityType.PHONE)
|
|
348
352
|
return false;
|
|
@@ -505,8 +509,15 @@ function enclosingLine(text, start, end) {
|
|
|
505
509
|
*/
|
|
506
510
|
function structuralUnit(text, start, end) {
|
|
507
511
|
const [lineStart, lineEnd] = enclosingLine(text, start, end);
|
|
512
|
+
// Both walks stop as soon as the paragraph is too wide to be used, because
|
|
513
|
+
// everything past that point is discarded by the cap below anyway. Applying
|
|
514
|
+
// the cap only to the *result* meant that on a document with no blank lines
|
|
515
|
+
// each walk ran to the top and bottom of the whole text — O(document) per
|
|
516
|
+
// candidate, O(n^2) overall.
|
|
508
517
|
let paraStart = lineStart;
|
|
509
518
|
while (paraStart > 0) {
|
|
519
|
+
if (lineEnd - paraStart > MAX_UNIT_CHARS)
|
|
520
|
+
return text.slice(lineStart, lineEnd);
|
|
510
521
|
const prevEnd = paraStart - 1;
|
|
511
522
|
const prevStart = text.lastIndexOf("\n", prevEnd - 1) + 1;
|
|
512
523
|
if (text.slice(prevStart, prevEnd).trim() === "")
|
|
@@ -515,6 +526,8 @@ function structuralUnit(text, start, end) {
|
|
|
515
526
|
}
|
|
516
527
|
let paraEnd = lineEnd;
|
|
517
528
|
while (paraEnd < text.length) {
|
|
529
|
+
if (paraEnd - paraStart > MAX_UNIT_CHARS)
|
|
530
|
+
return text.slice(lineStart, lineEnd);
|
|
518
531
|
const nextStart = paraEnd + 1;
|
|
519
532
|
let nextEnd = text.indexOf("\n", nextStart);
|
|
520
533
|
if (nextEnd === -1)
|
|
@@ -551,28 +564,74 @@ function escapeRe(s) {
|
|
|
551
564
|
* heading. Measured on the corpus this alone identifies ~78% of the BIC false
|
|
552
565
|
* positives with no dictionaries and no labelled data.
|
|
553
566
|
*/
|
|
554
|
-
|
|
567
|
+
/**
|
|
568
|
+
* Every all-letter word in the document that is not part of an email address
|
|
569
|
+
* or a domain name.
|
|
570
|
+
*
|
|
571
|
+
* `\b<word>\b` can only match a maximal run of word characters, so collecting
|
|
572
|
+
* the runs once answers the question for every candidate. Re-scanning the whole
|
|
573
|
+
* document per candidate made this quadratic: 1.58 s on a 400 KB document
|
|
574
|
+
* dense in bank codes, against ~1 ms to build this set.
|
|
575
|
+
*/
|
|
576
|
+
function buildDocumentWords(text) {
|
|
577
|
+
const words = new Set();
|
|
578
|
+
const re = /[A-Za-z0-9_]+/g;
|
|
579
|
+
let m;
|
|
580
|
+
while ((m = re.exec(text)) !== null) {
|
|
581
|
+
const word = m[0];
|
|
582
|
+
if (!/^[A-Za-z]+$/.test(word))
|
|
583
|
+
continue;
|
|
584
|
+
const i = m.index;
|
|
585
|
+
const before = i > 0 ? text[i - 1] : "";
|
|
586
|
+
if (before === "@")
|
|
587
|
+
continue;
|
|
588
|
+
if (before === "." && i >= 2 && /[A-Za-z0-9]/.test(text[i - 2]))
|
|
589
|
+
continue;
|
|
590
|
+
const after = text.slice(i + word.length, i + word.length + 8);
|
|
591
|
+
if (after.startsWith("@"))
|
|
592
|
+
continue;
|
|
593
|
+
if (/^\.[a-zA-Z]{2,6}\b/.test(after))
|
|
594
|
+
continue;
|
|
595
|
+
words.add(word);
|
|
596
|
+
}
|
|
597
|
+
return words;
|
|
598
|
+
}
|
|
599
|
+
/**
|
|
600
|
+
* Per-document scratch space for suppressors that would otherwise re-derive the
|
|
601
|
+
* same whole-document facts for every candidate.
|
|
602
|
+
*
|
|
603
|
+
* Built lazily and held only for the duration of one `detect()` call, so no
|
|
604
|
+
* document text outlives the call that supplied it.
|
|
605
|
+
*/
|
|
606
|
+
class SuppressionScratch {
|
|
607
|
+
text;
|
|
608
|
+
words = null;
|
|
609
|
+
constructor(text) {
|
|
610
|
+
this.text = text;
|
|
611
|
+
}
|
|
612
|
+
documentWords() {
|
|
613
|
+
if (this.words === null)
|
|
614
|
+
this.words = buildDocumentWords(this.text);
|
|
615
|
+
return this.words;
|
|
616
|
+
}
|
|
617
|
+
}
|
|
618
|
+
exports.SuppressionScratch = SuppressionScratch;
|
|
619
|
+
/**
|
|
620
|
+
* Does `token` also occur as an ordinary lowercase word in this document?
|
|
621
|
+
*
|
|
622
|
+
* `hospital`/`HOSPITAL`, `gegevens`/`GEGEVENS` — a token that appears in
|
|
623
|
+
* ordinary case elsewhere in the same document is a word, not a bank code.
|
|
624
|
+
* Email and domain contexts are excluded, so `ing.nl` does not vouch for a
|
|
625
|
+
* heading. Measured on the corpus this alone identifies ~78% of the BIC false
|
|
626
|
+
* positives with no dictionaries and no labelled data.
|
|
627
|
+
*/
|
|
628
|
+
function occursAsLowercaseWord(text, token, scratch) {
|
|
555
629
|
if (!/^[A-Za-z]+$/.test(token))
|
|
556
630
|
return false;
|
|
557
631
|
const lower = token.toLowerCase();
|
|
558
632
|
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;
|
|
633
|
+
const words = scratch ? scratch.documentWords() : buildDocumentWords(text);
|
|
634
|
+
return words.has(lower) || words.has(capitalised);
|
|
576
635
|
}
|
|
577
636
|
/**
|
|
578
637
|
* Is the candidate positioned as a section heading rather than a value?
|
|
@@ -610,25 +669,29 @@ function isHeadingShape(text, start, end, token) {
|
|
|
610
669
|
* ordinary lowercase word — so it is applied on both emitting paths. It scans
|
|
611
670
|
* the whole document, so it runs only on the paths that would otherwise emit.
|
|
612
671
|
*/
|
|
613
|
-
function suppressBicWithoutEvidence(text, match) {
|
|
672
|
+
function suppressBicWithoutEvidence(text, match, scratch) {
|
|
614
673
|
if (match.patternDef.entityType !== types_js_1.EntityType.BIC)
|
|
615
674
|
return false;
|
|
616
675
|
const token = match.text.trim();
|
|
617
676
|
if ((0, bicRegistry_js_1.isRegisteredBic)(token))
|
|
618
|
-
return occursAsLowercaseWord(text, token);
|
|
677
|
+
return occursAsLowercaseWord(text, token, scratch);
|
|
619
678
|
if (isHeadingShape(text, match.start, match.end, token))
|
|
620
679
|
return true;
|
|
621
680
|
// The token itself is blanked out so it cannot vouch for itself.
|
|
622
681
|
const unit = structuralUnit(text, match.start, match.end).split(token).join(" ".repeat(token.length));
|
|
623
682
|
if (BIC_KEYWORD.test(unit) || IBAN_SHAPE.test(unit) || BANK_BLOCK.test(unit)) {
|
|
624
|
-
return occursAsLowercaseWord(text, token);
|
|
683
|
+
return occursAsLowercaseWord(text, token, scratch);
|
|
625
684
|
}
|
|
626
685
|
return true;
|
|
627
686
|
}
|
|
628
687
|
// ── Postal code: digits belonging to a longer identifier ────────────────
|
|
629
688
|
// An identifier label immediately before the digits. A postal code is never
|
|
630
689
|
// introduced this way; an SVNr, policy number or service number always is.
|
|
631
|
-
|
|
690
|
+
// The `[\w\-]*` prefix is factored out of the five alternatives that share it.
|
|
691
|
+
// Repeated per alternative, the engine retried the same unbounded prefix five
|
|
692
|
+
// times per starting position — the most expensive single regex in the engine
|
|
693
|
+
// at 197 ms per 1 MB document. Same language, 8x faster on the worst case.
|
|
694
|
+
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
695
|
// An international dialling prefix earlier on the same line, with nothing but
|
|
633
696
|
// number punctuation in between: these digits belong to the phone detector.
|
|
634
697
|
const DIALLING_PREFIX_BEFORE = /\+\d{1,3}[\d\s\-().]*$/;
|
|
@@ -729,7 +792,7 @@ function suppressRequiresContext(text, match) {
|
|
|
729
792
|
return !match.patternDef.contextKeywords.some(kw => context.includes(kw.toLowerCase()));
|
|
730
793
|
}
|
|
731
794
|
const TYPE_SUPPRESSORS = {
|
|
732
|
-
[types_js_1.EntityType.PHONE]: [suppressCurrency, suppressUnits, suppressReference, suppressMath,
|
|
795
|
+
[types_js_1.EntityType.PHONE]: [suppressCurrency, suppressUnits, suppressReference, suppressMath, suppressPhoneServiceNumber, suppressPhoneDateOverlap, suppressPhoneAsNumberRange],
|
|
733
796
|
[types_js_1.EntityType.NATIONAL_ID]: [suppressCurrency, suppressUnits, suppressReference, suppressLegal, suppressMath, suppressNatidAsPassport, suppressSeNatidAsOrg],
|
|
734
797
|
[types_js_1.EntityType.SSN]: [suppressCurrency, suppressUnits, suppressReference, suppressMath],
|
|
735
798
|
[types_js_1.EntityType.TAX_ID]: [suppressCurrency, suppressUnits, suppressReference, suppressMath, suppressTaxidAsIpAddress],
|
|
@@ -740,13 +803,13 @@ const TYPE_SUPPRESSORS = {
|
|
|
740
803
|
[types_js_1.EntityType.SECRET]: [suppressSecretOverStructured, suppressSecretNotASecret],
|
|
741
804
|
[types_js_1.EntityType.CHAMBER_OF_COMMERCE]: [suppressReference],
|
|
742
805
|
};
|
|
743
|
-
function shouldSuppress(text, match) {
|
|
806
|
+
function shouldSuppress(text, match, scratch) {
|
|
744
807
|
if (suppressSequential(text, match))
|
|
745
808
|
return true;
|
|
746
809
|
const typeSups = TYPE_SUPPRESSORS[match.patternDef.entityType];
|
|
747
810
|
if (typeSups) {
|
|
748
811
|
for (const s of typeSups) {
|
|
749
|
-
if (s(text, match))
|
|
812
|
+
if (s(text, match, scratch))
|
|
750
813
|
return true;
|
|
751
814
|
}
|
|
752
815
|
}
|
|
@@ -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/cjs/types.js
CHANGED
|
@@ -36,6 +36,16 @@ var EntityType;
|
|
|
36
36
|
EntityType["HEALTH_INSURANCE"] = "HEALTH_INSURANCE";
|
|
37
37
|
EntityType["HEALTHCARE_PROVIDER"] = "HEALTHCARE_PROVIDER";
|
|
38
38
|
EntityType["CHAMBER_OF_COMMERCE"] = "CHAMBER_OF_COMMERCE";
|
|
39
|
+
/**
|
|
40
|
+
* Employee, badge or customer number tied to a person.
|
|
41
|
+
*
|
|
42
|
+
* Emitted **only** when an explicit label names it — see `rules/cues.ts`.
|
|
43
|
+
* There is no pattern for an internal identifier because there is no shape
|
|
44
|
+
* for one; without the label, a digit run is not distinguishable from any
|
|
45
|
+
* other. The type exists so that a labelled employee number is filed
|
|
46
|
+
* correctly instead of being claimed by the phone pattern.
|
|
47
|
+
*/
|
|
48
|
+
EntityType["INTERNAL_ID"] = "INTERNAL_ID";
|
|
39
49
|
EntityType["IMEI"] = "IMEI";
|
|
40
50
|
EntityType["GPS_COORDINATES"] = "GPS_COORDINATES";
|
|
41
51
|
EntityType["UUID"] = "UUID";
|
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),
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Local cues: what the document calls the value sitting next to it.
|
|
3
|
+
*
|
|
4
|
+
* A label touching the left edge of a span is the strongest statement a
|
|
5
|
+
* document makes about what that value *is*. "Phone: 0705237535" is a phone
|
|
6
|
+
* number even though those digits pass the Swedish personnummer checksum, and
|
|
7
|
+
* "Αρ. Ταυτότητας: 00892341" is an identity-card number even though nothing in
|
|
8
|
+
* the engine can checksum it.
|
|
9
|
+
*
|
|
10
|
+
* This table is the single source of that knowledge. Three callers read it, and
|
|
11
|
+
* none of them may move a span:
|
|
12
|
+
*
|
|
13
|
+
* 1. `localCueBonus` — ranks a candidate above its rivals on the *same span*
|
|
14
|
+
* when the cue names its type. The oldest use, and why the table exists.
|
|
15
|
+
* 2. `detectWithEvidence` — re-admits a candidate whose checksum failed when the
|
|
16
|
+
* cue names that same type. A declined identifier with its own label in front
|
|
17
|
+
* of it is still that identifier, mistyped or truncated.
|
|
18
|
+
* 3. `retypedBy` — relabels a winning generic candidate whose type the cue
|
|
19
|
+
* contradicts, when nothing of the cued type claimed the span.
|
|
20
|
+
*
|
|
21
|
+
* Consolidated here from two places that had drifted apart: `LOCAL_CUES` in
|
|
22
|
+
* `engine.ts` (typed, but missing most of Europe) and `ID_LABEL_BEFORE` in
|
|
23
|
+
* `suppressors.ts` (broader, but untyped — it could only *delete* a phone
|
|
24
|
+
* candidate, which left the value unmasked).
|
|
25
|
+
*
|
|
26
|
+
* Kept character-for-character in step with `euredact/rules/cues.py`. Two
|
|
27
|
+
* constructs are written the way they are so that both engines agree:
|
|
28
|
+
*
|
|
29
|
+
* * Boundaries are `(?<![A-Za-z0-9_])`, not `\b`. JavaScript's `\b` is
|
|
30
|
+
* ASCII-only, so `\bΑΦΜ` never matches — a Greek letter is not a word
|
|
31
|
+
* character there, and a space before it is not a transition. A boundary of
|
|
32
|
+
* some kind is required regardless: without one the short alternatives match
|
|
33
|
+
* the *tail* of an ordinary word. "NIS" matched "tālru**nis**", the Latvian
|
|
34
|
+
* for telephone, and suppressed 653 real phone numbers; "iva" matched
|
|
35
|
+
* "Pr**iva**t:" and read a personal e-mail address as a VAT number.
|
|
36
|
+
* * The qualifier class is spelled out rather than written `[^\W\d_]`, which
|
|
37
|
+
* would reject "εταιρειας" here for the same ASCII reason.
|
|
38
|
+
*
|
|
39
|
+
* Every label ends in `(?:\w*|\s+<one word>)`: the label either runs on into a
|
|
40
|
+
* longer word ("telefoon" → "telefoonnummer") **or** takes one qualifier word
|
|
41
|
+
* naming whose identifier it is ("ΑΦΜ εταιρειας:", "Tel Nr:") — never both.
|
|
42
|
+
* Allowing both let "nie" grow into the surname "Nieminen" and then swallow the
|
|
43
|
+
* real label in "Matti Nieminen\nDOB: 08.05.1994", reading a date of birth as a
|
|
44
|
+
* Spanish NIE: 52 false positives across the corpus.
|
|
45
|
+
*/
|
|
46
|
+
import { EntityType } from "../types.js";
|
|
47
|
+
/**
|
|
48
|
+
* How far back a cue may sit. Short on purpose: a cue is only evidence about
|
|
49
|
+
* the value it introduces, which is the lesson of the postal-code year defect,
|
|
50
|
+
* where a cue 150 characters away licensed every number in the window.
|
|
51
|
+
*/
|
|
52
|
+
export declare const CUE_WINDOW = 22;
|
|
53
|
+
/**
|
|
54
|
+
* (entity type, label pattern). Ordered: the first match wins, so the narrower
|
|
55
|
+
* label comes first where two could read the same text.
|
|
56
|
+
*/
|
|
57
|
+
export declare const CUES: Array<[EntityType, RegExp]>;
|
|
58
|
+
/**
|
|
59
|
+
* The entity type a label immediately before `start` names, or null.
|
|
60
|
+
*
|
|
61
|
+
* Reads at most `CUE_WINDOW` characters, and every pattern is anchored to the
|
|
62
|
+
* end of that window, so the cue must *touch* the span. A label further away is
|
|
63
|
+
* about some other value.
|
|
64
|
+
*/
|
|
65
|
+
export declare function cuedType(text: string, start: number): EntityType | null;
|
|
66
|
+
/**
|
|
67
|
+
* Types a label can assert on its own. Also the types eligible for the rescue
|
|
68
|
+
* in `engine.ts`, for the same reason read the other way — a label may vouch
|
|
69
|
+
* for one of these, so it may also vouch for a malformed one.
|
|
70
|
+
*
|
|
71
|
+
* PHONE is absent on purpose: "Tel:" in front of something no phone pattern
|
|
72
|
+
* matched means the document is laid out oddly, not that the value is a phone
|
|
73
|
+
* number. So are EMAIL, SECRET, DOB, VIN and the rest — those carry their own
|
|
74
|
+
* structure, and a nearby word is not entitled to overrule it.
|
|
75
|
+
*
|
|
76
|
+
* BIC's absence is what keeps the rescue honest. Its validator is a *registry
|
|
77
|
+
* lookup*, not a checksum, so a failure means "no such bank", which no label can
|
|
78
|
+
* talk you out of. Rescuing it read 34 unlisted codes behind a "BIC:" label as
|
|
79
|
+
* real ones. The same reasoning keeps CREDIT_CARD and BANK_ACCOUNT out: Luhn and
|
|
80
|
+
* mod-97 are strong enough that a failure really does mean "not one of these".
|
|
81
|
+
*/
|
|
82
|
+
export declare const CUE_TARGETS: Set<string>;
|
|
83
|
+
/**
|
|
84
|
+
* The type a cue overrules `entityType` with, or null to leave it alone.
|
|
85
|
+
*
|
|
86
|
+
* Both gates are load-bearing. Measured over 2,000 corpus documents, re-typing
|
|
87
|
+
* without them fired 52 times and got roughly 30 of those wrong — EMAIL read as
|
|
88
|
+
* VAT, a date of birth as a phone number, a VIN as VAT — against 20 real fixes.
|
|
89
|
+
* With them: 14 re-typings, every one of them PHONE to a structured identifier.
|
|
90
|
+
*/
|
|
91
|
+
export declare function retypedBy(text: string, start: number, entityType: EntityType | string): EntityType | null;
|