euredact 0.3.5 → 0.3.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/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()]
@@ -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),
@@ -1,6 +1,6 @@
1
1
  import { DetectionSource, EntityType } from "../types.js";
2
2
  import { MultiPatternMatcher } from "./matchers.js";
3
- import { shouldSuppress } from "./suppressors.js";
3
+ import { shouldSuppress, SuppressionScratch } from "./suppressors.js";
4
4
  import { detectStructuralDob } from "./structural.js";
5
5
  import { COUNTRY_CONFIGS } from "./countries/index.js";
6
6
  import * as evidenceMod from "./evidence.js";
@@ -273,6 +273,14 @@ export class RuleEngine {
273
273
  const allFailed = failedSpans
274
274
  .map(f => [f.start, f.end])
275
275
  .sort((a, b) => a[0] - b[0] || a[1] - b[1]);
276
+ // Running maximum of the end offsets, so the fragment test below can ask
277
+ // "does any span starting at or before me reach past my end?" in O(1)
278
+ // instead of walking the prefix per candidate.
279
+ const failedStarts = allFailed.map(f => f[0]);
280
+ const failedPrefixMax = new Array(allFailed.length + 1).fill(0);
281
+ for (let i = 0; i < allFailed.length; i++) {
282
+ failedPrefixMax[i + 1] = Math.max(failedPrefixMax[i], allFailed[i][1]);
283
+ }
276
284
  const mergedZones = new Map();
277
285
  for (const [etype, spans] of zonesByType) {
278
286
  spans.sort((a, b) => a[0] - b[0] || a[1] - b[1]);
@@ -295,8 +303,11 @@ export class RuleEngine {
295
303
  mergedZones.set(etype, { starts, ends });
296
304
  }
297
305
  const candidates = [];
306
+ // Whole-document facts a suppressor may need, derived at most once per call
307
+ // and discarded with it.
308
+ const scratch = new SuppressionScratch(text);
298
309
  for (const { match, hasValidValidator } of validated) {
299
- if (shouldSuppress(text, match))
310
+ if (shouldSuppress(text, match, scratch))
300
311
  continue;
301
312
  // A validator-less match inside a failed-validation span of its own type
302
313
  // is demoted below every other candidate rather than deleted. Demotion
@@ -324,13 +335,31 @@ export class RuleEngine {
324
335
  // default — the generic phone pattern kept claiming the tail of the
325
336
  // rejected national number.
326
337
  if (match.patternDef.validator === null && !demoted) {
338
+ // Upper bound of the spans starting at or before match.start.
339
+ let lo = 0, hi = failedStarts.length;
340
+ while (lo < hi) {
341
+ const mid = (lo + hi) >>> 1;
342
+ if (failedStarts[mid] <= match.start)
343
+ lo = mid + 1;
344
+ else
345
+ hi = mid;
346
+ }
327
347
  let fragment = false;
328
- for (const [zs, ze] of allFailed) {
329
- if (zs > match.start)
330
- break;
331
- if (match.end <= ze && (match.start > zs || match.end < ze)) {
332
- fragment = true;
333
- break;
348
+ const reach = failedPrefixMax[lo];
349
+ if (reach > match.end) {
350
+ // Something starting no later than us ends strictly later: we are a
351
+ // proper subset of it.
352
+ fragment = true;
353
+ }
354
+ else if (reach === match.end) {
355
+ // Same end offset — a proper subset only if some span with that end
356
+ // starts strictly earlier. Scanning backwards finds the enclosing
357
+ // span first, and the equal case is rare.
358
+ for (let i = lo - 1; i >= 0; i--) {
359
+ if (allFailed[i][1] === match.end && allFailed[i][0] < match.start) {
360
+ fragment = true;
361
+ break;
362
+ }
334
363
  }
335
364
  }
336
365
  if (fragment)
@@ -381,6 +410,7 @@ export class RuleEngine {
381
410
  length: match.end - match.start,
382
411
  countryScore: countryScores.get(match.countryCode) ?? 0,
383
412
  inScope: declared === null || declared.has(match.countryCode) ? 1 : 0,
413
+ tier: 0,
384
414
  det: {
385
415
  entityType: match.patternDef.entityType,
386
416
  start: match.start,
@@ -395,7 +425,7 @@ export class RuleEngine {
395
425
  }
396
426
  for (const d of detectStructuralDob(text)) {
397
427
  candidates.push({ blindPriority: 1, cue: 0, priority: 1, length: d.end - d.start,
398
- countryScore: 0, inScope: 1, det: d });
428
+ countryScore: 0, inScope: 1, tier: 0, det: d });
399
429
  }
400
430
  const detections = this.deduplicate(candidates);
401
431
  detections.sort((a, b) => a.start - b.start || b.end - a.end);
@@ -429,7 +459,10 @@ export class RuleEngine {
429
459
  if (seen === undefined || c.blindPriority > seen)
430
460
  spanTier.set(key, c.blindPriority);
431
461
  }
432
- const tierOf = (c) => spanTier.get(`${c.det.start}:${c.det.end}`);
462
+ // Resolved once per candidate rather than inside the comparator, which ran
463
+ // it O(n log n) times and rebuilt the key string on every comparison.
464
+ for (const c of candidates)
465
+ c.tier = spanTier.get(`${c.det.start}:${c.det.end}`);
433
466
  // Span length outranks *everything*, including validation.
434
467
  //
435
468
  // A shorter candidate winning re-cuts a longer one and leaves the
@@ -455,7 +488,7 @@ export class RuleEngine {
455
488
  // country-blind up to and including `start`, and (length, start) already
456
489
  // identifies a span uniquely.
457
490
  const sorted = [...candidates].sort((a, b) => b.length - a.length || // longest first } country-blind
458
- tierOf(b) - tierOf(a) || // best tier here } — decides
491
+ b.tier - a.tier || // best tier here } — decides
459
492
  a.det.start - b.det.start || // leftmost } WHICH span
460
493
  b.cue - a.cue || // local cue } country-aware
461
494
  b.priority - a.priority || // country priority } — decides the
@@ -6,4 +6,17 @@ export interface RawMatch {
6
6
  patternDef: PatternDef;
7
7
  countryCode: string;
8
8
  }
9
- export declare function shouldSuppress(text: string, match: RawMatch): boolean;
9
+ /**
10
+ * Per-document scratch space for suppressors that would otherwise re-derive the
11
+ * same whole-document facts for every candidate.
12
+ *
13
+ * Built lazily and held only for the duration of one `detect()` call, so no
14
+ * document text outlives the call that supplied it.
15
+ */
16
+ export declare class SuppressionScratch {
17
+ private readonly text;
18
+ private words;
19
+ constructor(text: string);
20
+ documentWords(): Set<string>;
21
+ }
22
+ export declare function shouldSuppress(text: string, match: RawMatch, scratch?: SuppressionScratch): boolean;