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.
@@ -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 if postal/address context nearby
334
- if (POSTAL_CONTEXT_NEAR.test(context))
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
- const COUNTRY_PREFIXED = /(?:^|[^A-Za-z0-9])[A-Z]{1,2}$/;
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
- if (ID_CUE_BEFORE.test(text.slice(Math.max(0, match.start - 40), match.start)))
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
- * Send a document to the cloud tier.
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
- * Send a document to the cloud tier.
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 result = await new CloudClient().redact(text, { country: countries[0] });
423
- if (options.tokenize || allowed.size > 0 || domains.size > 0) {
424
- await this.remaskCloudResult(result, text, { tokenize: options.tokenize ?? false, allowed, domains });
425
- }
426
- return result;
427
- }
428
- /**
429
- * Rebuild the masked text from the service's spans, in place.
430
- *
431
- * The service masks with bracketed labels only. Any other output option is
432
- * applied here, from its spans: the service builds its `redacted_text` from
433
- * exactly the spans it returns (entities it reports but cannot place are
434
- * listed separately and never applied), so nothing it masked is lost by
435
- * masking again from the same spans.
436
- *
437
- * The service reports offsets in code points and this SDK slices UTF-16
438
- * units, so the slice check is what stands between an emoji in the document
439
- * and a rebuilt text with the wrong characters masked.
440
- */
441
- async remaskCloudResult(result, text, opts) {
442
- const { CloudError } = await import("./cloud/errors.js");
443
- for (const det of result.detections) {
444
- if (text.slice(det.start, det.end) !== det.text) {
445
- throw new CloudError("span offsets do not match the document; cannot apply tokenize/allowlist locally");
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, mode = "rules", referentialIntegrity = false, tokenize = false, detectDates = false, } = options;
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 = `${mode}|dates=${detectDates}|hint=${hintKey}|ri=${referentialIntegrity}|tok=${tokenize}|allow=${allowKey}`;
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.5.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",