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.
@@ -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
- // The leading \b is load-bearing. Without it the short alternatives match the
47
- // *tail* of an ordinary word: "NIS" matched "tālru**nis** ", the Latvian for
48
- // "telephone", so every Latvian phone number was suppressed as though it sat
49
- // behind a Belgian national-ID label. 653 misses from a missing word boundary.
50
- const ID_LABEL_BEFORE = /\b(?:BSN|RR|NN|NIR|INSZ|NISS|NIS|Steuer-?ID|TIN|NIF|NIE|SSN|rijksregisternummer|numéro\s*national|national\s*number|matricule|Ausweisnummer|Personalausweis|Versichertennummer|KVNR|KV-Nr|Steuernummer|St\.\-Nr|StNr|Finanzamt\s+ist|Ondernemingen\s+onder\s+nummer|ondernemingsnummer|numéro\s*d'entreprise|enterprise\s*number|Kruispuntbank)\s*[:.]?\s*$/i;
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 = 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]);
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 = new Set([types_js_1.EntityType.PHONE, types_js_1.EntityType.NATIONAL_ID, types_js_1.EntityType.POSTAL_CODE]);
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
- function occursAsLowercaseWord(text, token) {
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
- for (const form of [lower, capitalised]) {
560
- // Cheap pre-check: a plain substring test almost always answers "no".
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
- const ID_CUE_BEFORE = /(?:[\w\-]*Nr|[\w\-]*N[°ºo]|[\w\-]*Nummer|[\w\-]*Numero|[\w\-]*Numéro|No|number|num|Kennzahl|Aktenzeichen|Az|e-?card|Polizze|Police|Policen)\.?\s*:?\s*$/i;
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, suppressPhoneAfterIdLabel, suppressPhoneServiceNumber, suppressPhoneDateOverlap, suppressPhoneAsNumberRange],
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
- let redacted = text;
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
- const replacement = referentialIntegrity
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";
@@ -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
- constructor(maxsize?: number, enabled?: boolean);
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
- // Simple hash function (FNV-1a) - no crypto dependency needed for cache keys
2
- function fnv1aHash(str) {
3
- let hash = 0x811c9dc5;
4
- for (let i = 0; i < str.length; i++) {
5
- hash ^= str.charCodeAt(i);
6
- hash = (hash * 0x01000193) >>> 0;
7
- }
8
- return hash.toString(16).padStart(8, "0");
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
- constructor(maxsize = 1024, enabled = true) {
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
- const raw = `${text}|${sorted.join("|")}|${mode}`;
21
- // Include raw length to reduce collision risk alongside dual FNV-1a hashes
22
- const h1 = fnv1aHash(raw);
23
- const h2 = fnv1aHash(raw.slice(Math.floor(raw.length / 2)) + raw.slice(0, Math.floor(raw.length / 2)));
24
- return `${raw.length}:${h1}${h2}`;
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
- this.store.delete(key);
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
- while (this.store.size > this.maxsize) {
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 !== undefined)
46
- this.store.delete(first);
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@jansen.nl"
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@jansen.nl"
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
- p(EntityType.SECRET, String.raw `\b[A-Za-z0-9_\-+/]{24,}[A-Za-z0-9_\-+/=]*\b`, "high_entropy", "High-entropy token", SECRET_CONTEXT, true),
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;