euredact 0.5.0 → 0.6.0
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 +262 -13
- package/dist/cjs/cloud/client.js +7 -4
- package/dist/cjs/rules/countries/index.js +125 -42
- package/dist/cjs/rules/cues.js +21 -2
- package/dist/cjs/rules/suppressors.js +130 -5
- package/dist/cjs/rules/validators.js +53 -0
- package/dist/cjs/sdk.js +133 -38
- package/dist/esm/cloud/client.d.ts +7 -4
- package/dist/esm/cloud/client.js +7 -4
- package/dist/esm/rules/countries/index.js +125 -42
- package/dist/esm/rules/cues.d.ts +13 -1
- package/dist/esm/rules/cues.js +21 -2
- package/dist/esm/rules/suppressors.js +130 -5
- package/dist/esm/rules/validators.js +53 -0
- package/dist/esm/sdk.d.ts +12 -15
- package/dist/esm/sdk.js +133 -38
- package/package.json +1 -1
|
@@ -309,9 +309,85 @@ function suppressSequential(_text, match) {
|
|
|
309
309
|
const clean = match.text.replace(/[\s.\-]/g, "");
|
|
310
310
|
return SEQUENTIAL_PATTERNS.test(clean);
|
|
311
311
|
}
|
|
312
|
+
// ── The engine's own output ───────────────────────────────────────────────
|
|
313
|
+
//
|
|
314
|
+
// Redaction has to be idempotent: running it over a document it already
|
|
315
|
+
// produced must leave the placeholders it wrote alone. It did not.
|
|
316
|
+
// `[POSTAL_CODE]` is thirteen characters of mixed case with an underscore, so
|
|
317
|
+
// the entropy-based SECRET rule read it as a credential and a second pass
|
|
318
|
+
// replaced it with `[SECRET]` (rules-engine#33).
|
|
319
|
+
//
|
|
320
|
+
// All three emitted forms are guarded, because all three come back as input in
|
|
321
|
+
// a re-processing pipeline: `[TYPE]`, `TYPE_1` (referentialIntegrity) and
|
|
322
|
+
// `TYPE_K7Q2` (tokenize -- where a false detection breaks restore()).
|
|
323
|
+
//
|
|
324
|
+
// Only real entity-type names count. Guarding any bracketed upper-case token
|
|
325
|
+
// would be simpler and wrong: `[AKIAIOSFODNN7EXAMPLE]` is one, and also a live
|
|
326
|
+
// AWS key. A placeholder for a *custom* pattern is therefore not covered.
|
|
327
|
+
// The closing bracket is optional: another pattern's span need not agree with
|
|
328
|
+
// this one about where the marker ends. The assigned-secret rule stops before
|
|
329
|
+
// sentence punctuation (rules-engine#35), so it claims "[POSTAL_CODE" without
|
|
330
|
+
// the "]". The opening bracket is still required.
|
|
331
|
+
const PLACEHOLDER = /^(?:\[([A-Z][A-Z0-9_]*)\]?|([A-Z][A-Z0-9_]*?)_(?:\d+|[A-HJ-NP-Z2-9]{4}))$/;
|
|
332
|
+
const KNOWN_TYPE_NAMES = new Set(Object.values(EntityType));
|
|
333
|
+
function suppressRedactionPlaceholder(_text, match) {
|
|
334
|
+
const found = PLACEHOLDER.exec(match.text);
|
|
335
|
+
if (found === null)
|
|
336
|
+
return false;
|
|
337
|
+
const name = found[1] ?? found[2];
|
|
338
|
+
return KNOWN_TYPE_NAMES.has(name);
|
|
339
|
+
}
|
|
312
340
|
const RECENT_YEAR = /^(?:19[5-9]\d|20[0-3]\d)$/;
|
|
313
341
|
const POSTAL_CONTEXT_NEAR = /(?:postcode|postal|code\s*postal|PLZ|Postleitzahl|postnummer|postinumero|póstnúmer|zip|straat|straße|strasse|rue\s|via\s|calle\s|rua\s|ulica|utca|street|avenue|laan\s|weg\s|plein|adres|adresse|address|woonplaats|wonende|woonachtig|gevestigd|domicili|demeurant|résidant|residant|bosatt|bopæl|wohnhaft|ansässig|stad\b|ville\b|city\b|Stadt|città|ciudad|cidade|miasto|město|város)/i;
|
|
314
342
|
const DATE_KEYWORD_NEAR = /(?:født|fødselsdato|fødsel|Fødselsdato|född|födelsedatum|födelsedag|syntynyt|syntymäaika|fæddur|fæðingardagur|geboren|geboortedatum|Geburtsdatum|nascido|nacido|data di nascita|nato il|nata il|Tiltr[æa]delsesdato|Tiltredelsesdato|datum|date\b|Datum|jaar|year|année|Jahr|since|sinds|depuis|seit|\d{2}\.\d{2}\.|(?:januar|februar|marts|april|maj|juni|juli|august|september|oktober|november|december|januari|februari|mars|mei|juin|juillet|août))/i;
|
|
343
|
+
// A sentence boundary: a full stop, question or exclamation mark followed by
|
|
344
|
+
// space, or a line break. Written so it cannot fire inside "11.03.1970", where
|
|
345
|
+
// the periods are not followed by whitespace -- that date is one of the false
|
|
346
|
+
// positives this gate has to reject.
|
|
347
|
+
const SENTENCE_BREAK = /[.!?][ \t\n]|\n/;
|
|
348
|
+
const SENTENCE_BREAK_G = /[.!?][ \t\n]|\n/g;
|
|
349
|
+
// The value's own sentence, bounded either side.
|
|
350
|
+
function sentenceAround(text, start, end, reach = 160) {
|
|
351
|
+
let before = text.slice(Math.max(0, start - reach), start);
|
|
352
|
+
let after = text.slice(end, end + reach);
|
|
353
|
+
let lastEnd = -1;
|
|
354
|
+
SENTENCE_BREAK_G.lastIndex = 0;
|
|
355
|
+
for (let m = SENTENCE_BREAK_G.exec(before); m !== null; m = SENTENCE_BREAK_G.exec(before)) {
|
|
356
|
+
lastEnd = m.index + m[0].length;
|
|
357
|
+
}
|
|
358
|
+
if (lastEnd >= 0)
|
|
359
|
+
before = before.slice(lastEnd);
|
|
360
|
+
const found = SENTENCE_BREAK.exec(after);
|
|
361
|
+
if (found !== null)
|
|
362
|
+
after = after.slice(0, found.index);
|
|
363
|
+
return before + after;
|
|
364
|
+
}
|
|
365
|
+
// A postal label whose only distance from the value is punctuation:
|
|
366
|
+
// "PLZ: 2011", "code postal : 2011", "Woonplaats: 1950".
|
|
367
|
+
const POSTAL_LABEL_TOUCHING = new RegExp(`(?:${POSTAL_CONTEXT_NEAR.source})[\\s:.\\-]{0,4}$`, "i");
|
|
368
|
+
// A capitalised place name after the code: "2000 Antwerpen". A following label
|
|
369
|
+
// ("1970 Fødselsnummer:") is excluded by the colon.
|
|
370
|
+
const CITY_AFTER = /^[ \t](?!\w+\s*:)[A-ZÀ-ÞŁŠŽ][\w\-']{2,}/u;
|
|
371
|
+
// Every position is local to the value or inside its own sentence. Proximity to
|
|
372
|
+
// an address *word* anywhere in a 300-character window is what the old rescue
|
|
373
|
+
// asked, and that is a neighbourhood rather than a position (rules-engine#32).
|
|
374
|
+
function yearSitsInAddressStructure(text, start, end) {
|
|
375
|
+
const before = text.slice(Math.max(0, start - 28), start);
|
|
376
|
+
// "Amsterdam, 2026" -- the comma of an address line.
|
|
377
|
+
if (/,[ \t]*$/.test(before))
|
|
378
|
+
return true;
|
|
379
|
+
// "PLZ: 2011" -- a postal label with nothing but punctuation between.
|
|
380
|
+
if (POSTAL_LABEL_TOUCHING.test(before))
|
|
381
|
+
return true;
|
|
382
|
+
// A capitalised place name immediately after.
|
|
383
|
+
if (CITY_AFTER.test(text.slice(end, end + 24)))
|
|
384
|
+
return true;
|
|
385
|
+
// An address word in the value's own sentence. "Te huur: Lange Nieuwstraat
|
|
386
|
+
// 12, rustige ligging in 2018, vlakbij openbaar vervoer." is an Antwerp
|
|
387
|
+
// postal code in prose; the false positives put the year and the address in
|
|
388
|
+
// different sentences.
|
|
389
|
+
return POSTAL_CONTEXT_NEAR.test(sentenceAround(text, start, end));
|
|
390
|
+
}
|
|
315
391
|
function suppressYearAsPostal(text, match) {
|
|
316
392
|
if (match.patternDef.entityType !== EntityType.POSTAL_CODE)
|
|
317
393
|
return false;
|
|
@@ -330,8 +406,10 @@ function suppressYearAsPostal(text, match) {
|
|
|
330
406
|
return true;
|
|
331
407
|
const [before, after] = getContext(text, match.start, match.end);
|
|
332
408
|
const context = before + after;
|
|
333
|
-
// Keep as postal code
|
|
334
|
-
|
|
409
|
+
// Keep as postal code only when the value sits in address *structure* --
|
|
410
|
+
// see yearSitsInAddressStructure for why a 300-character window of address
|
|
411
|
+
// words was the defect rather than the keyword list.
|
|
412
|
+
if (yearSitsInAddressStructure(text, match.start, match.end))
|
|
335
413
|
return false;
|
|
336
414
|
// Keep if preceded by comma+space (address pattern: "Amsterdam, 2026")
|
|
337
415
|
const immediateBefore = text.slice(Math.max(0, match.start - 3), match.start);
|
|
@@ -506,6 +584,35 @@ function suppressPostalInsideIban(text, match) {
|
|
|
506
584
|
}
|
|
507
585
|
return false;
|
|
508
586
|
}
|
|
587
|
+
// An IBAN-shaped run: two letters, two check digits, then grouped
|
|
588
|
+
// alphanumerics. Deliberately not checksum-gated -- the point is to describe the
|
|
589
|
+
// run a human wrote as one account number, including the ones that fail their
|
|
590
|
+
// checksum, because those are exactly the ones no structured detector claims.
|
|
591
|
+
const ACCOUNT_RUN = /\b[A-Z]{2}\d{2}(?:[ ]?[A-Z0-9]{2,4}){2,8}\b/g;
|
|
592
|
+
// "Compte: FR76 3000 4008 0300 0109 5374" fails the IBAN checksum, so no
|
|
593
|
+
// BANK_ACCOUNT candidate survives and the Danish eight-digit phone pattern took
|
|
594
|
+
// "3000 4008" out of the middle of it, leaving the rest in the clear
|
|
595
|
+
// (rules-engine#30). POSTAL_CODE has carried its own version of this guard
|
|
596
|
+
// since 0.3.3, in suppressPostalInsideIban.
|
|
597
|
+
function suppressPhoneInsideAccountRun(text, match) {
|
|
598
|
+
if (match.patternDef.entityType !== EntityType.PHONE)
|
|
599
|
+
return false;
|
|
600
|
+
const lineStart = text.lastIndexOf("\n", match.start) + 1;
|
|
601
|
+
let lineEnd = text.indexOf("\n", match.end);
|
|
602
|
+
if (lineEnd < 0)
|
|
603
|
+
lineEnd = text.length;
|
|
604
|
+
const line = text.slice(lineStart, lineEnd);
|
|
605
|
+
ACCOUNT_RUN.lastIndex = 0;
|
|
606
|
+
for (let m = ACCOUNT_RUN.exec(line); m !== null; m = ACCOUNT_RUN.exec(line)) {
|
|
607
|
+
const runStart = lineStart + m.index;
|
|
608
|
+
const runEnd = runStart + m[0].length;
|
|
609
|
+
if (runStart <= match.start && match.end <= runEnd
|
|
610
|
+
&& runEnd - runStart > match.end - match.start) {
|
|
611
|
+
return true;
|
|
612
|
+
}
|
|
613
|
+
}
|
|
614
|
+
return false;
|
|
615
|
+
}
|
|
509
616
|
function suppressPostalAsHouseNumber(text, match) {
|
|
510
617
|
if (match.patternDef.entityType !== EntityType.POSTAL_CODE)
|
|
511
618
|
return false;
|
|
@@ -733,12 +840,19 @@ function suppressBicWithoutEvidence(text, match, scratch) {
|
|
|
733
840
|
// Repeated per alternative, the engine retried the same unbounded prefix five
|
|
734
841
|
// times per starting position — the most expensive single regex in the engine
|
|
735
842
|
// at 197 ms per 1 MB document. Same language, 8x faster on the worst case.
|
|
843
|
+
// An identifier cue that is really a postal label -- see the use below.
|
|
844
|
+
const POSTAL_LABEL_CUE = /^post/i;
|
|
736
845
|
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;
|
|
737
846
|
// An international dialling prefix earlier on the same line, with nothing but
|
|
738
847
|
// number punctuation in between: these digits belong to the phone detector.
|
|
739
848
|
const DIALLING_PREFIX_BEFORE = /\+\d{1,3}[\d\s\-().]*$/;
|
|
740
849
|
// A country prefix on a postal code — "A-1010 Wien", "B-2000", "L-1234".
|
|
741
|
-
|
|
850
|
+
// The boundary excludes a hyphen, which is what makes this a *prefix* rather
|
|
851
|
+
// than a segment. It did not, so any `XX-NNNNN` tail of a hyphenated reference
|
|
852
|
+
// read as a country-prefixed address: in "PV-2026-LU-09143" the `LU` is
|
|
853
|
+
// preceded by `-` and the German five-digit postal pattern kept `09143`
|
|
854
|
+
// (rules-engine#31).
|
|
855
|
+
const COUNTRY_PREFIXED = /(?:^|[^A-Za-z0-9\-])[A-Z]{1,2}$/;
|
|
742
856
|
/**
|
|
743
857
|
* Suppress bare digit runs that belong to a longer number, not an address.
|
|
744
858
|
*
|
|
@@ -782,7 +896,16 @@ function suppressPostalInLongerIdentifier(text, match) {
|
|
|
782
896
|
if (/^[ \t]+\d/.test(text.slice(match.end, match.end + 4)))
|
|
783
897
|
return true;
|
|
784
898
|
// An identifier label introduces the digits: "DiNr. 4471", "Policen-Nr."
|
|
785
|
-
|
|
899
|
+
// Unless the label is a *postal* one. ID_CUE_BEFORE matches any word ending
|
|
900
|
+
// in "Nummer", "Nr", "Numero" or "Numéro" -- the wildcard is `[\w\-]*` -- so
|
|
901
|
+
// it matches `postnummer`, `postnr` and `postinumero`, the canonical postal
|
|
902
|
+
// labels of Norway, Denmark and Finland. Those countries write a bare
|
|
903
|
+
// four- or five-digit code, so the isdigit() guard above lets this run and
|
|
904
|
+
// the code was suppressed by its own label: `postnummer: 5020 Bergen`
|
|
905
|
+
// produced nothing at all (rules-engine#41).
|
|
906
|
+
const before = text.slice(Math.max(0, match.start - 40), match.start);
|
|
907
|
+
const cue = ID_CUE_BEFORE.exec(before);
|
|
908
|
+
if (cue !== null && !POSTAL_LABEL_CUE.test(cue[0].trimStart()))
|
|
786
909
|
return true;
|
|
787
910
|
// Digits after an international dialling prefix belong to PHONE
|
|
788
911
|
const [lineStart] = enclosingLine(text, match.start, match.end);
|
|
@@ -834,7 +957,7 @@ function suppressRequiresContext(text, match) {
|
|
|
834
957
|
return !match.patternDef.contextKeywords.some(kw => context.includes(kw.toLowerCase()));
|
|
835
958
|
}
|
|
836
959
|
const TYPE_SUPPRESSORS = {
|
|
837
|
-
[EntityType.PHONE]: [suppressCurrency, suppressUnits, suppressReference, suppressMath, suppressPhoneServiceNumber, suppressPhoneDateOverlap, suppressPhoneAsNumberRange],
|
|
960
|
+
[EntityType.PHONE]: [suppressCurrency, suppressUnits, suppressReference, suppressMath, suppressPhoneServiceNumber, suppressPhoneInsideAccountRun, suppressPhoneDateOverlap, suppressPhoneAsNumberRange],
|
|
838
961
|
[EntityType.NATIONAL_ID]: [suppressCurrency, suppressUnits, suppressReference, suppressLegal, suppressMath, suppressNatidAsPassport, suppressSeNatidAsOrg],
|
|
839
962
|
[EntityType.SSN]: [suppressCurrency, suppressUnits, suppressReference, suppressMath],
|
|
840
963
|
[EntityType.TAX_ID]: [suppressCurrency, suppressUnits, suppressReference, suppressMath, suppressTaxidAsIpAddress],
|
|
@@ -848,6 +971,8 @@ const TYPE_SUPPRESSORS = {
|
|
|
848
971
|
export function shouldSuppress(text, match, scratch) {
|
|
849
972
|
if (suppressSequential(text, match))
|
|
850
973
|
return true;
|
|
974
|
+
if (suppressRedactionPlaceholder(text, match))
|
|
975
|
+
return true;
|
|
851
976
|
const typeSups = TYPE_SUPPRESSORS[match.patternDef.entityType];
|
|
852
977
|
if (typeSups) {
|
|
853
978
|
for (const s of typeSups) {
|
|
@@ -467,10 +467,24 @@ export function validatePortugueseNif(candidate) {
|
|
|
467
467
|
const check = remainder < 2 ? 0 : 11 - remainder;
|
|
468
468
|
return parseInt(c[8]) === check;
|
|
469
469
|
}
|
|
470
|
+
// PESEL carries the century in the month field: +0 is the 1900s, +20 the 2000s,
|
|
471
|
+
// +40 the 2100s, +60 the 2200s, +80 the 1800s. Without a date check this
|
|
472
|
+
// accepted 80666603210 -- day 66 -- as NATIONAL_ID at high confidence
|
|
473
|
+
// (rules-engine#43).
|
|
470
474
|
export function validatePolishPesel(candidate) {
|
|
471
475
|
const c = candidate.replace(/[\s\-]/g, "");
|
|
472
476
|
if (c.length !== 11 || !/^\d{11}$/.test(c))
|
|
473
477
|
return false;
|
|
478
|
+
const rawMonth = parseInt(c.slice(2, 4), 10);
|
|
479
|
+
let month = null;
|
|
480
|
+
for (const off of [0, 20, 40, 60, 80]) {
|
|
481
|
+
if (rawMonth - off >= 1 && rawMonth - off <= 12) {
|
|
482
|
+
month = rawMonth - off;
|
|
483
|
+
break;
|
|
484
|
+
}
|
|
485
|
+
}
|
|
486
|
+
if (month === null || !isRealDay(month, parseInt(c.slice(4, 6), 10)))
|
|
487
|
+
return false;
|
|
474
488
|
const d = c.split("").map(Number);
|
|
475
489
|
const weights = [1, 3, 7, 9, 1, 3, 7, 9, 1, 3];
|
|
476
490
|
let total = 0;
|
|
@@ -489,18 +503,53 @@ export function validatePolishNip(candidate) {
|
|
|
489
503
|
total += d[i] * weights[i];
|
|
490
504
|
return total % 11 === d[9];
|
|
491
505
|
}
|
|
506
|
+
// Days per month for the rodné číslo date check. February is 29 because the
|
|
507
|
+
// century is not recoverable from a two-digit year, so a leap year cannot be
|
|
508
|
+
// ruled out -- being permissive by one day is the right direction here.
|
|
509
|
+
const RC_DAYS = [31, 29, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31];
|
|
510
|
+
// The calendar month a rodné číslo month field encodes, or null. Four ranges
|
|
511
|
+
// are legal: 1-12 is a man, +50 a woman, and since 2004 +20 marks a birth on a
|
|
512
|
+
// day whose sequence numbers were exhausted (so +70 is a woman on such a day).
|
|
513
|
+
// Whether `day` exists in `month`, February permissive at 29 because a
|
|
514
|
+
// two-digit year does not reveal the century.
|
|
515
|
+
function isRealDay(month, day) {
|
|
516
|
+
return month >= 1 && month <= 12 && day >= 1 && day <= RC_DAYS[month - 1];
|
|
517
|
+
}
|
|
518
|
+
function czechMonth(raw) {
|
|
519
|
+
for (const offset of [0, 20, 50, 70]) {
|
|
520
|
+
if (raw - offset >= 1 && raw - offset <= 12)
|
|
521
|
+
return raw - offset;
|
|
522
|
+
}
|
|
523
|
+
return null;
|
|
524
|
+
}
|
|
525
|
+
// The date is checked, not only the checksum. Without it the validator accepted
|
|
526
|
+
// an impossible month, and a Czech mobile number is the same shape -- nine
|
|
527
|
+
// digits opening 6 or 7 -- so any mobile that happened to pass mod 11 was typed
|
|
528
|
+
// NATIONAL_ID at high confidence: 606666032 (MM=66), 778836400 (MM=88),
|
|
529
|
+
// 728990603 (MM=89). 284 per corpus pass, the largest single false-positive
|
|
530
|
+
// bucket in the evaluation (rules-engine#37).
|
|
492
531
|
export function validateCzechBirthNumber(candidate) {
|
|
493
532
|
const c = candidate.replace(/[/\s]/g, "");
|
|
494
533
|
if (!/^\d+$/.test(c) || (c.length !== 9 && c.length !== 10))
|
|
495
534
|
return false;
|
|
535
|
+
const month = czechMonth(parseInt(c.slice(2, 4), 10));
|
|
536
|
+
if (month === null)
|
|
537
|
+
return false;
|
|
538
|
+
const day = parseInt(c.slice(4, 6), 10);
|
|
539
|
+
if (day < 1 || day > RC_DAYS[month - 1])
|
|
540
|
+
return false;
|
|
496
541
|
if (c.length === 10)
|
|
497
542
|
return parseInt(c) % 11 === 0;
|
|
498
543
|
return true;
|
|
499
544
|
}
|
|
545
|
+
// S YY MM DD JJ NNN C -- the leading S carries the century, so the month needs
|
|
546
|
+
// no offset convention here (rules-engine#43).
|
|
500
547
|
export function validateRomanianCnp(candidate) {
|
|
501
548
|
const c = candidate.replace(/[\s\-]/g, "");
|
|
502
549
|
if (c.length !== 13 || !/^\d{13}$/.test(c) || !"12345678".includes(c[0]))
|
|
503
550
|
return false;
|
|
551
|
+
if (!isRealDay(parseInt(c.slice(3, 5), 10), parseInt(c.slice(5, 7), 10)))
|
|
552
|
+
return false;
|
|
504
553
|
const key = "279146358279";
|
|
505
554
|
let total = 0;
|
|
506
555
|
for (let i = 0; i < 12; i++)
|
|
@@ -547,10 +596,14 @@ export function validateCroatianOib(candidate) {
|
|
|
547
596
|
const check = (11 - product) % 10;
|
|
548
597
|
return check === parseInt(c[10]);
|
|
549
598
|
}
|
|
599
|
+
// DD MM YYY RR BBB K -- note EMSO opens with the *day*, where the Czech, Polish
|
|
600
|
+
// and Romanian forms open with the year or a century digit (rules-engine#43).
|
|
550
601
|
export function validateSlovenianEmso(candidate) {
|
|
551
602
|
const c = candidate.replace(/[\s\-]/g, "");
|
|
552
603
|
if (c.length !== 13 || !/^\d{13}$/.test(c))
|
|
553
604
|
return false;
|
|
605
|
+
if (!isRealDay(parseInt(c.slice(2, 4), 10), parseInt(c.slice(0, 2), 10)))
|
|
606
|
+
return false;
|
|
554
607
|
const d = c.split("").map(Number);
|
|
555
608
|
const weights = [7, 6, 5, 4, 3, 2, 7, 6, 5, 4, 3, 2];
|
|
556
609
|
let total = 0;
|
package/dist/esm/sdk.d.ts
CHANGED
|
@@ -136,7 +136,16 @@ export declare class EuRedact {
|
|
|
136
136
|
/** The label function for one call, given its output options. */
|
|
137
137
|
private labelFor;
|
|
138
138
|
/**
|
|
139
|
-
*
|
|
139
|
+
* Run the rules here, then send what they left to the cloud tier.
|
|
140
|
+
*
|
|
141
|
+
* Local-first: the rules engine masks everything it can find on the caller's
|
|
142
|
+
* machine, and only that masked text is sent. The service looks for what has
|
|
143
|
+
* no shape to match on — names, employers, diagnoses — and answers with
|
|
144
|
+
* spans relative to the text it received, which are mapped back onto the
|
|
145
|
+
* original here.
|
|
146
|
+
*
|
|
147
|
+
* This is minimisation, not an exemption: what the rules miss, and the names
|
|
148
|
+
* and diagnoses the model is there to find, still travel.
|
|
140
149
|
*
|
|
141
150
|
* Options the service cannot honour throw rather than being ignored. Silently
|
|
142
151
|
* dropping one would mean returning a result that does not match what was
|
|
@@ -144,21 +153,9 @@ export declare class EuRedact {
|
|
|
144
153
|
* under-redaction wearing a plausible face.
|
|
145
154
|
*/
|
|
146
155
|
private redactViaCloud;
|
|
147
|
-
/**
|
|
148
|
-
* Rebuild the masked text from the service's spans, in place.
|
|
149
|
-
*
|
|
150
|
-
* The service masks with bracketed labels only. Any other output option is
|
|
151
|
-
* applied here, from its spans: the service builds its `redacted_text` from
|
|
152
|
-
* exactly the spans it returns (entities it reports but cannot place are
|
|
153
|
-
* listed separately and never applied), so nothing it masked is lost by
|
|
154
|
-
* masking again from the same spans.
|
|
155
|
-
*
|
|
156
|
-
* The service reports offsets in code points and this SDK slices UTF-16
|
|
157
|
-
* units, so the slice check is what stands between an emoji in the document
|
|
158
|
-
* and a rebuilt text with the wrong characters masked.
|
|
159
|
-
*/
|
|
160
|
-
private remaskCloudResult;
|
|
161
156
|
redact(text: string, options?: RedactOptions): RedactResult;
|
|
157
|
+
/** The local pipeline: normalise, detect, exempt, mask. */
|
|
158
|
+
private redactRules;
|
|
162
159
|
/**
|
|
163
160
|
* Redact, awaiting the cloud tier when `mode: "cloud"` is asked for.
|
|
164
161
|
*
|
package/dist/esm/sdk.js
CHANGED
|
@@ -48,6 +48,96 @@ export function applyReplacements(text, detections, labelFor) {
|
|
|
48
48
|
parts.push(text.slice(pos));
|
|
49
49
|
return parts.join("");
|
|
50
50
|
}
|
|
51
|
+
/**
|
|
52
|
+
* Bracket-mask `text` and record where each label landed.
|
|
53
|
+
*
|
|
54
|
+
* Returns what is sent to the cloud tier, plus one `WireLabel` per label.
|
|
55
|
+
* Everything between labels is copied unchanged, so the labels are all it
|
|
56
|
+
* takes to carry an offset from the masked text back to the original.
|
|
57
|
+
*
|
|
58
|
+
* Always `[TYPE]`, whatever label scheme the caller asked for. That is the
|
|
59
|
+
* syntax the model was trained on, where a placeholder means "already handled,
|
|
60
|
+
* do not list it". A token or a numbered label would be read as ordinary text,
|
|
61
|
+
* so the format on the wire is a model contract and not an output option.
|
|
62
|
+
*/
|
|
63
|
+
function maskForCloud(text, detections) {
|
|
64
|
+
const parts = [];
|
|
65
|
+
const labels = [];
|
|
66
|
+
let pos = 0;
|
|
67
|
+
let out = 0;
|
|
68
|
+
for (const det of detections) {
|
|
69
|
+
const start = Math.max(det.start, pos);
|
|
70
|
+
if (det.end <= start)
|
|
71
|
+
continue;
|
|
72
|
+
const label = `[${det.entityType}]`;
|
|
73
|
+
out += start - pos;
|
|
74
|
+
parts.push(text.slice(pos, start), label);
|
|
75
|
+
labels.push([out, out + label.length, start, det.end]);
|
|
76
|
+
out += label.length;
|
|
77
|
+
pos = det.end;
|
|
78
|
+
}
|
|
79
|
+
parts.push(text.slice(pos));
|
|
80
|
+
return [parts.join(""), labels];
|
|
81
|
+
}
|
|
82
|
+
/** Index of the last label starting at or before `offset` — strictly before
|
|
83
|
+
* when `strict` — or -1. */
|
|
84
|
+
function labelBefore(labels, offset, strict) {
|
|
85
|
+
let lo = 0;
|
|
86
|
+
let hi = labels.length;
|
|
87
|
+
while (lo < hi) {
|
|
88
|
+
const mid = (lo + hi) >>> 1;
|
|
89
|
+
if (strict ? labels[mid][0] < offset : labels[mid][0] <= offset)
|
|
90
|
+
lo = mid + 1;
|
|
91
|
+
else
|
|
92
|
+
hi = mid;
|
|
93
|
+
}
|
|
94
|
+
return lo - 1;
|
|
95
|
+
}
|
|
96
|
+
/**
|
|
97
|
+
* Carry the service's spans from the masked text onto the original.
|
|
98
|
+
*
|
|
99
|
+
* The service only ever saw `masked`, so its offsets index that. An offset in
|
|
100
|
+
* copied text shifts by whatever the labels before it added or removed. An
|
|
101
|
+
* offset inside a label has no counterpart in the original, so it snaps
|
|
102
|
+
* outward to the edge of the value the label replaced: an address the model
|
|
103
|
+
* reports around `[POSTAL_CODE]` covers the postal code, never half of it.
|
|
104
|
+
* Over-masking is the safe direction.
|
|
105
|
+
*
|
|
106
|
+
* A span lying wholly inside one label describes the label, not the document.
|
|
107
|
+
* It is dropped; the local detection behind the label stands.
|
|
108
|
+
*
|
|
109
|
+
* Throws `Unplaceable` when a span does not match the text that was sent. Such
|
|
110
|
+
* a span cannot be placed, and masking where it points would cover the wrong
|
|
111
|
+
* characters and leave the right ones in the clear.
|
|
112
|
+
*/
|
|
113
|
+
function ontoOriginal(spans, masked, text, labels, Unplaceable) {
|
|
114
|
+
const placed = [];
|
|
115
|
+
for (const span of spans) {
|
|
116
|
+
if (!(0 <= span.start && span.start <= span.end && span.end <= masked.length) ||
|
|
117
|
+
masked.slice(span.start, span.end) !== span.text) {
|
|
118
|
+
throw new Unplaceable("span offsets do not match the text that was sent; cannot place the " +
|
|
119
|
+
"service's detections in the document");
|
|
120
|
+
}
|
|
121
|
+
if (span.start === span.end)
|
|
122
|
+
continue;
|
|
123
|
+
let start = span.start;
|
|
124
|
+
let at = labelBefore(labels, span.start, false);
|
|
125
|
+
if (at >= 0) {
|
|
126
|
+
const [, maskedEnd, origStart, origEnd] = labels[at];
|
|
127
|
+
if (span.end <= maskedEnd)
|
|
128
|
+
continue;
|
|
129
|
+
start = span.start < maskedEnd ? origStart : origEnd + span.start - maskedEnd;
|
|
130
|
+
}
|
|
131
|
+
let end = span.end;
|
|
132
|
+
at = labelBefore(labels, span.end, true);
|
|
133
|
+
if (at >= 0) {
|
|
134
|
+
const [, maskedEnd, , origEnd] = labels[at];
|
|
135
|
+
end = origEnd + Math.max(0, span.end - maskedEnd);
|
|
136
|
+
}
|
|
137
|
+
placed.push({ ...span, start, end, text: text.slice(start, end) });
|
|
138
|
+
}
|
|
139
|
+
return placed;
|
|
140
|
+
}
|
|
51
141
|
/**
|
|
52
142
|
* Characters a token suffix is drawn from. No vowels, so a suffix never spells
|
|
53
143
|
* a word; no 0/1/I/O, so it survives being read back by a person; no
|
|
@@ -386,7 +476,16 @@ export class EuRedact {
|
|
|
386
476
|
return (det) => `[${det.entityType}]`;
|
|
387
477
|
}
|
|
388
478
|
/**
|
|
389
|
-
*
|
|
479
|
+
* Run the rules here, then send what they left to the cloud tier.
|
|
480
|
+
*
|
|
481
|
+
* Local-first: the rules engine masks everything it can find on the caller's
|
|
482
|
+
* machine, and only that masked text is sent. The service looks for what has
|
|
483
|
+
* no shape to match on — names, employers, diagnoses — and answers with
|
|
484
|
+
* spans relative to the text it received, which are mapped back onto the
|
|
485
|
+
* original here.
|
|
486
|
+
*
|
|
487
|
+
* This is minimisation, not an exemption: what the rules miss, and the names
|
|
488
|
+
* and diagnoses the model is there to find, still travel.
|
|
390
489
|
*
|
|
391
490
|
* Options the service cannot honour throw rather than being ignored. Silently
|
|
392
491
|
* dropping one would mean returning a result that does not match what was
|
|
@@ -395,6 +494,7 @@ export class EuRedact {
|
|
|
395
494
|
*/
|
|
396
495
|
async redactViaCloud(text, options) {
|
|
397
496
|
const { CloudClient } = await import("./cloud/client.js");
|
|
497
|
+
const { CloudError } = await import("./cloud/errors.js");
|
|
398
498
|
const countries = options.countries ?? null;
|
|
399
499
|
if (!countries || countries.length !== 1) {
|
|
400
500
|
throw new Error('cloud mode needs exactly one country, e.g. { countries: ["BE"] }. The ' +
|
|
@@ -412,43 +512,34 @@ export class EuRedact {
|
|
|
412
512
|
if (options.referentialIntegrity) {
|
|
413
513
|
throw new Error("referentialIntegrity is not supported in cloud mode");
|
|
414
514
|
}
|
|
415
|
-
// detectDates is deliberately NOT forwarded. The service always runs its
|
|
416
|
-
// rules engine with dates on, because that is what the model was trained
|
|
417
|
-
// against; the caller's value cannot change that. It is the one ignored
|
|
418
|
-
// option that is safe to ignore — it can only cause MORE to be detected,
|
|
419
|
-
// never less, so it cannot produce under-redaction.
|
|
420
515
|
const allowed = this.allowedFor(options);
|
|
421
516
|
const domains = this.domainsFor(options);
|
|
422
|
-
const
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
}
|
|
448
|
-
[result.detections, result.exempted] = applyAllowlist(text, result.detections, opts.allowed, opts.domains);
|
|
449
|
-
const tokenMapper = opts.tokenize ? new TokenMapper(text, result.detections) : null;
|
|
450
|
-
result.redactedText = applyReplacements(text, result.detections, this.labelFor(false, tokenMapper));
|
|
451
|
-
result.tokens = tokenMapper ? tokenMapper.tokens : {};
|
|
517
|
+
const client = new CloudClient();
|
|
518
|
+
// Dates are always on, whatever detectDates says: that is the rules output
|
|
519
|
+
// the model was trained against, and a date of birth the rules can place
|
|
520
|
+
// has no reason to travel. It is the one ignored option that is safe to
|
|
521
|
+
// ignore — it can only cause MORE to be detected, never less.
|
|
522
|
+
//
|
|
523
|
+
// No allowlist and no tokens on this pass. An exemption says what the
|
|
524
|
+
// caller wants back, not what may leave; both are applied below, after the
|
|
525
|
+
// response, to the merged spans.
|
|
526
|
+
const local = this.redactRules(text, { countries, detectDates: true, cache: options.cache }, new Map(), new Map());
|
|
527
|
+
const [masked, labels] = maskForCloud(text, local.detections);
|
|
528
|
+
// The client converts the service's code-point offsets to UTF-16 units of
|
|
529
|
+
// the text it sent, so everything from here on is in one unit.
|
|
530
|
+
const remote = await client.redact(masked, { country: countries[0] });
|
|
531
|
+
// A new array throughout: `local` may be the cached rules result.
|
|
532
|
+
const [detections, exempted] = applyAllowlist(text, [...local.detections, ...ontoOriginal(remote.detections, masked, text, labels, CloudError)], allowed, domains);
|
|
533
|
+
detections.sort((a, b) => a.start - b.start || b.end - a.end);
|
|
534
|
+
const tokenMapper = options.tokenize ? new TokenMapper(text, detections) : null;
|
|
535
|
+
return {
|
|
536
|
+
...local,
|
|
537
|
+
redactedText: applyReplacements(text, detections, this.labelFor(false, tokenMapper)),
|
|
538
|
+
detections,
|
|
539
|
+
source: "cloud",
|
|
540
|
+
tokens: tokenMapper ? tokenMapper.tokens : {},
|
|
541
|
+
exempted,
|
|
542
|
+
};
|
|
452
543
|
}
|
|
453
544
|
redact(text, options = {}) {
|
|
454
545
|
checkCountryArg(options.countries, "countries");
|
|
@@ -469,12 +560,16 @@ export class EuRedact {
|
|
|
469
560
|
if (requestedMode !== "rules") {
|
|
470
561
|
throw new Error(`unknown mode ${JSON.stringify(requestedMode)}: expected "rules" or "cloud"`);
|
|
471
562
|
}
|
|
563
|
+
return this.redactRules(text, options, allowed, domains);
|
|
564
|
+
}
|
|
565
|
+
/** The local pipeline: normalise, detect, exempt, mask. */
|
|
566
|
+
redactRules(text, options, allowed, domains) {
|
|
472
567
|
if (text.length > this.maxInputLength) {
|
|
473
568
|
throw new Error(`Input text length (${text.length.toLocaleString()} chars) exceeds the maximum ` +
|
|
474
569
|
`(${this.maxInputLength.toLocaleString()} chars). Split the input or ` +
|
|
475
570
|
`increase maxInputLength when constructing EuRedact.`);
|
|
476
571
|
}
|
|
477
|
-
const { countries = null, countryHint = null, context = null, chunkOffset = 0,
|
|
572
|
+
const { countries = null, countryHint = null, context = null, chunkOffset = 0, referentialIntegrity = false, tokenize = false, detectDates = false, } = options;
|
|
478
573
|
// A context makes the result depend on evidence from other chunks, so the
|
|
479
574
|
// text no longer identifies the result. Caching is disabled rather than
|
|
480
575
|
// keyed on the context, whose contents change as chunks arrive.
|
|
@@ -492,7 +587,7 @@ export class EuRedact {
|
|
|
492
587
|
// separator, and two different lists must never share a key.
|
|
493
588
|
const allowKey = (allowed.size ? JSON.stringify([...allowed.keys()].sort()) : "")
|
|
494
589
|
+ (domains.size ? "|dom=" + JSON.stringify([...domains.keys()].sort()) : "");
|
|
495
|
-
const cacheMode =
|
|
590
|
+
const cacheMode = `rules|dates=${detectDates}|hint=${hintKey}|ri=${referentialIntegrity}|tok=${tokenize}|allow=${allowKey}`;
|
|
496
591
|
let cacheKey;
|
|
497
592
|
if (cache) {
|
|
498
593
|
cacheKey = this.cache.key(normalizedText, countriesTuple, cacheMode);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "euredact",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.6.0",
|
|
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",
|