euredact 0.3.7 → 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 +29 -2
- package/dist/cjs/rules/cues.js +168 -0
- package/dist/cjs/rules/engine.js +81 -24
- package/dist/cjs/rules/suppressors.js +11 -12
- package/dist/cjs/types.js +10 -0
- package/dist/esm/rules/cues.d.ts +91 -0
- package/dist/esm/rules/cues.js +163 -0
- package/dist/esm/rules/engine.js +81 -24
- package/dist/esm/rules/suppressors.js +11 -12
- package/dist/esm/types.d.ts +10 -0
- package/dist/esm/types.js +10 -0
- package/package.json +1 -1
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
|
|
@@ -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
|
|
@@ -438,8 +437,28 @@ class RuleEngine {
|
|
|
438
437
|
priority = 1;
|
|
439
438
|
blindPriority = 1;
|
|
440
439
|
}
|
|
441
|
-
|
|
440
|
+
let entityType = match.patternDef.entityType;
|
|
441
|
+
let country = (match.countryCode !== "SHARED" && match.countryCode !== "CUSTOM")
|
|
442
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
|
+
}
|
|
443
462
|
candidates.push({
|
|
444
463
|
blindPriority,
|
|
445
464
|
cue: localCueBonus(text, match.start, match.patternDef.entityType),
|
|
@@ -448,6 +467,44 @@ class RuleEngine {
|
|
|
448
467
|
countryScore: countryScores.get(match.countryCode) ?? 0,
|
|
449
468
|
inScope: declared === null || declared.has(match.countryCode) ? 1 : 0,
|
|
450
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,
|
|
451
508
|
det: {
|
|
452
509
|
entityType: match.patternDef.entityType,
|
|
453
510
|
start: match.start,
|
|
@@ -455,7 +512,7 @@ class RuleEngine {
|
|
|
455
512
|
text: match.text,
|
|
456
513
|
source: types_js_1.DetectionSource.RULES,
|
|
457
514
|
country,
|
|
458
|
-
confidence: "
|
|
515
|
+
confidence: "low",
|
|
459
516
|
outOfScope: declared !== null && country !== null && !declared.has(match.countryCode),
|
|
460
517
|
},
|
|
461
518
|
});
|
|
@@ -44,11 +44,16 @@ const MATH_AFTER = /^\s*[=+\-×÷*/]/;
|
|
|
44
44
|
const COUNTRY_PREFIX_HYPHEN = /(?:^|[^A-Za-z0-9])[A-Z]{1,2}-\s*$/;
|
|
45
45
|
const SEQUENTIAL_PATTERNS = /^(?:0{6,}|1234567890?|0123456789|9876543210?|1111111111?|000000000|123456789)$/;
|
|
46
46
|
// YEAR_PATTERN kept for reference but replaced by RECENT_YEAR in suppressYearAsPostal
|
|
47
|
-
//
|
|
48
|
-
//
|
|
49
|
-
//
|
|
50
|
-
//
|
|
51
|
-
|
|
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`.
|
|
52
57
|
const SERVICE_NUMBER = /^0800[\-\s]/;
|
|
53
58
|
const DATE_PATTERN_FULL = /^\d{2}[-/.]\d{2}[-/.]\d{4}$|^\d{4}[-/.]\d{2}[-/.]\d{2}$/;
|
|
54
59
|
const PASSPORT_CONTEXT_BEFORE = /(?:Reisepass|passport|passeport|paspoort|Bisheriger\s+Reisepass)\s*(?:Nr\.?|Nummer|nummer|number|n[°o])?\s*[:.]?\s*$/i;
|
|
@@ -342,12 +347,6 @@ function suppressYearAsPostal(text, match) {
|
|
|
342
347
|
// Suppress: recent years without postal context are almost never postal codes
|
|
343
348
|
return true;
|
|
344
349
|
}
|
|
345
|
-
function suppressPhoneAfterIdLabel(text, match) {
|
|
346
|
-
if (match.patternDef.entityType !== types_js_1.EntityType.PHONE)
|
|
347
|
-
return false;
|
|
348
|
-
const [before] = getContext(text, match.start, match.end);
|
|
349
|
-
return ID_LABEL_BEFORE.test(before);
|
|
350
|
-
}
|
|
351
350
|
function suppressPhoneServiceNumber(_text, match) {
|
|
352
351
|
if (match.patternDef.entityType !== types_js_1.EntityType.PHONE)
|
|
353
352
|
return false;
|
|
@@ -793,7 +792,7 @@ function suppressRequiresContext(text, match) {
|
|
|
793
792
|
return !match.patternDef.contextKeywords.some(kw => context.includes(kw.toLowerCase()));
|
|
794
793
|
}
|
|
795
794
|
const TYPE_SUPPRESSORS = {
|
|
796
|
-
[types_js_1.EntityType.PHONE]: [suppressCurrency, suppressUnits, suppressReference, suppressMath,
|
|
795
|
+
[types_js_1.EntityType.PHONE]: [suppressCurrency, suppressUnits, suppressReference, suppressMath, suppressPhoneServiceNumber, suppressPhoneDateOverlap, suppressPhoneAsNumberRange],
|
|
797
796
|
[types_js_1.EntityType.NATIONAL_ID]: [suppressCurrency, suppressUnits, suppressReference, suppressLegal, suppressMath, suppressNatidAsPassport, suppressSeNatidAsOrg],
|
|
798
797
|
[types_js_1.EntityType.SSN]: [suppressCurrency, suppressUnits, suppressReference, suppressMath],
|
|
799
798
|
[types_js_1.EntityType.TAX_ID]: [suppressCurrency, suppressUnits, suppressReference, suppressMath, suppressTaxidAsIpAddress],
|
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";
|
|
@@ -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;
|
|
@@ -0,0 +1,163 @@
|
|
|
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 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 const CUES = [
|
|
58
|
+
// Phone. Ranking only — nothing is ever relabelled *to* PHONE: a phone cue in
|
|
59
|
+
// front of a span that no phone pattern matched says the document is laid out
|
|
60
|
+
// unusually, not that the value is a phone number.
|
|
61
|
+
[EntityType.PHONE,
|
|
62
|
+
/(?<![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],
|
|
63
|
+
// National identity number.
|
|
64
|
+
[EntityType.NATIONAL_ID,
|
|
65
|
+
/(?<![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],
|
|
66
|
+
// Cyprus: "ID number" in both official languages, plus the Greek abbreviation
|
|
67
|
+
// ΑΔΤ (Αστυνομική Ταυτότητα). Neither has a phone reading.
|
|
68
|
+
[EntityType.NATIONAL_ID,
|
|
69
|
+
/(?<![A-Za-z0-9_])(?:αρ\.?\s*ταυτότητας|ταυτότητας|αδτ|kimlik\s*numaras[ıi])(?:\w*|\s+[^\s:.\-\d,;()/]{2,20})\s*[:.\-]?\s*$/i],
|
|
70
|
+
// Social security.
|
|
71
|
+
[EntityType.SSN,
|
|
72
|
+
/(?<![A-Za-z0-9_])(?:ssn|social\s*security|sozialversicherungsnummer|svnr|sofi)(?:\w*|\s+[^\s:.\-\d,;()/]{2,20})\s*[:.\-]?\s*$/i],
|
|
73
|
+
// Tax number. Greece's ΑΦΜ is issued by the tax authority; the identity-card
|
|
74
|
+
// number is the ΑΔΤ, above. The engine used to return NATIONAL_ID for a value
|
|
75
|
+
// cued ΑΦΜ, which is wrong independently of the phone problem.
|
|
76
|
+
[EntityType.TAX_ID,
|
|
77
|
+
/(?<![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],
|
|
78
|
+
// Health insurance.
|
|
79
|
+
[EntityType.HEALTH_INSURANCE,
|
|
80
|
+
/(?<![A-Za-z0-9_])(?:versichertennummer|krankenversicherung|kvnr|kv-?nr|mutualité|ziekenfonds|aansluitingsnummer)(?:\w*|\s+[^\s:.\-\d,;()/]{2,20})\s*[:.\-]?\s*$/i],
|
|
81
|
+
// Company / trade register.
|
|
82
|
+
[EntityType.CHAMBER_OF_COMMERCE,
|
|
83
|
+
/(?<![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],
|
|
84
|
+
// VAT.
|
|
85
|
+
[EntityType.VAT,
|
|
86
|
+
/(?<![A-Za-z0-9_])(?:vat|btw|tva|ust|iva|moms|alv|mwst|dič|vsk)(?:[\w.\-]*|\s+[^\s:.\-\d,;()/]{2,20})\s*[:.\-]?\s*$/i],
|
|
87
|
+
// BIC / SWIFT.
|
|
88
|
+
[EntityType.BIC,
|
|
89
|
+
/(?<![A-Za-z0-9_])(?:bic|swift)[\w/]*\s*[:.\-]?\s*\(?\s*$/i],
|
|
90
|
+
// Postal code. A postal code loses every contested span on priority (it is
|
|
91
|
+
// the weakest evidence the engine has), so without a cue entry
|
|
92
|
+
// "Cod postal: 040171" was handed to a German phone pattern.
|
|
93
|
+
[EntityType.POSTAL_CODE,
|
|
94
|
+
/(?<![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],
|
|
95
|
+
// Internal / employee identifier. The canon calls these INTERNAL_ID and
|
|
96
|
+
// annotates them with an LLM, so the engine has no pattern for them — but it
|
|
97
|
+
// does have a phone pattern that was happily claiming them. The cue is the
|
|
98
|
+
// only thing that identifies one, which is why the engine emits this type
|
|
99
|
+
// from here and nowhere else.
|
|
100
|
+
[EntityType.INTERNAL_ID,
|
|
101
|
+
/(?<![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],
|
|
102
|
+
];
|
|
103
|
+
/**
|
|
104
|
+
* The entity type a label immediately before `start` names, or null.
|
|
105
|
+
*
|
|
106
|
+
* Reads at most `CUE_WINDOW` characters, and every pattern is anchored to the
|
|
107
|
+
* end of that window, so the cue must *touch* the span. A label further away is
|
|
108
|
+
* about some other value.
|
|
109
|
+
*/
|
|
110
|
+
export function cuedType(text, start) {
|
|
111
|
+
const before = text.slice(Math.max(0, start - CUE_WINDOW), start);
|
|
112
|
+
for (const [entityType, pattern] of CUES) {
|
|
113
|
+
if (pattern.test(before))
|
|
114
|
+
return entityType;
|
|
115
|
+
}
|
|
116
|
+
return null;
|
|
117
|
+
}
|
|
118
|
+
/**
|
|
119
|
+
* Shapes so generic that a label in front is better evidence than the pattern
|
|
120
|
+
* that matched. A bare digit run is not a phone number because it *could* be
|
|
121
|
+
* dialled.
|
|
122
|
+
*/
|
|
123
|
+
const RETYPABLE = new Set([
|
|
124
|
+
EntityType.PHONE, EntityType.POSTAL_CODE, EntityType.LICENSE_PLATE,
|
|
125
|
+
]);
|
|
126
|
+
/**
|
|
127
|
+
* Types a label can assert on its own. Also the types eligible for the rescue
|
|
128
|
+
* in `engine.ts`, for the same reason read the other way — a label may vouch
|
|
129
|
+
* for one of these, so it may also vouch for a malformed one.
|
|
130
|
+
*
|
|
131
|
+
* PHONE is absent on purpose: "Tel:" in front of something no phone pattern
|
|
132
|
+
* matched means the document is laid out oddly, not that the value is a phone
|
|
133
|
+
* number. So are EMAIL, SECRET, DOB, VIN and the rest — those carry their own
|
|
134
|
+
* structure, and a nearby word is not entitled to overrule it.
|
|
135
|
+
*
|
|
136
|
+
* BIC's absence is what keeps the rescue honest. Its validator is a *registry
|
|
137
|
+
* lookup*, not a checksum, so a failure means "no such bank", which no label can
|
|
138
|
+
* talk you out of. Rescuing it read 34 unlisted codes behind a "BIC:" label as
|
|
139
|
+
* real ones. The same reasoning keeps CREDIT_CARD and BANK_ACCOUNT out: Luhn and
|
|
140
|
+
* mod-97 are strong enough that a failure really does mean "not one of these".
|
|
141
|
+
*/
|
|
142
|
+
export const CUE_TARGETS = new Set([
|
|
143
|
+
EntityType.NATIONAL_ID, EntityType.SSN, EntityType.TAX_ID,
|
|
144
|
+
EntityType.HEALTH_INSURANCE, EntityType.CHAMBER_OF_COMMERCE,
|
|
145
|
+
EntityType.VAT, EntityType.POSTAL_CODE, EntityType.PASSPORT,
|
|
146
|
+
EntityType.INTERNAL_ID,
|
|
147
|
+
]);
|
|
148
|
+
/**
|
|
149
|
+
* The type a cue overrules `entityType` with, or null to leave it alone.
|
|
150
|
+
*
|
|
151
|
+
* Both gates are load-bearing. Measured over 2,000 corpus documents, re-typing
|
|
152
|
+
* without them fired 52 times and got roughly 30 of those wrong — EMAIL read as
|
|
153
|
+
* VAT, a date of birth as a phone number, a VIN as VAT — against 20 real fixes.
|
|
154
|
+
* With them: 14 re-typings, every one of them PHONE to a structured identifier.
|
|
155
|
+
*/
|
|
156
|
+
export function retypedBy(text, start, entityType) {
|
|
157
|
+
if (!RETYPABLE.has(String(entityType)))
|
|
158
|
+
return null;
|
|
159
|
+
const cued = cuedType(text, start);
|
|
160
|
+
if (cued === null || cued === entityType || !CUE_TARGETS.has(String(cued)))
|
|
161
|
+
return null;
|
|
162
|
+
return cued;
|
|
163
|
+
}
|
package/dist/esm/rules/engine.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { DetectionSource, EntityType } from "../types.js";
|
|
2
2
|
import { MultiPatternMatcher } from "./matchers.js";
|
|
3
3
|
import { shouldSuppress, SuppressionScratch } from "./suppressors.js";
|
|
4
|
+
import { CUE_TARGETS, cuedType, retypedBy } from "./cues.js";
|
|
4
5
|
import { detectStructuralDob } from "./structural.js";
|
|
5
6
|
import { COUNTRY_CONFIGS } from "./countries/index.js";
|
|
6
7
|
import * as evidenceMod from "./evidence.js";
|
|
@@ -68,30 +69,13 @@ function warnUnknownCountry(code) {
|
|
|
68
69
|
//
|
|
69
70
|
// Measured: 263 phone numbers and 18 French SIREN were reported as national IDs,
|
|
70
71
|
// each with an explicit cue in front of it.
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
/(?:siren|siret|kbo|bce|kvk|ondernemingsnummer|handelsregister|company\s*(?:no|number|reg)|organisationsnummer|orgnr)\w*\s*[:.\-]?\s*$/i],
|
|
76
|
-
[EntityType.VAT,
|
|
77
|
-
/(?:vat|btw|tva|ust|iva|moms|alv|mwst)[\w.\-]*\s*[:.\-]?\s*$/i],
|
|
78
|
-
[EntityType.BIC,
|
|
79
|
-
/(?:bic|swift)[\w/]*\s*[:.\-]?\s*\(?\s*$/i],
|
|
80
|
-
[EntityType.NATIONAL_ID,
|
|
81
|
-
/(?:bsn|personnummer|rijksregisternummer|national\s*id|identiteitsnummer|henkilötunnus|kennitala|cpr|nif|dni)\w*\s*[:.\-]?\s*$/i],
|
|
82
|
-
];
|
|
83
|
-
// How far back a cue may sit. Short on purpose: a cue is only evidence about the
|
|
84
|
-
// value it introduces, which is the lesson of the postal-code year defect, where
|
|
85
|
-
// a cue 150 characters away licensed every number in the window.
|
|
86
|
-
const CUE_WINDOW = 22;
|
|
72
|
+
//
|
|
73
|
+
// The table itself lives in `cues.ts`, because two other steps read it: the
|
|
74
|
+
// rescue of a declined identifier below, and the re-typing in deduplicate.
|
|
75
|
+
// `CUE_WINDOW` is re-exported from there.
|
|
87
76
|
/** 1 when a cue naming `entityType` sits immediately before the span. */
|
|
88
77
|
function localCueBonus(text, start, entityType) {
|
|
89
|
-
|
|
90
|
-
for (const [cuedType, pattern] of LOCAL_CUES) {
|
|
91
|
-
if (cuedType === entityType && pattern.test(before))
|
|
92
|
-
return 1;
|
|
93
|
-
}
|
|
94
|
-
return 0;
|
|
78
|
+
return cuedType(text, start) === entityType ? 1 : 0;
|
|
95
79
|
}
|
|
96
80
|
export class RuleEngine {
|
|
97
81
|
matcher = new MultiPatternMatcher();
|
|
@@ -198,7 +182,19 @@ export class RuleEngine {
|
|
|
198
182
|
// but failed the checksum is recorded with its country and entity type;
|
|
199
183
|
// what it is allowed to demote is decided below, once the document's
|
|
200
184
|
// countries are known.
|
|
185
|
+
//
|
|
186
|
+
// One kind of failure is rescued rather than recorded: a span the document
|
|
187
|
+
// itself labels as the very type whose checksum just failed.
|
|
188
|
+
// "Rijksregisternummer: 85.03.19-284.73" is a Belgian national number with a
|
|
189
|
+
// bad check digit — mistyped, OCR'd, or invented — and it is still a
|
|
190
|
+
// national number. Before this, the pattern declined, the generic phone rule
|
|
191
|
+
// was denied the span as a fragment, and the value was left in the clear: a
|
|
192
|
+
// redaction library printing an identifier it had recognised and rejected.
|
|
193
|
+
//
|
|
194
|
+
// Rescued candidates are still recorded as failed spans, so the demotion
|
|
195
|
+
// zones and the fragment rule below are unchanged.
|
|
201
196
|
const validated = [];
|
|
197
|
+
const rescued = [];
|
|
202
198
|
const failedSpans = [];
|
|
203
199
|
for (const m of rawMatches) {
|
|
204
200
|
if (this.matcher.validate(m)) {
|
|
@@ -207,6 +203,9 @@ export class RuleEngine {
|
|
|
207
203
|
else if (m.patternDef.validator !== null && !m.patternDef.requiresContext) {
|
|
208
204
|
failedSpans.push({ start: m.start, end: m.end, code: m.countryCode,
|
|
209
205
|
etype: String(m.patternDef.entityType) });
|
|
206
|
+
if (CUE_TARGETS.has(String(m.patternDef.entityType))
|
|
207
|
+
&& cuedType(text, m.start) === m.patternDef.entityType)
|
|
208
|
+
rescued.push(m);
|
|
210
209
|
}
|
|
211
210
|
}
|
|
212
211
|
// Evidence pass: work out which countries this document belongs to, from
|
|
@@ -401,8 +400,28 @@ export class RuleEngine {
|
|
|
401
400
|
priority = 1;
|
|
402
401
|
blindPriority = 1;
|
|
403
402
|
}
|
|
404
|
-
|
|
403
|
+
let entityType = match.patternDef.entityType;
|
|
404
|
+
let country = (match.countryCode !== "SHARED" && match.countryCode !== "CUSTOM")
|
|
405
405
|
? match.countryCode : null;
|
|
406
|
+
let outOfScope = declared !== null && country !== null && !declared.has(match.countryCode);
|
|
407
|
+
let confidence = "high";
|
|
408
|
+
// The document labels this span as something else. Relabelling, not
|
|
409
|
+
// suppression: the span is found either way, so removing the claim would
|
|
410
|
+
// decide only whether the value is masked — and the answer was "no".
|
|
411
|
+
// "Rijksregisternummer: 85.03.19-284.73" produced no detection at all.
|
|
412
|
+
//
|
|
413
|
+
// The country came from the pattern that matched, and that pattern was
|
|
414
|
+
// just overruled: a Finnish phone rule saying "Finland" is worthless once
|
|
415
|
+
// the value is filed as a Cypriot identity number. Dropping it also clears
|
|
416
|
+
// outOfScope, which would otherwise claim the detection belongs to a
|
|
417
|
+
// country the caller did not declare while naming no country at all.
|
|
418
|
+
const retyped = retypedBy(text, match.start, entityType);
|
|
419
|
+
if (retyped !== null) {
|
|
420
|
+
entityType = retyped;
|
|
421
|
+
country = null;
|
|
422
|
+
outOfScope = false;
|
|
423
|
+
confidence = "medium";
|
|
424
|
+
}
|
|
406
425
|
candidates.push({
|
|
407
426
|
blindPriority,
|
|
408
427
|
cue: localCueBonus(text, match.start, match.patternDef.entityType),
|
|
@@ -411,6 +430,44 @@ export class RuleEngine {
|
|
|
411
430
|
countryScore: countryScores.get(match.countryCode) ?? 0,
|
|
412
431
|
inScope: declared === null || declared.has(match.countryCode) ? 1 : 0,
|
|
413
432
|
tier: 0,
|
|
433
|
+
det: {
|
|
434
|
+
entityType,
|
|
435
|
+
start: match.start,
|
|
436
|
+
end: match.end,
|
|
437
|
+
text: match.text,
|
|
438
|
+
source: DetectionSource.RULES,
|
|
439
|
+
country,
|
|
440
|
+
confidence,
|
|
441
|
+
outOfScope,
|
|
442
|
+
},
|
|
443
|
+
});
|
|
444
|
+
}
|
|
445
|
+
// A declined identifier the document itself labels. Cue-backed by
|
|
446
|
+
// construction, so `cue` is 1 — that is what lets it take a span from a
|
|
447
|
+
// foreign pattern whose checksum happened to pass, which is how
|
|
448
|
+
// "ΑΦΜ: 147382965" was reported as a Czech national ID.
|
|
449
|
+
//
|
|
450
|
+
// `priority` is 0: below every regex-only claim, so it wins only what
|
|
451
|
+
// nothing better wants. `blindPriority` is 1 so that span selection — the
|
|
452
|
+
// country-blind half of the sort — treats it like any other regex-strength
|
|
453
|
+
// claim and invariant I1 is untouched.
|
|
454
|
+
//
|
|
455
|
+
// Emitted as "low", which is the honest description: the shape and the label
|
|
456
|
+
// agree, the check digit does not. A caller wanting only checksum-backed
|
|
457
|
+
// detections filters on it.
|
|
458
|
+
for (const match of rescued) {
|
|
459
|
+
if (shouldSuppress(text, match, scratch))
|
|
460
|
+
continue;
|
|
461
|
+
const country = (match.countryCode !== "SHARED" && match.countryCode !== "CUSTOM")
|
|
462
|
+
? match.countryCode : null;
|
|
463
|
+
candidates.push({
|
|
464
|
+
blindPriority: 1,
|
|
465
|
+
cue: 1,
|
|
466
|
+
priority: 0,
|
|
467
|
+
length: match.end - match.start,
|
|
468
|
+
countryScore: countryScores.get(match.countryCode) ?? 0,
|
|
469
|
+
inScope: declared === null || declared.has(match.countryCode) ? 1 : 0,
|
|
470
|
+
tier: 0,
|
|
414
471
|
det: {
|
|
415
472
|
entityType: match.patternDef.entityType,
|
|
416
473
|
start: match.start,
|
|
@@ -418,7 +475,7 @@ export class RuleEngine {
|
|
|
418
475
|
text: match.text,
|
|
419
476
|
source: DetectionSource.RULES,
|
|
420
477
|
country,
|
|
421
|
-
confidence: "
|
|
478
|
+
confidence: "low",
|
|
422
479
|
outOfScope: declared !== null && country !== null && !declared.has(match.countryCode),
|
|
423
480
|
},
|
|
424
481
|
});
|
|
@@ -40,11 +40,16 @@ const MATH_AFTER = /^\s*[=+\-×÷*/]/;
|
|
|
40
40
|
const COUNTRY_PREFIX_HYPHEN = /(?:^|[^A-Za-z0-9])[A-Z]{1,2}-\s*$/;
|
|
41
41
|
const SEQUENTIAL_PATTERNS = /^(?:0{6,}|1234567890?|0123456789|9876543210?|1111111111?|000000000|123456789)$/;
|
|
42
42
|
// YEAR_PATTERN kept for reference but replaced by RECENT_YEAR in suppressYearAsPostal
|
|
43
|
-
//
|
|
44
|
-
//
|
|
45
|
-
//
|
|
46
|
-
//
|
|
47
|
-
|
|
43
|
+
// `ID_LABEL_BEFORE` and `suppressPhoneAfterIdLabel` lived here. Their labels are
|
|
44
|
+
// now typed entries in `cues.ts`, and a phone-shaped span behind one is
|
|
45
|
+
// *relabelled* rather than dropped.
|
|
46
|
+
//
|
|
47
|
+
// Dropping was the wrong verb. The span is found either way, so removing the
|
|
48
|
+
// claim decided only whether the value was masked — and the answer was "no":
|
|
49
|
+
// "Rijksregisternummer: 85.03.19-284.73" produced no detection at all, a
|
|
50
|
+
// redaction library printing in full an identifier it had recognised and
|
|
51
|
+
// rejected. The 653-miss word-boundary lesson recorded here moved with the
|
|
52
|
+
// labels; see the module comment in `cues.ts`.
|
|
48
53
|
const SERVICE_NUMBER = /^0800[\-\s]/;
|
|
49
54
|
const DATE_PATTERN_FULL = /^\d{2}[-/.]\d{2}[-/.]\d{4}$|^\d{4}[-/.]\d{2}[-/.]\d{2}$/;
|
|
50
55
|
const PASSPORT_CONTEXT_BEFORE = /(?:Reisepass|passport|passeport|paspoort|Bisheriger\s+Reisepass)\s*(?:Nr\.?|Nummer|nummer|number|n[°o])?\s*[:.]?\s*$/i;
|
|
@@ -338,12 +343,6 @@ function suppressYearAsPostal(text, match) {
|
|
|
338
343
|
// Suppress: recent years without postal context are almost never postal codes
|
|
339
344
|
return true;
|
|
340
345
|
}
|
|
341
|
-
function suppressPhoneAfterIdLabel(text, match) {
|
|
342
|
-
if (match.patternDef.entityType !== EntityType.PHONE)
|
|
343
|
-
return false;
|
|
344
|
-
const [before] = getContext(text, match.start, match.end);
|
|
345
|
-
return ID_LABEL_BEFORE.test(before);
|
|
346
|
-
}
|
|
347
346
|
function suppressPhoneServiceNumber(_text, match) {
|
|
348
347
|
if (match.patternDef.entityType !== EntityType.PHONE)
|
|
349
348
|
return false;
|
|
@@ -788,7 +787,7 @@ function suppressRequiresContext(text, match) {
|
|
|
788
787
|
return !match.patternDef.contextKeywords.some(kw => context.includes(kw.toLowerCase()));
|
|
789
788
|
}
|
|
790
789
|
const TYPE_SUPPRESSORS = {
|
|
791
|
-
[EntityType.PHONE]: [suppressCurrency, suppressUnits, suppressReference, suppressMath,
|
|
790
|
+
[EntityType.PHONE]: [suppressCurrency, suppressUnits, suppressReference, suppressMath, suppressPhoneServiceNumber, suppressPhoneDateOverlap, suppressPhoneAsNumberRange],
|
|
792
791
|
[EntityType.NATIONAL_ID]: [suppressCurrency, suppressUnits, suppressReference, suppressLegal, suppressMath, suppressNatidAsPassport, suppressSeNatidAsOrg],
|
|
793
792
|
[EntityType.SSN]: [suppressCurrency, suppressUnits, suppressReference, suppressMath],
|
|
794
793
|
[EntityType.TAX_ID]: [suppressCurrency, suppressUnits, suppressReference, suppressMath, suppressTaxidAsIpAddress],
|
package/dist/esm/types.d.ts
CHANGED
|
@@ -32,6 +32,16 @@ export declare enum EntityType {
|
|
|
32
32
|
HEALTH_INSURANCE = "HEALTH_INSURANCE",
|
|
33
33
|
HEALTHCARE_PROVIDER = "HEALTHCARE_PROVIDER",
|
|
34
34
|
CHAMBER_OF_COMMERCE = "CHAMBER_OF_COMMERCE",
|
|
35
|
+
/**
|
|
36
|
+
* Employee, badge or customer number tied to a person.
|
|
37
|
+
*
|
|
38
|
+
* Emitted **only** when an explicit label names it — see `rules/cues.ts`.
|
|
39
|
+
* There is no pattern for an internal identifier because there is no shape
|
|
40
|
+
* for one; without the label, a digit run is not distinguishable from any
|
|
41
|
+
* other. The type exists so that a labelled employee number is filed
|
|
42
|
+
* correctly instead of being claimed by the phone pattern.
|
|
43
|
+
*/
|
|
44
|
+
INTERNAL_ID = "INTERNAL_ID",
|
|
35
45
|
IMEI = "IMEI",
|
|
36
46
|
GPS_COORDINATES = "GPS_COORDINATES",
|
|
37
47
|
UUID = "UUID",
|
package/dist/esm/types.js
CHANGED
|
@@ -33,6 +33,16 @@ export var EntityType;
|
|
|
33
33
|
EntityType["HEALTH_INSURANCE"] = "HEALTH_INSURANCE";
|
|
34
34
|
EntityType["HEALTHCARE_PROVIDER"] = "HEALTHCARE_PROVIDER";
|
|
35
35
|
EntityType["CHAMBER_OF_COMMERCE"] = "CHAMBER_OF_COMMERCE";
|
|
36
|
+
/**
|
|
37
|
+
* Employee, badge or customer number tied to a person.
|
|
38
|
+
*
|
|
39
|
+
* Emitted **only** when an explicit label names it — see `rules/cues.ts`.
|
|
40
|
+
* There is no pattern for an internal identifier because there is no shape
|
|
41
|
+
* for one; without the label, a digit run is not distinguishable from any
|
|
42
|
+
* other. The type exists so that a labelled employee number is filed
|
|
43
|
+
* correctly instead of being claimed by the phone pattern.
|
|
44
|
+
*/
|
|
45
|
+
EntityType["INTERNAL_ID"] = "INTERNAL_ID";
|
|
36
46
|
EntityType["IMEI"] = "IMEI";
|
|
37
47
|
EntityType["GPS_COORDINATES"] = "GPS_COORDINATES";
|
|
38
48
|
EntityType["UUID"] = "UUID";
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "euredact",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.8",
|
|
4
4
|
"description": "European PII redaction SDK -- fast, zero-dependency, rule-based detection and redaction of personal data across 31 European countries",
|
|
5
5
|
"homepage": "https://euredact.dev",
|
|
6
6
|
"license": "Apache-2.0",
|