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/README.md +1 -1
- package/dist/cjs/cache.js +52 -18
- package/dist/cjs/rules/context.js +1 -1
- package/dist/cjs/rules/countries/index.js +14 -1
- package/dist/cjs/rules/engine.js +43 -10
- package/dist/cjs/rules/suppressors.js +169 -36
- package/dist/cjs/rules/validators.js +12 -0
- package/dist/cjs/sdk.js +45 -3
- package/dist/esm/cache.d.ts +5 -1
- package/dist/esm/cache.js +52 -18
- package/dist/esm/rules/context.d.ts +1 -1
- package/dist/esm/rules/context.js +1 -1
- package/dist/esm/rules/countries/index.js +14 -1
- package/dist/esm/rules/engine.js +44 -11
- package/dist/esm/rules/suppressors.d.ts +14 -1
- package/dist/esm/rules/suppressors.js +167 -36
- package/dist/esm/rules/validators.d.ts +12 -0
- package/dist/esm/rules/validators.js +12 -0
- package/dist/esm/sdk.js +45 -3
- package/package.json +8 -3
package/dist/cjs/sdk.js
CHANGED
|
@@ -7,20 +7,44 @@ const engine_js_1 = require("./rules/engine.js");
|
|
|
7
7
|
const evidence_js_1 = require("./rules/evidence.js");
|
|
8
8
|
const types_js_1 = require("./types.js");
|
|
9
9
|
const DATE_TYPES = new Set([types_js_1.EntityType.DOB, types_js_1.EntityType.DATE_OF_DEATH]);
|
|
10
|
+
/** Entry count at which a one-time warning is emitted. */
|
|
11
|
+
const MAPPING_WARN_THRESHOLD = 100_000;
|
|
12
|
+
/**
|
|
13
|
+
* Maps real PII values to consistent labels within a session.
|
|
14
|
+
*
|
|
15
|
+
* The mapping is keyed on the **raw PII value** and is never evicted — evicting
|
|
16
|
+
* would hand a previously seen value a second label and quietly break
|
|
17
|
+
* referential integrity. Two consequences worth designing around:
|
|
18
|
+
*
|
|
19
|
+
* - It grows for as long as the process runs. Call `clear()` between workloads;
|
|
20
|
+
* a warning is emitted once the mapping passes `MAPPING_WARN_THRESHOLD`.
|
|
21
|
+
* - Labels are shared by every caller of the same instance, including every
|
|
22
|
+
* caller of the module-level `redact()`. A label repeated across two
|
|
23
|
+
* documents reveals that they contain the same underlying value, so give each
|
|
24
|
+
* tenant its own `EuRedact` instance rather than sharing the module-level one.
|
|
25
|
+
*/
|
|
10
26
|
class ReferentialMapper {
|
|
11
27
|
counters = new Map();
|
|
12
28
|
mapping = new Map();
|
|
29
|
+
warned = false;
|
|
13
30
|
getLabel(text, entityType) {
|
|
14
31
|
if (!this.mapping.has(text)) {
|
|
15
32
|
const count = (this.counters.get(entityType) ?? 0) + 1;
|
|
16
33
|
this.counters.set(entityType, count);
|
|
17
34
|
this.mapping.set(text, `${entityType}_${count}`);
|
|
35
|
+
if (!this.warned && this.mapping.size > MAPPING_WARN_THRESHOLD) {
|
|
36
|
+
this.warned = true;
|
|
37
|
+
console.warn(`[euredact] Referential integrity mapping holds ${this.mapping.size} ` +
|
|
38
|
+
`raw PII values and is never evicted. Call clear() between workloads ` +
|
|
39
|
+
`to release them.`);
|
|
40
|
+
}
|
|
18
41
|
}
|
|
19
42
|
return this.mapping.get(text);
|
|
20
43
|
}
|
|
21
44
|
clear() {
|
|
22
45
|
this.counters.clear();
|
|
23
46
|
this.mapping.clear();
|
|
47
|
+
this.warned = false;
|
|
24
48
|
}
|
|
25
49
|
}
|
|
26
50
|
const DEFAULT_MAX_INPUT_LENGTH = 10_485_760; // ~10 MB of text
|
|
@@ -110,14 +134,32 @@ class EuRedact {
|
|
|
110
134
|
detections = detections.filter(d => !DATE_TYPES.has(d.entityType));
|
|
111
135
|
}
|
|
112
136
|
detections.sort((a, b) => a.start - b.start || b.end - a.end);
|
|
113
|
-
|
|
137
|
+
// Labels are resolved right-to-left because the referential mapper numbers
|
|
138
|
+
// each entity type in call order, and that order is part of the output
|
|
139
|
+
// contract. The string itself is then assembled in a single forward pass:
|
|
140
|
+
// rebuilding it per detection copied the whole document each time, which is
|
|
141
|
+
// O(document x detections) — 1.68 s of pure copying on a 1 MB document with
|
|
142
|
+
// 15,000 detections, versus 2 ms here.
|
|
143
|
+
const replacements = new Array(detections.length);
|
|
114
144
|
for (let i = detections.length - 1; i >= 0; i--) {
|
|
115
145
|
const det = detections[i];
|
|
116
|
-
|
|
146
|
+
replacements[i] = referentialIntegrity
|
|
117
147
|
? this.referentialMapper.getLabel(det.text, det.entityType)
|
|
118
148
|
: `[${det.entityType}]`;
|
|
119
|
-
redacted = redacted.slice(0, det.start) + replacement + redacted.slice(det.end);
|
|
120
149
|
}
|
|
150
|
+
const parts = [];
|
|
151
|
+
let pos = 0;
|
|
152
|
+
for (let i = 0; i < detections.length; i++) {
|
|
153
|
+
const det = detections[i];
|
|
154
|
+
// Spans reach here deduplicated and non-overlapping; one starting behind
|
|
155
|
+
// the cursor would splice into the middle of a label, so drop it.
|
|
156
|
+
if (det.start < pos)
|
|
157
|
+
continue;
|
|
158
|
+
parts.push(text.slice(pos, det.start), replacements[i]);
|
|
159
|
+
pos = det.end;
|
|
160
|
+
}
|
|
161
|
+
parts.push(text.slice(pos));
|
|
162
|
+
const redacted = parts.join("");
|
|
121
163
|
// Report the inference so it can be audited. Spans in `evidence` are
|
|
122
164
|
// offsets into the normalised text, matching `detections`.
|
|
123
165
|
const inferredCountries = [...(0, evidence_js_1.weightsToRanking)(scores).entries()]
|
package/dist/esm/cache.d.ts
CHANGED
|
@@ -1,11 +1,15 @@
|
|
|
1
1
|
import type { RedactResult } from "./types.js";
|
|
2
2
|
export declare class ResultCache {
|
|
3
3
|
private maxsize;
|
|
4
|
+
private maxChars;
|
|
4
5
|
private enabled;
|
|
5
6
|
private store;
|
|
6
|
-
|
|
7
|
+
private sizes;
|
|
8
|
+
private chars;
|
|
9
|
+
constructor(maxsize?: number, enabled?: boolean, maxChars?: number);
|
|
7
10
|
key(text: string, countries: string[], mode: string): string;
|
|
8
11
|
get(key: string): RedactResult | null;
|
|
9
12
|
put(key: string, result: RedactResult): void;
|
|
13
|
+
private evict;
|
|
10
14
|
clear(): void;
|
|
11
15
|
}
|
package/dist/esm/cache.js
CHANGED
|
@@ -1,27 +1,41 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
// Default ceiling on retained characters, not entries. A count-only cap of
|
|
3
|
+
// 1024 entries says nothing about memory: 1024 cached 1 MB documents is a
|
|
4
|
+
// gigabyte of PII-bearing text held live. ~16M characters is a few tens of MB.
|
|
5
|
+
const DEFAULT_MAX_CHARS = 16_000_000;
|
|
6
|
+
/** Approximate retained size of a cached result, in characters. */
|
|
7
|
+
function resultChars(result) {
|
|
8
|
+
let chars = result.redactedText.length;
|
|
9
|
+
for (const d of result.detections)
|
|
10
|
+
chars += d.text.length;
|
|
11
|
+
return chars;
|
|
9
12
|
}
|
|
10
13
|
export class ResultCache {
|
|
11
14
|
maxsize;
|
|
15
|
+
maxChars;
|
|
12
16
|
enabled;
|
|
13
17
|
store = new Map();
|
|
14
|
-
|
|
18
|
+
sizes = new Map();
|
|
19
|
+
chars = 0;
|
|
20
|
+
constructor(maxsize = 1024, enabled = true, maxChars = DEFAULT_MAX_CHARS) {
|
|
15
21
|
this.maxsize = maxsize;
|
|
22
|
+
this.maxChars = maxChars;
|
|
16
23
|
this.enabled = enabled;
|
|
17
24
|
}
|
|
18
25
|
key(text, countries, mode) {
|
|
19
26
|
const sorted = [...countries].sort();
|
|
20
|
-
|
|
21
|
-
//
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
27
|
+
// SHA-256, matching the Python implementation. The previous dual FNV-1a
|
|
28
|
+
// was non-cryptographic and collidable by construction: in a shared-process
|
|
29
|
+
// deployment an attacker who can guess a victim's document could submit a
|
|
30
|
+
// same-length colliding one and be served the victim's cached result,
|
|
31
|
+
// detections and raw matched PII included.
|
|
32
|
+
return createHash("sha256")
|
|
33
|
+
.update(text)
|
|
34
|
+
.update("|")
|
|
35
|
+
.update(sorted.join("|"))
|
|
36
|
+
.update("|")
|
|
37
|
+
.update(mode)
|
|
38
|
+
.digest("hex");
|
|
25
39
|
}
|
|
26
40
|
get(key) {
|
|
27
41
|
if (!this.enabled)
|
|
@@ -38,15 +52,35 @@ export class ResultCache {
|
|
|
38
52
|
put(key, result) {
|
|
39
53
|
if (!this.enabled)
|
|
40
54
|
return;
|
|
41
|
-
|
|
55
|
+
const size = resultChars(result);
|
|
56
|
+
// A single result bigger than the whole budget would evict everything else
|
|
57
|
+
// and still not fit; skip it rather than empty the cache for one document.
|
|
58
|
+
if (size > this.maxChars) {
|
|
59
|
+
this.evict(key);
|
|
60
|
+
return;
|
|
61
|
+
}
|
|
62
|
+
this.evict(key);
|
|
42
63
|
this.store.set(key, result);
|
|
43
|
-
|
|
64
|
+
this.sizes.set(key, size);
|
|
65
|
+
this.chars += size;
|
|
66
|
+
while (this.store.size > this.maxsize || this.chars > this.maxChars) {
|
|
44
67
|
const first = this.store.keys().next().value;
|
|
45
|
-
if (first
|
|
46
|
-
|
|
68
|
+
if (first === undefined)
|
|
69
|
+
break;
|
|
70
|
+
this.evict(first);
|
|
47
71
|
}
|
|
48
72
|
}
|
|
73
|
+
evict(key) {
|
|
74
|
+
const size = this.sizes.get(key);
|
|
75
|
+
if (size !== undefined) {
|
|
76
|
+
this.chars -= size;
|
|
77
|
+
this.sizes.delete(key);
|
|
78
|
+
}
|
|
79
|
+
this.store.delete(key);
|
|
80
|
+
}
|
|
49
81
|
clear() {
|
|
50
82
|
this.store.clear();
|
|
83
|
+
this.sizes.clear();
|
|
84
|
+
this.chars = 0;
|
|
51
85
|
}
|
|
52
86
|
}
|
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
* contain no IBAN, no `+CC` number and no ccTLD infers nothing at all, even
|
|
7
7
|
* when the first page identified the document beyond doubt:
|
|
8
8
|
*
|
|
9
|
-
* page 1 "Factuur — IBAN NL91ABNA0417164300, info@
|
|
9
|
+
* page 1 "Factuur — IBAN NL91ABNA0417164300, info@example.nl"
|
|
10
10
|
* page 7 "Telefoon 0612345678" <- Dutch mobile, or Danish CPR?
|
|
11
11
|
*
|
|
12
12
|
* Scanned alone, page 7 has nothing to go on. Sharing a context across the two
|
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
* contain no IBAN, no `+CC` number and no ccTLD infers nothing at all, even
|
|
7
7
|
* when the first page identified the document beyond doubt:
|
|
8
8
|
*
|
|
9
|
-
* page 1 "Factuur — IBAN NL91ABNA0417164300, info@
|
|
9
|
+
* page 1 "Factuur — IBAN NL91ABNA0417164300, info@example.nl"
|
|
10
10
|
* page 7 "Telefoon 0612345678" <- Dutch mobile, or Danish CPR?
|
|
11
11
|
*
|
|
12
12
|
* Scanned alone, page 7 has nothing to go on. Sharing a context across the two
|
|
@@ -157,7 +157,20 @@ const SHARED = {
|
|
|
157
157
|
// --- Secret / API Key (assignment-based: KEY=value or KEY: value) ---
|
|
158
158
|
p(EntityType.SECRET, String.raw `(?<=[:=] )\S{8,}|(?<=[:=])\S{8,}`, "high_entropy", "Assigned secret value", SECRET_CONTEXT, true),
|
|
159
159
|
// --- Secret / API Key (entropy-based fallback for longer tokens) ---
|
|
160
|
-
|
|
160
|
+
// Only the *trailing* \b is replaced. It could not match when the token run
|
|
161
|
+
// ended on "-", "+" or "/", so the engine backtracked across two
|
|
162
|
+
// overlapping quantified classes: an ordinary dashed rule line ("x-----...")
|
|
163
|
+
// cost O(n^2), 8.3 s for 80 KB. The lookahead simply asserts the run is
|
|
164
|
+
// over, which is what \b was there to express, and it cannot fail after a
|
|
165
|
+
// maximal run — so there is nothing to backtrack into.
|
|
166
|
+
//
|
|
167
|
+
// The leading \b stays. A class lookbehind there widened the span leftwards
|
|
168
|
+
// over any "/" in front, so an authorized_keys entry started "//...",
|
|
169
|
+
// matched the protocol-relative URL shape in the endpoint suppressor, and
|
|
170
|
+
// was dropped — leaving SSH key material in the clear. Base64 padding is
|
|
171
|
+
// now inside the span instead of trailing outside it; that is the only
|
|
172
|
+
// intended difference.
|
|
173
|
+
p(EntityType.SECRET, String.raw `\b[A-Za-z0-9_\-+/]{24,}[A-Za-z0-9_\-+/=]*(?![A-Za-z0-9_\-+/=])`, "high_entropy", "High-entropy token", SECRET_CONTEXT, true),
|
|
161
174
|
p(EntityType.DOB, String.raw `\b(?:0[1-9]|[12][0-9]|3[01])[/.\-](?:0[1-9]|1[0-2])[/.\-](?:19|20)\d{2}\b`, null, "DD/MM/YYYY", DOB_CONTEXT, true),
|
|
162
175
|
p(EntityType.DATE_OF_DEATH, String.raw `\b(?:0[1-9]|[12][0-9]|3[01])[/.\-](?:0[1-9]|1[0-2])[/.\-](?:19|20)\d{2}\b`, null, "", DATE_OF_DEATH_CONTEXT, true),
|
|
163
176
|
p(EntityType.DOB, String.raw `\b(?:19|20)\d{2}[/.\-](?:0[1-9]|1[0-2])[/.\-](?:0[1-9]|[12][0-9]|3[01])\b`, null, "ISO YYYY-MM-DD", DOB_ISO_CONTEXT, true),
|
package/dist/esm/rules/engine.js
CHANGED
|
@@ -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
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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;
|