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.
- package/README.md +30 -3
- 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/cues.js +168 -0
- package/dist/cjs/rules/engine.js +124 -34
- package/dist/cjs/rules/suppressors.js +101 -38
- package/dist/cjs/rules/validators.js +12 -0
- package/dist/cjs/sdk.js +45 -3
- package/dist/cjs/types.js +10 -0
- 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/cues.d.ts +91 -0
- package/dist/esm/rules/cues.js +163 -0
- package/dist/esm/rules/engine.js +125 -35
- package/dist/esm/rules/suppressors.d.ts +14 -1
- package/dist/esm/rules/suppressors.js +99 -38
- 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/dist/esm/types.d.ts +10 -0
- package/dist/esm/types.js +10 -0
- package/package.json +4 -3
package/README.md
CHANGED
|
@@ -167,7 +167,7 @@ interface Detection {
|
|
|
167
167
|
text: string; // The matched substring
|
|
168
168
|
source: DetectionSource; // "rules" or "cloud"
|
|
169
169
|
country: string | null; // ISO code or null for shared/custom patterns
|
|
170
|
-
confidence: string; //
|
|
170
|
+
confidence: string; // "high" | "medium" | "low" — see below
|
|
171
171
|
countryConfidence?: number; // How strongly the document supports `country`,
|
|
172
172
|
// in [0, 1]. 0 means the attribution rests on
|
|
173
173
|
// a checksum alone.
|
|
@@ -176,6 +176,26 @@ interface Detection {
|
|
|
176
176
|
}
|
|
177
177
|
```
|
|
178
178
|
|
|
179
|
+
**`confidence`** describes how the *type* was arrived at. It never says anything
|
|
180
|
+
about whether the span is masked — every detection is, at every level.
|
|
181
|
+
|
|
182
|
+
| value | meaning |
|
|
183
|
+
|---|---|
|
|
184
|
+
| `"high"` | a pattern matched and, where one exists, its checksum passed |
|
|
185
|
+
| `"medium"` | the type comes from a label touching the span, because no pattern of that type claimed it — `Αρ. Ταυτότητας: 00892341` is a `NATIONAL_ID` although nothing can checksum it |
|
|
186
|
+
| `"low"` | a pattern matched, its checksum *failed*, and the document labels the span as that very type — `Rijksregisternummer: 85.03.19-284.73` is a national number with a bad check digit |
|
|
187
|
+
|
|
188
|
+
Filter on it when you need only checksum-backed detections:
|
|
189
|
+
|
|
190
|
+
```ts
|
|
191
|
+
const strict = result.detections.filter(d => d.confidence === "high");
|
|
192
|
+
```
|
|
193
|
+
|
|
194
|
+
A `"low"` detection is the honest description of a mistyped, OCR'd or invented
|
|
195
|
+
identifier: the shape and the label agree, the check digit does not. Earlier
|
|
196
|
+
versions dropped these, which meant a redaction library printed in full an
|
|
197
|
+
identifier it had recognised and rejected.
|
|
198
|
+
|
|
179
199
|
#### `RedactResult`
|
|
180
200
|
|
|
181
201
|
```ts
|
|
@@ -205,9 +225,16 @@ PASSPORT DRIVERS_LICENSE RESIDENCE_PERMIT LICENSE_PLATE
|
|
|
205
225
|
VIN VAT POSTAL_CODE IP_ADDRESS
|
|
206
226
|
IPV6_ADDRESS MAC_ADDRESS HEALTH_INSURANCE HEALTHCARE_PROVIDER
|
|
207
227
|
CHAMBER_OF_COMMERCE IMEI GPS_COORDINATES UUID
|
|
208
|
-
SOCIAL_HANDLE SECRET OTHER
|
|
228
|
+
SOCIAL_HANDLE SECRET INTERNAL_ID OTHER
|
|
209
229
|
```
|
|
210
230
|
|
|
231
|
+
`INTERNAL_ID` — an employee, badge or customer number tied to a person — is
|
|
232
|
+
emitted **only** when an explicit label names it (`medarbejdernummer:`,
|
|
233
|
+
`Personalnummer:`, `Employee No:`). There is no pattern for one, because there
|
|
234
|
+
is no shape for one: without the label, a digit run is not distinguishable from
|
|
235
|
+
any other. The type exists so that a labelled employee number is filed
|
|
236
|
+
correctly instead of being claimed by the phone pattern.
|
|
237
|
+
|
|
211
238
|
For custom patterns, `entityType` is a plain string (e.g. `"EMPLOYEE_ID"`).
|
|
212
239
|
|
|
213
240
|
## Country codes
|
|
@@ -313,7 +340,7 @@ for (const page of pages) {
|
|
|
313
340
|
offset += page.length;
|
|
314
341
|
}
|
|
315
342
|
|
|
316
|
-
// page 1: "Factuur — IBAN NL91ABNA0417164300, info@
|
|
343
|
+
// page 1: "Factuur — IBAN NL91ABNA0417164300, info@example.nl"
|
|
317
344
|
// page 7: "Telefoon 0612345678" -> PHONE (NL), not NATIONAL_ID (DK)
|
|
318
345
|
```
|
|
319
346
|
|
package/dist/cjs/cache.js
CHANGED
|
@@ -1,30 +1,44 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
3
|
exports.ResultCache = void 0;
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
4
|
+
const node_crypto_1 = require("node:crypto");
|
|
5
|
+
// Default ceiling on retained characters, not entries. A count-only cap of
|
|
6
|
+
// 1024 entries says nothing about memory: 1024 cached 1 MB documents is a
|
|
7
|
+
// gigabyte of PII-bearing text held live. ~16M characters is a few tens of MB.
|
|
8
|
+
const DEFAULT_MAX_CHARS = 16_000_000;
|
|
9
|
+
/** Approximate retained size of a cached result, in characters. */
|
|
10
|
+
function resultChars(result) {
|
|
11
|
+
let chars = result.redactedText.length;
|
|
12
|
+
for (const d of result.detections)
|
|
13
|
+
chars += d.text.length;
|
|
14
|
+
return chars;
|
|
12
15
|
}
|
|
13
16
|
class ResultCache {
|
|
14
17
|
maxsize;
|
|
18
|
+
maxChars;
|
|
15
19
|
enabled;
|
|
16
20
|
store = new Map();
|
|
17
|
-
|
|
21
|
+
sizes = new Map();
|
|
22
|
+
chars = 0;
|
|
23
|
+
constructor(maxsize = 1024, enabled = true, maxChars = DEFAULT_MAX_CHARS) {
|
|
18
24
|
this.maxsize = maxsize;
|
|
25
|
+
this.maxChars = maxChars;
|
|
19
26
|
this.enabled = enabled;
|
|
20
27
|
}
|
|
21
28
|
key(text, countries, mode) {
|
|
22
29
|
const sorted = [...countries].sort();
|
|
23
|
-
|
|
24
|
-
//
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
30
|
+
// SHA-256, matching the Python implementation. The previous dual FNV-1a
|
|
31
|
+
// was non-cryptographic and collidable by construction: in a shared-process
|
|
32
|
+
// deployment an attacker who can guess a victim's document could submit a
|
|
33
|
+
// same-length colliding one and be served the victim's cached result,
|
|
34
|
+
// detections and raw matched PII included.
|
|
35
|
+
return (0, node_crypto_1.createHash)("sha256")
|
|
36
|
+
.update(text)
|
|
37
|
+
.update("|")
|
|
38
|
+
.update(sorted.join("|"))
|
|
39
|
+
.update("|")
|
|
40
|
+
.update(mode)
|
|
41
|
+
.digest("hex");
|
|
28
42
|
}
|
|
29
43
|
get(key) {
|
|
30
44
|
if (!this.enabled)
|
|
@@ -41,16 +55,36 @@ class ResultCache {
|
|
|
41
55
|
put(key, result) {
|
|
42
56
|
if (!this.enabled)
|
|
43
57
|
return;
|
|
44
|
-
|
|
58
|
+
const size = resultChars(result);
|
|
59
|
+
// A single result bigger than the whole budget would evict everything else
|
|
60
|
+
// and still not fit; skip it rather than empty the cache for one document.
|
|
61
|
+
if (size > this.maxChars) {
|
|
62
|
+
this.evict(key);
|
|
63
|
+
return;
|
|
64
|
+
}
|
|
65
|
+
this.evict(key);
|
|
45
66
|
this.store.set(key, result);
|
|
46
|
-
|
|
67
|
+
this.sizes.set(key, size);
|
|
68
|
+
this.chars += size;
|
|
69
|
+
while (this.store.size > this.maxsize || this.chars > this.maxChars) {
|
|
47
70
|
const first = this.store.keys().next().value;
|
|
48
|
-
if (first
|
|
49
|
-
|
|
71
|
+
if (first === undefined)
|
|
72
|
+
break;
|
|
73
|
+
this.evict(first);
|
|
50
74
|
}
|
|
51
75
|
}
|
|
76
|
+
evict(key) {
|
|
77
|
+
const size = this.sizes.get(key);
|
|
78
|
+
if (size !== undefined) {
|
|
79
|
+
this.chars -= size;
|
|
80
|
+
this.sizes.delete(key);
|
|
81
|
+
}
|
|
82
|
+
this.store.delete(key);
|
|
83
|
+
}
|
|
52
84
|
clear() {
|
|
53
85
|
this.store.clear();
|
|
86
|
+
this.sizes.clear();
|
|
87
|
+
this.chars = 0;
|
|
54
88
|
}
|
|
55
89
|
}
|
|
56
90
|
exports.ResultCache = ResultCache;
|
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
* contain no IBAN, no `+CC` number and no ccTLD infers nothing at all, even
|
|
8
8
|
* when the first page identified the document beyond doubt:
|
|
9
9
|
*
|
|
10
|
-
* page 1 "Factuur — IBAN NL91ABNA0417164300, info@
|
|
10
|
+
* page 1 "Factuur — IBAN NL91ABNA0417164300, info@example.nl"
|
|
11
11
|
* page 7 "Telefoon 0612345678" <- Dutch mobile, or Danish CPR?
|
|
12
12
|
*
|
|
13
13
|
* Scanned alone, page 7 has nothing to go on. Sharing a context across the two
|
|
@@ -160,7 +160,20 @@ const SHARED = {
|
|
|
160
160
|
// --- Secret / API Key (assignment-based: KEY=value or KEY: value) ---
|
|
161
161
|
p(types_js_1.EntityType.SECRET, String.raw `(?<=[:=] )\S{8,}|(?<=[:=])\S{8,}`, "high_entropy", "Assigned secret value", SECRET_CONTEXT, true),
|
|
162
162
|
// --- Secret / API Key (entropy-based fallback for longer tokens) ---
|
|
163
|
-
|
|
163
|
+
// Only the *trailing* \b is replaced. It could not match when the token run
|
|
164
|
+
// ended on "-", "+" or "/", so the engine backtracked across two
|
|
165
|
+
// overlapping quantified classes: an ordinary dashed rule line ("x-----...")
|
|
166
|
+
// cost O(n^2), 8.3 s for 80 KB. The lookahead simply asserts the run is
|
|
167
|
+
// over, which is what \b was there to express, and it cannot fail after a
|
|
168
|
+
// maximal run — so there is nothing to backtrack into.
|
|
169
|
+
//
|
|
170
|
+
// The leading \b stays. A class lookbehind there widened the span leftwards
|
|
171
|
+
// over any "/" in front, so an authorized_keys entry started "//...",
|
|
172
|
+
// matched the protocol-relative URL shape in the endpoint suppressor, and
|
|
173
|
+
// was dropped — leaving SSH key material in the clear. Base64 padding is
|
|
174
|
+
// now inside the span instead of trailing outside it; that is the only
|
|
175
|
+
// intended difference.
|
|
176
|
+
p(types_js_1.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),
|
|
164
177
|
p(types_js_1.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),
|
|
165
178
|
p(types_js_1.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),
|
|
166
179
|
p(types_js_1.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,168 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* Local cues: what the document calls the value sitting next to it.
|
|
4
|
+
*
|
|
5
|
+
* A label touching the left edge of a span is the strongest statement a
|
|
6
|
+
* document makes about what that value *is*. "Phone: 0705237535" is a phone
|
|
7
|
+
* number even though those digits pass the Swedish personnummer checksum, and
|
|
8
|
+
* "Αρ. Ταυτότητας: 00892341" is an identity-card number even though nothing in
|
|
9
|
+
* the engine can checksum it.
|
|
10
|
+
*
|
|
11
|
+
* This table is the single source of that knowledge. Three callers read it, and
|
|
12
|
+
* none of them may move a span:
|
|
13
|
+
*
|
|
14
|
+
* 1. `localCueBonus` — ranks a candidate above its rivals on the *same span*
|
|
15
|
+
* when the cue names its type. The oldest use, and why the table exists.
|
|
16
|
+
* 2. `detectWithEvidence` — re-admits a candidate whose checksum failed when the
|
|
17
|
+
* cue names that same type. A declined identifier with its own label in front
|
|
18
|
+
* of it is still that identifier, mistyped or truncated.
|
|
19
|
+
* 3. `retypedBy` — relabels a winning generic candidate whose type the cue
|
|
20
|
+
* contradicts, when nothing of the cued type claimed the span.
|
|
21
|
+
*
|
|
22
|
+
* Consolidated here from two places that had drifted apart: `LOCAL_CUES` in
|
|
23
|
+
* `engine.ts` (typed, but missing most of Europe) and `ID_LABEL_BEFORE` in
|
|
24
|
+
* `suppressors.ts` (broader, but untyped — it could only *delete* a phone
|
|
25
|
+
* candidate, which left the value unmasked).
|
|
26
|
+
*
|
|
27
|
+
* Kept character-for-character in step with `euredact/rules/cues.py`. Two
|
|
28
|
+
* constructs are written the way they are so that both engines agree:
|
|
29
|
+
*
|
|
30
|
+
* * Boundaries are `(?<![A-Za-z0-9_])`, not `\b`. JavaScript's `\b` is
|
|
31
|
+
* ASCII-only, so `\bΑΦΜ` never matches — a Greek letter is not a word
|
|
32
|
+
* character there, and a space before it is not a transition. A boundary of
|
|
33
|
+
* some kind is required regardless: without one the short alternatives match
|
|
34
|
+
* the *tail* of an ordinary word. "NIS" matched "tālru**nis**", the Latvian
|
|
35
|
+
* for telephone, and suppressed 653 real phone numbers; "iva" matched
|
|
36
|
+
* "Pr**iva**t:" and read a personal e-mail address as a VAT number.
|
|
37
|
+
* * The qualifier class is spelled out rather than written `[^\W\d_]`, which
|
|
38
|
+
* would reject "εταιρειας" here for the same ASCII reason.
|
|
39
|
+
*
|
|
40
|
+
* Every label ends in `(?:\w*|\s+<one word>)`: the label either runs on into a
|
|
41
|
+
* longer word ("telefoon" → "telefoonnummer") **or** takes one qualifier word
|
|
42
|
+
* naming whose identifier it is ("ΑΦΜ εταιρειας:", "Tel Nr:") — never both.
|
|
43
|
+
* Allowing both let "nie" grow into the surname "Nieminen" and then swallow the
|
|
44
|
+
* real label in "Matti Nieminen\nDOB: 08.05.1994", reading a date of birth as a
|
|
45
|
+
* Spanish NIE: 52 false positives across the corpus.
|
|
46
|
+
*/
|
|
47
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
48
|
+
exports.CUE_TARGETS = exports.CUES = exports.CUE_WINDOW = void 0;
|
|
49
|
+
exports.cuedType = cuedType;
|
|
50
|
+
exports.retypedBy = retypedBy;
|
|
51
|
+
const types_js_1 = require("../types.js");
|
|
52
|
+
/**
|
|
53
|
+
* How far back a cue may sit. Short on purpose: a cue is only evidence about
|
|
54
|
+
* the value it introduces, which is the lesson of the postal-code year defect,
|
|
55
|
+
* where a cue 150 characters away licensed every number in the window.
|
|
56
|
+
*/
|
|
57
|
+
exports.CUE_WINDOW = 22;
|
|
58
|
+
/**
|
|
59
|
+
* (entity type, label pattern). Ordered: the first match wins, so the narrower
|
|
60
|
+
* label comes first where two could read the same text.
|
|
61
|
+
*/
|
|
62
|
+
exports.CUES = [
|
|
63
|
+
// Phone. Ranking only — nothing is ever relabelled *to* PHONE: a phone cue in
|
|
64
|
+
// front of a span that no phone pattern matched says the document is laid out
|
|
65
|
+
// unusually, not that the value is a phone number.
|
|
66
|
+
[types_js_1.EntityType.PHONE,
|
|
67
|
+
/(?<![A-Za-z0-9_])(?:phone|tel|telephone|téléphone|telefoon|telefon|telefono|teléfono|tlf|mobil|mobile|gsm|handy|sími|puh|móvil|cell|tālrunis|τηλέφωνο|τηλ)(?:\w*|\s+[^\s:.\-\d,;()/]{2,20})\s*[:.\-]?\s*$/i],
|
|
68
|
+
// National identity number.
|
|
69
|
+
[types_js_1.EntityType.NATIONAL_ID,
|
|
70
|
+
/(?<![A-Za-z0-9_])(?:bsn|personnummer|rijksregisternummer|nationaal\s*nummer|numéro\s*national|national\s*(?:id|number)|identiteitsnummer|henkilötunnus|kennitala|cpr(?:-?nummer)?|nif|nie|dni|rr|nn|nir|insz|niss|nis|matricule|ausweisnummer|personalausweis|osobní\s*číslo|ЕГН|EGN)(?:\w*|\s+[^\s:.\-\d,;()/]{2,20})\s*[:.\-]?\s*$/i],
|
|
71
|
+
// Cyprus: "ID number" in both official languages, plus the Greek abbreviation
|
|
72
|
+
// ΑΔΤ (Αστυνομική Ταυτότητα). Neither has a phone reading.
|
|
73
|
+
[types_js_1.EntityType.NATIONAL_ID,
|
|
74
|
+
/(?<![A-Za-z0-9_])(?:αρ\.?\s*ταυτότητας|ταυτότητας|αδτ|kimlik\s*numaras[ıi])(?:\w*|\s+[^\s:.\-\d,;()/]{2,20})\s*[:.\-]?\s*$/i],
|
|
75
|
+
// Social security.
|
|
76
|
+
[types_js_1.EntityType.SSN,
|
|
77
|
+
/(?<![A-Za-z0-9_])(?:ssn|social\s*security|sozialversicherungsnummer|svnr|sofi)(?:\w*|\s+[^\s:.\-\d,;()/]{2,20})\s*[:.\-]?\s*$/i],
|
|
78
|
+
// Tax number. Greece's ΑΦΜ is issued by the tax authority; the identity-card
|
|
79
|
+
// number is the ΑΔΤ, above. The engine used to return NATIONAL_ID for a value
|
|
80
|
+
// cued ΑΦΜ, which is wrong independently of the phone problem.
|
|
81
|
+
[types_js_1.EntityType.TAX_ID,
|
|
82
|
+
/(?<![A-Za-z0-9_])(?:αφμ|α\.φ\.μ\.|steuer-?id|steuernummer|st\.?-?nr|stnr|tin|finanzamt\s+ist|tax\s*(?:id|no|number)|daňové\s*číslo|numer\s*podatkowy)(?:\w*|\s+[^\s:.\-\d,;()/]{2,20})\s*[:.\-]?\s*$/i],
|
|
83
|
+
// Health insurance.
|
|
84
|
+
[types_js_1.EntityType.HEALTH_INSURANCE,
|
|
85
|
+
/(?<![A-Za-z0-9_])(?:versichertennummer|krankenversicherung|kvnr|kv-?nr|mutualité|ziekenfonds|aansluitingsnummer)(?:\w*|\s+[^\s:.\-\d,;()/]{2,20})\s*[:.\-]?\s*$/i],
|
|
86
|
+
// Company / trade register.
|
|
87
|
+
[types_js_1.EntityType.CHAMBER_OF_COMMERCE,
|
|
88
|
+
/(?<![A-Za-z0-9_])(?:siren|siret|kbo|bce|kvk|ondernemingsnummer|ondernemingen\s+onder\s+nummer|kruispuntbank|numéro\s*d'entreprise|enterprise\s*number|handelsregister|company\s*(?:no|number|reg)|organisationsnummer|orgnr|i[čc]o|identifikační\s*číslo|ЕИК|eik|bulstat|cvr(?:-?nummer)?|virksomhedsnummer)(?:\w*|\s+[^\s:.\-\d,;()/]{2,20})\s*[:.\-]?\s*$/i],
|
|
89
|
+
// VAT.
|
|
90
|
+
[types_js_1.EntityType.VAT,
|
|
91
|
+
/(?<![A-Za-z0-9_])(?:vat|btw|tva|ust|iva|moms|alv|mwst|dič|vsk)(?:[\w.\-]*|\s+[^\s:.\-\d,;()/]{2,20})\s*[:.\-]?\s*$/i],
|
|
92
|
+
// BIC / SWIFT.
|
|
93
|
+
[types_js_1.EntityType.BIC,
|
|
94
|
+
/(?<![A-Za-z0-9_])(?:bic|swift)[\w/]*\s*[:.\-]?\s*\(?\s*$/i],
|
|
95
|
+
// Postal code. A postal code loses every contested span on priority (it is
|
|
96
|
+
// the weakest evidence the engine has), so without a cue entry
|
|
97
|
+
// "Cod postal: 040171" was handed to a German phone pattern.
|
|
98
|
+
[types_js_1.EntityType.POSTAL_CODE,
|
|
99
|
+
/(?<![A-Za-z0-9_])(?:cod\s*po[sș]tal|postnummer|postnr|postleitzahl|plz|postcode|code\s*postal|codice\s*postale|kod\s*pocztowy|ps[čc]|τ\.?κ\.?|пощенски\s*код)(?:\w*|\s+[^\s:.\-\d,;()/]{2,20})\s*[:.\-]?\s*$/i],
|
|
100
|
+
// Internal / employee identifier. The canon calls these INTERNAL_ID and
|
|
101
|
+
// annotates them with an LLM, so the engine has no pattern for them — but it
|
|
102
|
+
// does have a phone pattern that was happily claiming them. The cue is the
|
|
103
|
+
// only thing that identifies one, which is why the engine emits this type
|
|
104
|
+
// from here and nowhere else.
|
|
105
|
+
[types_js_1.EntityType.INTERNAL_ID,
|
|
106
|
+
/(?<![A-Za-z0-9_])(?:medarbejdernummer|ansattnummer|anställningsnummer|personalnummer|personeelsnummer|matricule\s*salarié|employee\s*(?:no|number|id)|badge\s*(?:no|number)|osobní\s*(?:číslo\s*)?zaměstnance)(?:\w*|\s+[^\s:.\-\d,;()/]{2,20})\s*[:.\-]?\s*$/i],
|
|
107
|
+
];
|
|
108
|
+
/**
|
|
109
|
+
* The entity type a label immediately before `start` names, or null.
|
|
110
|
+
*
|
|
111
|
+
* Reads at most `CUE_WINDOW` characters, and every pattern is anchored to the
|
|
112
|
+
* end of that window, so the cue must *touch* the span. A label further away is
|
|
113
|
+
* about some other value.
|
|
114
|
+
*/
|
|
115
|
+
function cuedType(text, start) {
|
|
116
|
+
const before = text.slice(Math.max(0, start - exports.CUE_WINDOW), start);
|
|
117
|
+
for (const [entityType, pattern] of exports.CUES) {
|
|
118
|
+
if (pattern.test(before))
|
|
119
|
+
return entityType;
|
|
120
|
+
}
|
|
121
|
+
return null;
|
|
122
|
+
}
|
|
123
|
+
/**
|
|
124
|
+
* Shapes so generic that a label in front is better evidence than the pattern
|
|
125
|
+
* that matched. A bare digit run is not a phone number because it *could* be
|
|
126
|
+
* dialled.
|
|
127
|
+
*/
|
|
128
|
+
const RETYPABLE = new Set([
|
|
129
|
+
types_js_1.EntityType.PHONE, types_js_1.EntityType.POSTAL_CODE, types_js_1.EntityType.LICENSE_PLATE,
|
|
130
|
+
]);
|
|
131
|
+
/**
|
|
132
|
+
* Types a label can assert on its own. Also the types eligible for the rescue
|
|
133
|
+
* in `engine.ts`, for the same reason read the other way — a label may vouch
|
|
134
|
+
* for one of these, so it may also vouch for a malformed one.
|
|
135
|
+
*
|
|
136
|
+
* PHONE is absent on purpose: "Tel:" in front of something no phone pattern
|
|
137
|
+
* matched means the document is laid out oddly, not that the value is a phone
|
|
138
|
+
* number. So are EMAIL, SECRET, DOB, VIN and the rest — those carry their own
|
|
139
|
+
* structure, and a nearby word is not entitled to overrule it.
|
|
140
|
+
*
|
|
141
|
+
* BIC's absence is what keeps the rescue honest. Its validator is a *registry
|
|
142
|
+
* lookup*, not a checksum, so a failure means "no such bank", which no label can
|
|
143
|
+
* talk you out of. Rescuing it read 34 unlisted codes behind a "BIC:" label as
|
|
144
|
+
* real ones. The same reasoning keeps CREDIT_CARD and BANK_ACCOUNT out: Luhn and
|
|
145
|
+
* mod-97 are strong enough that a failure really does mean "not one of these".
|
|
146
|
+
*/
|
|
147
|
+
exports.CUE_TARGETS = new Set([
|
|
148
|
+
types_js_1.EntityType.NATIONAL_ID, types_js_1.EntityType.SSN, types_js_1.EntityType.TAX_ID,
|
|
149
|
+
types_js_1.EntityType.HEALTH_INSURANCE, types_js_1.EntityType.CHAMBER_OF_COMMERCE,
|
|
150
|
+
types_js_1.EntityType.VAT, types_js_1.EntityType.POSTAL_CODE, types_js_1.EntityType.PASSPORT,
|
|
151
|
+
types_js_1.EntityType.INTERNAL_ID,
|
|
152
|
+
]);
|
|
153
|
+
/**
|
|
154
|
+
* The type a cue overrules `entityType` with, or null to leave it alone.
|
|
155
|
+
*
|
|
156
|
+
* Both gates are load-bearing. Measured over 2,000 corpus documents, re-typing
|
|
157
|
+
* without them fired 52 times and got roughly 30 of those wrong — EMAIL read as
|
|
158
|
+
* VAT, a date of birth as a phone number, a VIN as VAT — against 20 real fixes.
|
|
159
|
+
* With them: 14 re-typings, every one of them PHONE to a structured identifier.
|
|
160
|
+
*/
|
|
161
|
+
function retypedBy(text, start, entityType) {
|
|
162
|
+
if (!RETYPABLE.has(String(entityType)))
|
|
163
|
+
return null;
|
|
164
|
+
const cued = cuedType(text, start);
|
|
165
|
+
if (cued === null || cued === entityType || !exports.CUE_TARGETS.has(String(cued)))
|
|
166
|
+
return null;
|
|
167
|
+
return cued;
|
|
168
|
+
}
|
package/dist/cjs/rules/engine.js
CHANGED
|
@@ -38,6 +38,7 @@ exports.resolveCountryCode = resolveCountryCode;
|
|
|
38
38
|
const types_js_1 = require("../types.js");
|
|
39
39
|
const matchers_js_1 = require("./matchers.js");
|
|
40
40
|
const suppressors_js_1 = require("./suppressors.js");
|
|
41
|
+
const cues_js_1 = require("./cues.js");
|
|
41
42
|
const structural_js_1 = require("./structural.js");
|
|
42
43
|
const index_js_1 = require("./countries/index.js");
|
|
43
44
|
const evidenceMod = __importStar(require("./evidence.js"));
|
|
@@ -105,30 +106,13 @@ function warnUnknownCountry(code) {
|
|
|
105
106
|
//
|
|
106
107
|
// Measured: 263 phone numbers and 18 French SIREN were reported as national IDs,
|
|
107
108
|
// each with an explicit cue in front of it.
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
/(?:siren|siret|kbo|bce|kvk|ondernemingsnummer|handelsregister|company\s*(?:no|number|reg)|organisationsnummer|orgnr)\w*\s*[:.\-]?\s*$/i],
|
|
113
|
-
[types_js_1.EntityType.VAT,
|
|
114
|
-
/(?:vat|btw|tva|ust|iva|moms|alv|mwst)[\w.\-]*\s*[:.\-]?\s*$/i],
|
|
115
|
-
[types_js_1.EntityType.BIC,
|
|
116
|
-
/(?:bic|swift)[\w/]*\s*[:.\-]?\s*\(?\s*$/i],
|
|
117
|
-
[types_js_1.EntityType.NATIONAL_ID,
|
|
118
|
-
/(?:bsn|personnummer|rijksregisternummer|national\s*id|identiteitsnummer|henkilötunnus|kennitala|cpr|nif|dni)\w*\s*[:.\-]?\s*$/i],
|
|
119
|
-
];
|
|
120
|
-
// How far back a cue may sit. Short on purpose: a cue is only evidence about the
|
|
121
|
-
// value it introduces, which is the lesson of the postal-code year defect, where
|
|
122
|
-
// a cue 150 characters away licensed every number in the window.
|
|
123
|
-
const CUE_WINDOW = 22;
|
|
109
|
+
//
|
|
110
|
+
// The table itself lives in `cues.ts`, because two other steps read it: the
|
|
111
|
+
// rescue of a declined identifier below, and the re-typing in deduplicate.
|
|
112
|
+
// `CUE_WINDOW` is re-exported from there.
|
|
124
113
|
/** 1 when a cue naming `entityType` sits immediately before the span. */
|
|
125
114
|
function localCueBonus(text, start, entityType) {
|
|
126
|
-
|
|
127
|
-
for (const [cuedType, pattern] of LOCAL_CUES) {
|
|
128
|
-
if (cuedType === entityType && pattern.test(before))
|
|
129
|
-
return 1;
|
|
130
|
-
}
|
|
131
|
-
return 0;
|
|
115
|
+
return (0, cues_js_1.cuedType)(text, start) === entityType ? 1 : 0;
|
|
132
116
|
}
|
|
133
117
|
class RuleEngine {
|
|
134
118
|
matcher = new matchers_js_1.MultiPatternMatcher();
|
|
@@ -235,7 +219,19 @@ class RuleEngine {
|
|
|
235
219
|
// but failed the checksum is recorded with its country and entity type;
|
|
236
220
|
// what it is allowed to demote is decided below, once the document's
|
|
237
221
|
// countries are known.
|
|
222
|
+
//
|
|
223
|
+
// One kind of failure is rescued rather than recorded: a span the document
|
|
224
|
+
// itself labels as the very type whose checksum just failed.
|
|
225
|
+
// "Rijksregisternummer: 85.03.19-284.73" is a Belgian national number with a
|
|
226
|
+
// bad check digit — mistyped, OCR'd, or invented — and it is still a
|
|
227
|
+
// national number. Before this, the pattern declined, the generic phone rule
|
|
228
|
+
// was denied the span as a fragment, and the value was left in the clear: a
|
|
229
|
+
// redaction library printing an identifier it had recognised and rejected.
|
|
230
|
+
//
|
|
231
|
+
// Rescued candidates are still recorded as failed spans, so the demotion
|
|
232
|
+
// zones and the fragment rule below are unchanged.
|
|
238
233
|
const validated = [];
|
|
234
|
+
const rescued = [];
|
|
239
235
|
const failedSpans = [];
|
|
240
236
|
for (const m of rawMatches) {
|
|
241
237
|
if (this.matcher.validate(m)) {
|
|
@@ -244,6 +240,9 @@ class RuleEngine {
|
|
|
244
240
|
else if (m.patternDef.validator !== null && !m.patternDef.requiresContext) {
|
|
245
241
|
failedSpans.push({ start: m.start, end: m.end, code: m.countryCode,
|
|
246
242
|
etype: String(m.patternDef.entityType) });
|
|
243
|
+
if (cues_js_1.CUE_TARGETS.has(String(m.patternDef.entityType))
|
|
244
|
+
&& (0, cues_js_1.cuedType)(text, m.start) === m.patternDef.entityType)
|
|
245
|
+
rescued.push(m);
|
|
247
246
|
}
|
|
248
247
|
}
|
|
249
248
|
// Evidence pass: work out which countries this document belongs to, from
|
|
@@ -310,6 +309,14 @@ class RuleEngine {
|
|
|
310
309
|
const allFailed = failedSpans
|
|
311
310
|
.map(f => [f.start, f.end])
|
|
312
311
|
.sort((a, b) => a[0] - b[0] || a[1] - b[1]);
|
|
312
|
+
// Running maximum of the end offsets, so the fragment test below can ask
|
|
313
|
+
// "does any span starting at or before me reach past my end?" in O(1)
|
|
314
|
+
// instead of walking the prefix per candidate.
|
|
315
|
+
const failedStarts = allFailed.map(f => f[0]);
|
|
316
|
+
const failedPrefixMax = new Array(allFailed.length + 1).fill(0);
|
|
317
|
+
for (let i = 0; i < allFailed.length; i++) {
|
|
318
|
+
failedPrefixMax[i + 1] = Math.max(failedPrefixMax[i], allFailed[i][1]);
|
|
319
|
+
}
|
|
313
320
|
const mergedZones = new Map();
|
|
314
321
|
for (const [etype, spans] of zonesByType) {
|
|
315
322
|
spans.sort((a, b) => a[0] - b[0] || a[1] - b[1]);
|
|
@@ -332,8 +339,11 @@ class RuleEngine {
|
|
|
332
339
|
mergedZones.set(etype, { starts, ends });
|
|
333
340
|
}
|
|
334
341
|
const candidates = [];
|
|
342
|
+
// Whole-document facts a suppressor may need, derived at most once per call
|
|
343
|
+
// and discarded with it.
|
|
344
|
+
const scratch = new suppressors_js_1.SuppressionScratch(text);
|
|
335
345
|
for (const { match, hasValidValidator } of validated) {
|
|
336
|
-
if ((0, suppressors_js_1.shouldSuppress)(text, match))
|
|
346
|
+
if ((0, suppressors_js_1.shouldSuppress)(text, match, scratch))
|
|
337
347
|
continue;
|
|
338
348
|
// A validator-less match inside a failed-validation span of its own type
|
|
339
349
|
// is demoted below every other candidate rather than deleted. Demotion
|
|
@@ -361,13 +371,31 @@ class RuleEngine {
|
|
|
361
371
|
// default — the generic phone pattern kept claiming the tail of the
|
|
362
372
|
// rejected national number.
|
|
363
373
|
if (match.patternDef.validator === null && !demoted) {
|
|
374
|
+
// Upper bound of the spans starting at or before match.start.
|
|
375
|
+
let lo = 0, hi = failedStarts.length;
|
|
376
|
+
while (lo < hi) {
|
|
377
|
+
const mid = (lo + hi) >>> 1;
|
|
378
|
+
if (failedStarts[mid] <= match.start)
|
|
379
|
+
lo = mid + 1;
|
|
380
|
+
else
|
|
381
|
+
hi = mid;
|
|
382
|
+
}
|
|
364
383
|
let fragment = false;
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
384
|
+
const reach = failedPrefixMax[lo];
|
|
385
|
+
if (reach > match.end) {
|
|
386
|
+
// Something starting no later than us ends strictly later: we are a
|
|
387
|
+
// proper subset of it.
|
|
388
|
+
fragment = true;
|
|
389
|
+
}
|
|
390
|
+
else if (reach === match.end) {
|
|
391
|
+
// Same end offset — a proper subset only if some span with that end
|
|
392
|
+
// starts strictly earlier. Scanning backwards finds the enclosing
|
|
393
|
+
// span first, and the equal case is rare.
|
|
394
|
+
for (let i = lo - 1; i >= 0; i--) {
|
|
395
|
+
if (allFailed[i][1] === match.end && allFailed[i][0] < match.start) {
|
|
396
|
+
fragment = true;
|
|
397
|
+
break;
|
|
398
|
+
}
|
|
371
399
|
}
|
|
372
400
|
}
|
|
373
401
|
if (fragment)
|
|
@@ -409,8 +437,28 @@ class RuleEngine {
|
|
|
409
437
|
priority = 1;
|
|
410
438
|
blindPriority = 1;
|
|
411
439
|
}
|
|
412
|
-
|
|
440
|
+
let entityType = match.patternDef.entityType;
|
|
441
|
+
let country = (match.countryCode !== "SHARED" && match.countryCode !== "CUSTOM")
|
|
413
442
|
? match.countryCode : null;
|
|
443
|
+
let outOfScope = declared !== null && country !== null && !declared.has(match.countryCode);
|
|
444
|
+
let confidence = "high";
|
|
445
|
+
// The document labels this span as something else. Relabelling, not
|
|
446
|
+
// suppression: the span is found either way, so removing the claim would
|
|
447
|
+
// decide only whether the value is masked — and the answer was "no".
|
|
448
|
+
// "Rijksregisternummer: 85.03.19-284.73" produced no detection at all.
|
|
449
|
+
//
|
|
450
|
+
// The country came from the pattern that matched, and that pattern was
|
|
451
|
+
// just overruled: a Finnish phone rule saying "Finland" is worthless once
|
|
452
|
+
// the value is filed as a Cypriot identity number. Dropping it also clears
|
|
453
|
+
// outOfScope, which would otherwise claim the detection belongs to a
|
|
454
|
+
// country the caller did not declare while naming no country at all.
|
|
455
|
+
const retyped = (0, cues_js_1.retypedBy)(text, match.start, entityType);
|
|
456
|
+
if (retyped !== null) {
|
|
457
|
+
entityType = retyped;
|
|
458
|
+
country = null;
|
|
459
|
+
outOfScope = false;
|
|
460
|
+
confidence = "medium";
|
|
461
|
+
}
|
|
414
462
|
candidates.push({
|
|
415
463
|
blindPriority,
|
|
416
464
|
cue: localCueBonus(text, match.start, match.patternDef.entityType),
|
|
@@ -418,6 +466,45 @@ class RuleEngine {
|
|
|
418
466
|
length: match.end - match.start,
|
|
419
467
|
countryScore: countryScores.get(match.countryCode) ?? 0,
|
|
420
468
|
inScope: declared === null || declared.has(match.countryCode) ? 1 : 0,
|
|
469
|
+
tier: 0,
|
|
470
|
+
det: {
|
|
471
|
+
entityType,
|
|
472
|
+
start: match.start,
|
|
473
|
+
end: match.end,
|
|
474
|
+
text: match.text,
|
|
475
|
+
source: types_js_1.DetectionSource.RULES,
|
|
476
|
+
country,
|
|
477
|
+
confidence,
|
|
478
|
+
outOfScope,
|
|
479
|
+
},
|
|
480
|
+
});
|
|
481
|
+
}
|
|
482
|
+
// A declined identifier the document itself labels. Cue-backed by
|
|
483
|
+
// construction, so `cue` is 1 — that is what lets it take a span from a
|
|
484
|
+
// foreign pattern whose checksum happened to pass, which is how
|
|
485
|
+
// "ΑΦΜ: 147382965" was reported as a Czech national ID.
|
|
486
|
+
//
|
|
487
|
+
// `priority` is 0: below every regex-only claim, so it wins only what
|
|
488
|
+
// nothing better wants. `blindPriority` is 1 so that span selection — the
|
|
489
|
+
// country-blind half of the sort — treats it like any other regex-strength
|
|
490
|
+
// claim and invariant I1 is untouched.
|
|
491
|
+
//
|
|
492
|
+
// Emitted as "low", which is the honest description: the shape and the label
|
|
493
|
+
// agree, the check digit does not. A caller wanting only checksum-backed
|
|
494
|
+
// detections filters on it.
|
|
495
|
+
for (const match of rescued) {
|
|
496
|
+
if ((0, suppressors_js_1.shouldSuppress)(text, match, scratch))
|
|
497
|
+
continue;
|
|
498
|
+
const country = (match.countryCode !== "SHARED" && match.countryCode !== "CUSTOM")
|
|
499
|
+
? match.countryCode : null;
|
|
500
|
+
candidates.push({
|
|
501
|
+
blindPriority: 1,
|
|
502
|
+
cue: 1,
|
|
503
|
+
priority: 0,
|
|
504
|
+
length: match.end - match.start,
|
|
505
|
+
countryScore: countryScores.get(match.countryCode) ?? 0,
|
|
506
|
+
inScope: declared === null || declared.has(match.countryCode) ? 1 : 0,
|
|
507
|
+
tier: 0,
|
|
421
508
|
det: {
|
|
422
509
|
entityType: match.patternDef.entityType,
|
|
423
510
|
start: match.start,
|
|
@@ -425,14 +512,14 @@ class RuleEngine {
|
|
|
425
512
|
text: match.text,
|
|
426
513
|
source: types_js_1.DetectionSource.RULES,
|
|
427
514
|
country,
|
|
428
|
-
confidence: "
|
|
515
|
+
confidence: "low",
|
|
429
516
|
outOfScope: declared !== null && country !== null && !declared.has(match.countryCode),
|
|
430
517
|
},
|
|
431
518
|
});
|
|
432
519
|
}
|
|
433
520
|
for (const d of (0, structural_js_1.detectStructuralDob)(text)) {
|
|
434
521
|
candidates.push({ blindPriority: 1, cue: 0, priority: 1, length: d.end - d.start,
|
|
435
|
-
countryScore: 0, inScope: 1, det: d });
|
|
522
|
+
countryScore: 0, inScope: 1, tier: 0, det: d });
|
|
436
523
|
}
|
|
437
524
|
const detections = this.deduplicate(candidates);
|
|
438
525
|
detections.sort((a, b) => a.start - b.start || b.end - a.end);
|
|
@@ -466,7 +553,10 @@ class RuleEngine {
|
|
|
466
553
|
if (seen === undefined || c.blindPriority > seen)
|
|
467
554
|
spanTier.set(key, c.blindPriority);
|
|
468
555
|
}
|
|
469
|
-
|
|
556
|
+
// Resolved once per candidate rather than inside the comparator, which ran
|
|
557
|
+
// it O(n log n) times and rebuilt the key string on every comparison.
|
|
558
|
+
for (const c of candidates)
|
|
559
|
+
c.tier = spanTier.get(`${c.det.start}:${c.det.end}`);
|
|
470
560
|
// Span length outranks *everything*, including validation.
|
|
471
561
|
//
|
|
472
562
|
// A shorter candidate winning re-cuts a longer one and leaves the
|
|
@@ -492,7 +582,7 @@ class RuleEngine {
|
|
|
492
582
|
// country-blind up to and including `start`, and (length, start) already
|
|
493
583
|
// identifies a span uniquely.
|
|
494
584
|
const sorted = [...candidates].sort((a, b) => b.length - a.length || // longest first } country-blind
|
|
495
|
-
|
|
585
|
+
b.tier - a.tier || // best tier here } — decides
|
|
496
586
|
a.det.start - b.det.start || // leftmost } WHICH span
|
|
497
587
|
b.cue - a.cue || // local cue } country-aware
|
|
498
588
|
b.priority - a.priority || // country priority } — decides the
|