euredact 0.3.9 → 0.5.0
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 +313 -25
- package/dist/cjs/cache.js +5 -0
- package/dist/cjs/cloud/client.js +243 -0
- package/dist/cjs/cloud/config.js +57 -0
- package/dist/cjs/cloud/errors.js +55 -0
- package/dist/cjs/cloud/index.js +31 -0
- package/dist/cjs/index.js +23 -3
- package/dist/cjs/rules/countries/index.js +24 -5
- package/dist/cjs/sdk.js +442 -29
- package/dist/cjs/types.js +45 -1
- package/dist/esm/cache.d.ts +2 -0
- package/dist/esm/cache.js +5 -1
- package/dist/esm/cloud/client.d.ts +42 -0
- package/dist/esm/cloud/client.js +239 -0
- package/dist/esm/cloud/config.d.ts +43 -0
- package/dist/esm/cloud/config.js +51 -0
- package/dist/esm/cloud/errors.d.ts +32 -0
- package/dist/esm/cloud/errors.js +48 -0
- package/dist/esm/cloud/index.d.ts +19 -0
- package/dist/esm/cloud/index.js +19 -0
- package/dist/esm/index.d.ts +11 -3
- package/dist/esm/index.js +12 -2
- package/dist/esm/rules/countries/index.js +24 -5
- package/dist/esm/sdk.d.ts +128 -1
- package/dist/esm/sdk.js +405 -28
- package/dist/esm/types.d.ts +63 -1
- package/dist/esm/types.js +44 -1
- package/package.json +7 -3
package/dist/cjs/sdk.js
CHANGED
|
@@ -1,12 +1,310 @@
|
|
|
1
1
|
"use strict";
|
|
2
|
+
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
|
3
|
+
if (k2 === undefined) k2 = k;
|
|
4
|
+
var desc = Object.getOwnPropertyDescriptor(m, k);
|
|
5
|
+
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
|
6
|
+
desc = { enumerable: true, get: function() { return m[k]; } };
|
|
7
|
+
}
|
|
8
|
+
Object.defineProperty(o, k2, desc);
|
|
9
|
+
}) : (function(o, m, k, k2) {
|
|
10
|
+
if (k2 === undefined) k2 = k;
|
|
11
|
+
o[k2] = m[k];
|
|
12
|
+
}));
|
|
13
|
+
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
|
|
14
|
+
Object.defineProperty(o, "default", { enumerable: true, value: v });
|
|
15
|
+
}) : function(o, v) {
|
|
16
|
+
o["default"] = v;
|
|
17
|
+
});
|
|
18
|
+
var __importStar = (this && this.__importStar) || (function () {
|
|
19
|
+
var ownKeys = function(o) {
|
|
20
|
+
ownKeys = Object.getOwnPropertyNames || function (o) {
|
|
21
|
+
var ar = [];
|
|
22
|
+
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
|
|
23
|
+
return ar;
|
|
24
|
+
};
|
|
25
|
+
return ownKeys(o);
|
|
26
|
+
};
|
|
27
|
+
return function (mod) {
|
|
28
|
+
if (mod && mod.__esModule) return mod;
|
|
29
|
+
var result = {};
|
|
30
|
+
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
|
|
31
|
+
__setModuleDefault(result, mod);
|
|
32
|
+
return result;
|
|
33
|
+
};
|
|
34
|
+
})();
|
|
2
35
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
-
exports.EuRedact = void 0;
|
|
36
|
+
exports.EuRedact = exports.TokenMapper = exports.TOKEN_SUFFIX_LENGTH = exports.TOKEN_ALPHABET = void 0;
|
|
37
|
+
exports.applyReplacements = applyReplacements;
|
|
38
|
+
exports.restore = restore;
|
|
39
|
+
const node_crypto_1 = require("node:crypto");
|
|
4
40
|
const cache_js_1 = require("./cache.js");
|
|
5
41
|
const normalizer_js_1 = require("./normalizer.js");
|
|
6
42
|
const engine_js_1 = require("./rules/engine.js");
|
|
7
43
|
const evidence_js_1 = require("./rules/evidence.js");
|
|
8
44
|
const types_js_1 = require("./types.js");
|
|
9
45
|
const DATE_TYPES = new Set([types_js_1.EntityType.DOB, types_js_1.EntityType.DATE_OF_DEATH]);
|
|
46
|
+
/**
|
|
47
|
+
* Splice a label over every detection and return the masked text.
|
|
48
|
+
*
|
|
49
|
+
* `detections` must be sorted by `(start, -end)`.
|
|
50
|
+
*
|
|
51
|
+
* Labels are resolved right-to-left because the referential mapper numbers
|
|
52
|
+
* each entity type in call order, and that order is part of the output
|
|
53
|
+
* contract. The string itself is then assembled in a single forward pass:
|
|
54
|
+
* rebuilding it per detection copied the whole document each time, which is
|
|
55
|
+
* O(document x detections) — 1.68 s of pure copying on a 1 MB document with
|
|
56
|
+
* 15,000 detections, versus 2 ms here.
|
|
57
|
+
*
|
|
58
|
+
* Spans from the rule engine are deduplicated and non-overlapping. Spans from
|
|
59
|
+
* elsewhere (the cloud service) are only sorted, so a span may start behind
|
|
60
|
+
* the cursor. Its uncovered tail is still masked rather than dropped: dropping
|
|
61
|
+
* the span would leave those characters in the clear, and splicing it whole
|
|
62
|
+
* would corrupt the label already emitted over its head.
|
|
63
|
+
*/
|
|
64
|
+
function applyReplacements(text, detections, labelFor) {
|
|
65
|
+
const kept = [];
|
|
66
|
+
let pos = 0;
|
|
67
|
+
for (const det of detections) {
|
|
68
|
+
const start = Math.max(det.start, pos);
|
|
69
|
+
if (det.end <= start)
|
|
70
|
+
continue;
|
|
71
|
+
kept.push([det, start, det.end]);
|
|
72
|
+
pos = det.end;
|
|
73
|
+
}
|
|
74
|
+
const labels = new Array(kept.length);
|
|
75
|
+
for (let i = kept.length - 1; i >= 0; i--) {
|
|
76
|
+
const [det, start, end] = kept[i];
|
|
77
|
+
labels[i] = labelFor(det, text.slice(start, end));
|
|
78
|
+
}
|
|
79
|
+
const parts = [];
|
|
80
|
+
pos = 0;
|
|
81
|
+
for (let i = 0; i < kept.length; i++) {
|
|
82
|
+
const [, start, end] = kept[i];
|
|
83
|
+
parts.push(text.slice(pos, start), labels[i]);
|
|
84
|
+
pos = end;
|
|
85
|
+
}
|
|
86
|
+
parts.push(text.slice(pos));
|
|
87
|
+
return parts.join("");
|
|
88
|
+
}
|
|
89
|
+
/**
|
|
90
|
+
* Characters a token suffix is drawn from. No vowels, so a suffix never spells
|
|
91
|
+
* a word; no 0/1/I/O, so it survives being read back by a person; no
|
|
92
|
+
* underscore, so the type prefix stays unambiguous.
|
|
93
|
+
*/
|
|
94
|
+
exports.TOKEN_ALPHABET = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789";
|
|
95
|
+
exports.TOKEN_SUFFIX_LENGTH = 4;
|
|
96
|
+
const TOKEN_MAX_DRAWS = 100;
|
|
97
|
+
function escapeRegExp(s) {
|
|
98
|
+
return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
99
|
+
}
|
|
100
|
+
/**
|
|
101
|
+
* Maps each distinct PII value in one call to a reversible token.
|
|
102
|
+
*
|
|
103
|
+
* A token is `TYPE_XXXX`: the entity type, then `TOKEN_SUFFIX_LENGTH` random
|
|
104
|
+
* characters from `TOKEN_ALPHABET`. The same value gets the same token within
|
|
105
|
+
* the call, so relationships survive; across calls it gets a different one, so
|
|
106
|
+
* two tokenized documents never reveal a shared value.
|
|
107
|
+
*
|
|
108
|
+
* Nothing is kept beyond the call. The mapping goes to the caller in
|
|
109
|
+
* `RedactResult.tokens`, and only the caller can turn it back into text with
|
|
110
|
+
* `restore()`.
|
|
111
|
+
*
|
|
112
|
+
* Tokens are also kept clear of any token-shaped string already in the
|
|
113
|
+
* document. A caller that redacts an LLM's reply to a tokenized prompt has
|
|
114
|
+
* exactly such a document, and a fresh token colliding with an old one would
|
|
115
|
+
* make `restore()` put the wrong value back.
|
|
116
|
+
*/
|
|
117
|
+
class TokenMapper {
|
|
118
|
+
byValue = new Map();
|
|
119
|
+
taken = new Set();
|
|
120
|
+
/** Token -> original value, for `RedactResult.tokens`. */
|
|
121
|
+
tokens = {};
|
|
122
|
+
constructor(text, detections) {
|
|
123
|
+
const types = [...new Set(detections.map(d => String(d.entityType)))].sort();
|
|
124
|
+
if (types.length === 0)
|
|
125
|
+
return;
|
|
126
|
+
const shaped = new RegExp(`(?:${types.map(escapeRegExp).join("|")})_[${exports.TOKEN_ALPHABET}]{${exports.TOKEN_SUFFIX_LENGTH}}`, "g");
|
|
127
|
+
for (const m of text.matchAll(shaped))
|
|
128
|
+
this.taken.add(m[0]);
|
|
129
|
+
}
|
|
130
|
+
/** Return the token for `value`, minting one on first sight. */
|
|
131
|
+
getToken = (det, value) => {
|
|
132
|
+
let token = this.byValue.get(value);
|
|
133
|
+
if (token === undefined) {
|
|
134
|
+
const prefix = `${det.entityType}_`;
|
|
135
|
+
let draws = 0;
|
|
136
|
+
do {
|
|
137
|
+
if (draws++ >= TOKEN_MAX_DRAWS) {
|
|
138
|
+
throw new Error(`could not mint a unique token with prefix ${JSON.stringify(prefix)} after ${TOKEN_MAX_DRAWS} draws`);
|
|
139
|
+
}
|
|
140
|
+
let suffix = "";
|
|
141
|
+
for (let i = 0; i < exports.TOKEN_SUFFIX_LENGTH; i++) {
|
|
142
|
+
suffix += exports.TOKEN_ALPHABET[(0, node_crypto_1.randomInt)(exports.TOKEN_ALPHABET.length)];
|
|
143
|
+
}
|
|
144
|
+
token = prefix + suffix;
|
|
145
|
+
} while (this.taken.has(token));
|
|
146
|
+
this.taken.add(token);
|
|
147
|
+
this.byValue.set(value, token);
|
|
148
|
+
this.tokens[token] = value;
|
|
149
|
+
}
|
|
150
|
+
return token;
|
|
151
|
+
};
|
|
152
|
+
}
|
|
153
|
+
exports.TokenMapper = TokenMapper;
|
|
154
|
+
/**
|
|
155
|
+
* Put the original values back into `text`.
|
|
156
|
+
*
|
|
157
|
+
* `tokens` is `RedactResult.tokens` from the `redact(text, { tokenize: true })`
|
|
158
|
+
* call that produced the text this one derives from — typically the reply an
|
|
159
|
+
* LLM wrote to the tokenized prompt. Every occurrence of every token is
|
|
160
|
+
* replaced; a token an LLM glued to other characters (`EMAIL_K7Q2s`) is still
|
|
161
|
+
* restored, since leaving a token behind is the worse failure.
|
|
162
|
+
*/
|
|
163
|
+
function restore(text, tokens) {
|
|
164
|
+
const keys = Object.keys(tokens);
|
|
165
|
+
if (keys.length === 0)
|
|
166
|
+
return text;
|
|
167
|
+
// Longest first so a token that is a prefix of another (custom pattern names
|
|
168
|
+
// allow it) cannot be matched short. The replacement is a function so `$&`
|
|
169
|
+
// and friends in the original values are literal.
|
|
170
|
+
keys.sort((a, b) => b.length - a.length);
|
|
171
|
+
const pattern = new RegExp(keys.map(escapeRegExp).join("|"), "g");
|
|
172
|
+
return text.replace(pattern, m => tokens[m]);
|
|
173
|
+
}
|
|
174
|
+
/**
|
|
175
|
+
* Types whose separators are presentational: the same identifier stays the
|
|
176
|
+
* same value however it is spaced, hyphenated or dotted, so an allowlisted
|
|
177
|
+
* IBAN is exempted whether the document writes NL91ABNA0417164300 or
|
|
178
|
+
* NL91 ABNA 0417 1643 00 (issue rules-engine#15).
|
|
179
|
+
*
|
|
180
|
+
* Free-text types are deliberately absent: folding separators in an address
|
|
181
|
+
* would make `jan.devries@acme.be` exempt `jandevries@acme.be`, a different
|
|
182
|
+
* mailbox at most providers.
|
|
183
|
+
*/
|
|
184
|
+
const SEPARATOR_INSENSITIVE = new Set([
|
|
185
|
+
types_js_1.EntityType.BANK_ACCOUNT, types_js_1.EntityType.BIC, types_js_1.EntityType.CREDIT_CARD,
|
|
186
|
+
types_js_1.EntityType.PHONE, types_js_1.EntityType.VAT, types_js_1.EntityType.NATIONAL_ID, types_js_1.EntityType.SSN,
|
|
187
|
+
types_js_1.EntityType.TAX_ID, types_js_1.EntityType.PASSPORT, types_js_1.EntityType.DRIVERS_LICENSE,
|
|
188
|
+
types_js_1.EntityType.RESIDENCE_PERMIT, types_js_1.EntityType.HEALTH_INSURANCE,
|
|
189
|
+
types_js_1.EntityType.CHAMBER_OF_COMMERCE, types_js_1.EntityType.IMEI, types_js_1.EntityType.VIN,
|
|
190
|
+
]);
|
|
191
|
+
/** Types carrying a domain an owner may want exempted wholesale. */
|
|
192
|
+
const DOMAIN_BEARING = new Set([types_js_1.EntityType.EMAIL, types_js_1.EntityType.URL]);
|
|
193
|
+
const SEPARATORS = /[\s.\-/()]+/g;
|
|
194
|
+
/**
|
|
195
|
+
* Reject a bare string where a list of allowlisted values is expected.
|
|
196
|
+
*
|
|
197
|
+
* A string is iterable, so `allowlist: "ACME NV"` would become the
|
|
198
|
+
* one-character entries "A", "C", ... — none of which is a whole detection, so
|
|
199
|
+
* nothing is exempted and the caller's own name is redacted after all.
|
|
200
|
+
*/
|
|
201
|
+
function checkAllowlistArg(value, param = "allowlist") {
|
|
202
|
+
if (typeof value === "string") {
|
|
203
|
+
throw new TypeError(`${param} must be an array of values, not a bare string. ` +
|
|
204
|
+
`Pass ${param}: ["${value}"] rather than ${param}: "${value}".`);
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
/**
|
|
208
|
+
* The form an allowlist entry and a detected value are compared in. NFC
|
|
209
|
+
* because the rule engine matches on NFC-normalised text while the document
|
|
210
|
+
* may be NFD; lower-cased because an org name in a heading is the same org.
|
|
211
|
+
*/
|
|
212
|
+
function allowlistKey(value) {
|
|
213
|
+
return value.normalize("NFC").trim().toLowerCase();
|
|
214
|
+
}
|
|
215
|
+
/** `allowlistKey` with presentational separators removed. */
|
|
216
|
+
function foldedKey(value) {
|
|
217
|
+
return allowlistKey(value).replace(SEPARATORS, "");
|
|
218
|
+
}
|
|
219
|
+
/** Comparison key -> the entry as the caller wrote it (for reporting). */
|
|
220
|
+
function normalizeAllowlist(values) {
|
|
221
|
+
checkAllowlistArg(values);
|
|
222
|
+
const out = new Map();
|
|
223
|
+
for (const v of values ?? []) {
|
|
224
|
+
if (!v || !v.trim())
|
|
225
|
+
continue;
|
|
226
|
+
if (!out.has(allowlistKey(v)))
|
|
227
|
+
out.set(allowlistKey(v), v);
|
|
228
|
+
if (!out.has(foldedKey(v)))
|
|
229
|
+
out.set(foldedKey(v), v);
|
|
230
|
+
}
|
|
231
|
+
return out;
|
|
232
|
+
}
|
|
233
|
+
/** Domain key -> the entry as written. A leading `@` or `.` is ignored. */
|
|
234
|
+
function normalizeDomains(values) {
|
|
235
|
+
checkAllowlistArg(values, "allowlistDomains");
|
|
236
|
+
const out = new Map();
|
|
237
|
+
for (const v of values ?? []) {
|
|
238
|
+
if (!v || !v.trim())
|
|
239
|
+
continue;
|
|
240
|
+
const k = allowlistKey(v).replace(/^[@.]+/, "");
|
|
241
|
+
if (k && !out.has(k))
|
|
242
|
+
out.set(k, v);
|
|
243
|
+
}
|
|
244
|
+
return out;
|
|
245
|
+
}
|
|
246
|
+
/** The domain an exemption rule would apply to, or null. */
|
|
247
|
+
function domainOf(entityType, value) {
|
|
248
|
+
if (!DOMAIN_BEARING.has(entityType))
|
|
249
|
+
return null;
|
|
250
|
+
let v = allowlistKey(value);
|
|
251
|
+
if (v.includes("@"))
|
|
252
|
+
return v.slice(v.lastIndexOf("@") + 1).replace(/^[<[(]+|[>\])(),;:"']+$/g, "") || null;
|
|
253
|
+
v = v.replace(/^[a-z][a-z0-9+.-]*:\/\//, "");
|
|
254
|
+
return v.split("/")[0].split(":")[0].replace(/^[<[(]+|[>\])(),;:"']+$/g, "") || null;
|
|
255
|
+
}
|
|
256
|
+
/** The [rule, kind] exempting this detection, or null. */
|
|
257
|
+
function matchingRule(det, slice, allowed, domains) {
|
|
258
|
+
for (const candidate of [slice, det.text]) {
|
|
259
|
+
if (!candidate)
|
|
260
|
+
continue;
|
|
261
|
+
const exact = allowed.get(allowlistKey(candidate));
|
|
262
|
+
if (exact !== undefined)
|
|
263
|
+
return [exact, "value"];
|
|
264
|
+
if (SEPARATOR_INSENSITIVE.has(det.entityType)) {
|
|
265
|
+
const folded = allowed.get(foldedKey(candidate));
|
|
266
|
+
if (folded !== undefined)
|
|
267
|
+
return [folded, "value"];
|
|
268
|
+
}
|
|
269
|
+
const host = domainOf(det.entityType, candidate);
|
|
270
|
+
if (host) {
|
|
271
|
+
for (const [owned, entry] of domains) {
|
|
272
|
+
if (host === owned || host.endsWith("." + owned))
|
|
273
|
+
return [entry, "domain"];
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
return null;
|
|
278
|
+
}
|
|
279
|
+
/**
|
|
280
|
+
* Split detections into those to redact and those the caller exempted.
|
|
281
|
+
*
|
|
282
|
+
* Whole span only: `euredact.be` as a *value* does not exempt
|
|
283
|
+
* `joren@euredact.be`; that is what `allowlistDomains` is for.
|
|
284
|
+
*/
|
|
285
|
+
function applyAllowlist(text, detections, allowed, domains) {
|
|
286
|
+
if (allowed.size === 0 && domains.size === 0)
|
|
287
|
+
return [detections, []];
|
|
288
|
+
const kept = [];
|
|
289
|
+
const exempted = [];
|
|
290
|
+
for (const d of detections) {
|
|
291
|
+
const hit = matchingRule(d, text.slice(d.start, d.end), allowed, domains);
|
|
292
|
+
if (hit === null)
|
|
293
|
+
kept.push(d);
|
|
294
|
+
else
|
|
295
|
+
exempted.push({
|
|
296
|
+
entityType: d.entityType, start: d.start, end: d.end,
|
|
297
|
+
text: text.slice(d.start, d.end), rule: hit[0], ruleKind: hit[1],
|
|
298
|
+
});
|
|
299
|
+
}
|
|
300
|
+
return [kept, exempted];
|
|
301
|
+
}
|
|
302
|
+
/** Two label schemes for the same spans cannot both apply. */
|
|
303
|
+
function checkLabelOptions(options) {
|
|
304
|
+
if (options.tokenize && options.referentialIntegrity) {
|
|
305
|
+
throw new Error("tokenize and referentialIntegrity are two label schemes for the same spans; pass one of them");
|
|
306
|
+
}
|
|
307
|
+
}
|
|
10
308
|
/** Entry count at which a one-time warning is emitted. */
|
|
11
309
|
const MAPPING_WARN_THRESHOLD = 100_000;
|
|
12
310
|
/**
|
|
@@ -79,8 +377,18 @@ class EuRedact {
|
|
|
79
377
|
cache = new cache_js_1.ResultCache();
|
|
80
378
|
referentialMapper = new ReferentialMapper();
|
|
81
379
|
maxInputLength;
|
|
380
|
+
allowlist;
|
|
381
|
+
allowlistDomains;
|
|
382
|
+
/**
|
|
383
|
+
* @param options.maxInputLength Longest document `redact` accepts, in characters.
|
|
384
|
+
* @param options.allowlist Values never to redact, for every call on this
|
|
385
|
+
* instance — an organisation's own name, its own addresses. Merged with
|
|
386
|
+
* the per-call `allowlist`. Matched whole, case-insensitively.
|
|
387
|
+
*/
|
|
82
388
|
constructor(options) {
|
|
83
389
|
this.maxInputLength = options?.maxInputLength ?? DEFAULT_MAX_INPUT_LENGTH;
|
|
390
|
+
this.allowlist = normalizeAllowlist(options?.allowlist);
|
|
391
|
+
this.allowlistDomains = normalizeDomains(options?.allowlistDomains);
|
|
84
392
|
}
|
|
85
393
|
addCustomPattern(name, pattern) {
|
|
86
394
|
this.engine.addCustomPattern(name, pattern);
|
|
@@ -92,15 +400,120 @@ class EuRedact {
|
|
|
92
400
|
this.cache.clear();
|
|
93
401
|
this.referentialMapper.clear();
|
|
94
402
|
}
|
|
403
|
+
/** The instance allowlist merged with a call's, in comparison form. */
|
|
404
|
+
allowedFor(options) {
|
|
405
|
+
const allowed = new Map(this.allowlist);
|
|
406
|
+
for (const [k, v] of normalizeAllowlist(options.allowlist))
|
|
407
|
+
allowed.set(k, v);
|
|
408
|
+
return allowed;
|
|
409
|
+
}
|
|
410
|
+
/** The instance domain list merged with a call's. */
|
|
411
|
+
domainsFor(options) {
|
|
412
|
+
const domains = new Map(this.allowlistDomains);
|
|
413
|
+
for (const [k, v] of normalizeDomains(options.allowlistDomains))
|
|
414
|
+
domains.set(k, v);
|
|
415
|
+
return domains;
|
|
416
|
+
}
|
|
417
|
+
/** The label function for one call, given its output options. */
|
|
418
|
+
labelFor(referentialIntegrity, tokenMapper) {
|
|
419
|
+
if (tokenMapper !== null)
|
|
420
|
+
return tokenMapper.getToken;
|
|
421
|
+
if (referentialIntegrity) {
|
|
422
|
+
const mapper = this.referentialMapper;
|
|
423
|
+
return (det) => mapper.getLabel(det.text, det.entityType);
|
|
424
|
+
}
|
|
425
|
+
return (det) => `[${det.entityType}]`;
|
|
426
|
+
}
|
|
427
|
+
/**
|
|
428
|
+
* Send a document to the cloud tier.
|
|
429
|
+
*
|
|
430
|
+
* Options the service cannot honour throw rather than being ignored. Silently
|
|
431
|
+
* dropping one would mean returning a result that does not match what was
|
|
432
|
+
* asked for — which, for anything that changes which spans come back, is
|
|
433
|
+
* under-redaction wearing a plausible face.
|
|
434
|
+
*/
|
|
435
|
+
async redactViaCloud(text, options) {
|
|
436
|
+
const { CloudClient } = await Promise.resolve().then(() => __importStar(require("./cloud/client.js")));
|
|
437
|
+
const countries = options.countries ?? null;
|
|
438
|
+
if (!countries || countries.length !== 1) {
|
|
439
|
+
throw new Error('cloud mode needs exactly one country, e.g. { countries: ["BE"] }. The ' +
|
|
440
|
+
"model is trained and evaluated per country, so a multi-country request " +
|
|
441
|
+
"has no defined behaviour.");
|
|
442
|
+
}
|
|
443
|
+
if (options.countryHint) {
|
|
444
|
+
throw new Error("countryHint is not supported in cloud mode");
|
|
445
|
+
}
|
|
446
|
+
if (options.context || options.chunkOffset) {
|
|
447
|
+
throw new Error("context/chunkOffset are not supported in cloud mode: the model has never " +
|
|
448
|
+
"seen a chunk boundary, so the service rejects oversized input rather " +
|
|
449
|
+
"than splitting it");
|
|
450
|
+
}
|
|
451
|
+
if (options.referentialIntegrity) {
|
|
452
|
+
throw new Error("referentialIntegrity is not supported in cloud mode");
|
|
453
|
+
}
|
|
454
|
+
// detectDates is deliberately NOT forwarded. The service always runs its
|
|
455
|
+
// rules engine with dates on, because that is what the model was trained
|
|
456
|
+
// against; the caller's value cannot change that. It is the one ignored
|
|
457
|
+
// option that is safe to ignore — it can only cause MORE to be detected,
|
|
458
|
+
// never less, so it cannot produce under-redaction.
|
|
459
|
+
const allowed = this.allowedFor(options);
|
|
460
|
+
const domains = this.domainsFor(options);
|
|
461
|
+
const result = await new CloudClient().redact(text, { country: countries[0] });
|
|
462
|
+
if (options.tokenize || allowed.size > 0 || domains.size > 0) {
|
|
463
|
+
await this.remaskCloudResult(result, text, { tokenize: options.tokenize ?? false, allowed, domains });
|
|
464
|
+
}
|
|
465
|
+
return result;
|
|
466
|
+
}
|
|
467
|
+
/**
|
|
468
|
+
* Rebuild the masked text from the service's spans, in place.
|
|
469
|
+
*
|
|
470
|
+
* The service masks with bracketed labels only. Any other output option is
|
|
471
|
+
* applied here, from its spans: the service builds its `redacted_text` from
|
|
472
|
+
* exactly the spans it returns (entities it reports but cannot place are
|
|
473
|
+
* listed separately and never applied), so nothing it masked is lost by
|
|
474
|
+
* masking again from the same spans.
|
|
475
|
+
*
|
|
476
|
+
* The service reports offsets in code points and this SDK slices UTF-16
|
|
477
|
+
* units, so the slice check is what stands between an emoji in the document
|
|
478
|
+
* and a rebuilt text with the wrong characters masked.
|
|
479
|
+
*/
|
|
480
|
+
async remaskCloudResult(result, text, opts) {
|
|
481
|
+
const { CloudError } = await Promise.resolve().then(() => __importStar(require("./cloud/errors.js")));
|
|
482
|
+
for (const det of result.detections) {
|
|
483
|
+
if (text.slice(det.start, det.end) !== det.text) {
|
|
484
|
+
throw new CloudError("span offsets do not match the document; cannot apply tokenize/allowlist locally");
|
|
485
|
+
}
|
|
486
|
+
}
|
|
487
|
+
[result.detections, result.exempted] = applyAllowlist(text, result.detections, opts.allowed, opts.domains);
|
|
488
|
+
const tokenMapper = opts.tokenize ? new TokenMapper(text, result.detections) : null;
|
|
489
|
+
result.redactedText = applyReplacements(text, result.detections, this.labelFor(false, tokenMapper));
|
|
490
|
+
result.tokens = tokenMapper ? tokenMapper.tokens : {};
|
|
491
|
+
}
|
|
95
492
|
redact(text, options = {}) {
|
|
96
493
|
checkCountryArg(options.countries, "countries");
|
|
97
494
|
checkCountryArg(options.countryHint, "countryHint");
|
|
495
|
+
checkLabelOptions(options);
|
|
496
|
+
const allowed = this.allowedFor(options);
|
|
497
|
+
const domains = this.domainsFor(options);
|
|
498
|
+
const requestedMode = options.mode ?? "rules";
|
|
499
|
+
if (requestedMode === "cloud") {
|
|
500
|
+
// Cannot be served synchronously: the cloud tier is a network call, and
|
|
501
|
+
// returning rules-only output instead would be the silent under-redaction
|
|
502
|
+
// this guard exists to prevent. Point at the async entry point rather
|
|
503
|
+
// than inventing a Promise-or-value union return type.
|
|
504
|
+
throw new Error('mode: "cloud" is asynchronous — use redactAsync(text, { mode: "cloud" }) ' +
|
|
505
|
+
"(or EuRedact#redactAsync). redact() is synchronous and can only serve " +
|
|
506
|
+
'mode: "rules".');
|
|
507
|
+
}
|
|
508
|
+
if (requestedMode !== "rules") {
|
|
509
|
+
throw new Error(`unknown mode ${JSON.stringify(requestedMode)}: expected "rules" or "cloud"`);
|
|
510
|
+
}
|
|
98
511
|
if (text.length > this.maxInputLength) {
|
|
99
512
|
throw new Error(`Input text length (${text.length.toLocaleString()} chars) exceeds the maximum ` +
|
|
100
513
|
`(${this.maxInputLength.toLocaleString()} chars). Split the input or ` +
|
|
101
514
|
`increase maxInputLength when constructing EuRedact.`);
|
|
102
515
|
}
|
|
103
|
-
const { countries = null, countryHint = null, context = null, chunkOffset = 0, mode = "rules", referentialIntegrity = false, detectDates = false, } = options;
|
|
516
|
+
const { countries = null, countryHint = null, context = null, chunkOffset = 0, mode = "rules", referentialIntegrity = false, tokenize = false, detectDates = false, } = options;
|
|
104
517
|
// A context makes the result depend on evidence from other chunks, so the
|
|
105
518
|
// text no longer identifies the result. Caching is disabled rather than
|
|
106
519
|
// keyed on the context, whose contents change as chunks arrive.
|
|
@@ -111,7 +524,14 @@ class EuRedact {
|
|
|
111
524
|
: ["ALL"];
|
|
112
525
|
// countryHint changes attribution, so it must key the cache too.
|
|
113
526
|
const hintKey = countryHint ? countryHint.map(c => c.toUpperCase()).sort().join(",") : "";
|
|
114
|
-
|
|
527
|
+
// referentialIntegrity changes the labels, not the spans, so a cached
|
|
528
|
+
// bracketed result is the wrong answer for a labelled call on the same
|
|
529
|
+
// text — it has to key the cache too.
|
|
530
|
+
// JSON rather than a joined string: an entry may itself contain the
|
|
531
|
+
// separator, and two different lists must never share a key.
|
|
532
|
+
const allowKey = (allowed.size ? JSON.stringify([...allowed.keys()].sort()) : "")
|
|
533
|
+
+ (domains.size ? "|dom=" + JSON.stringify([...domains.keys()].sort()) : "");
|
|
534
|
+
const cacheMode = `${mode}|dates=${detectDates}|hint=${hintKey}|ri=${referentialIntegrity}|tok=${tokenize}|allow=${allowKey}`;
|
|
115
535
|
let cacheKey;
|
|
116
536
|
if (cache) {
|
|
117
537
|
cacheKey = this.cache.key(normalizedText, countriesTuple, cacheMode);
|
|
@@ -133,33 +553,11 @@ class EuRedact {
|
|
|
133
553
|
if (!detectDates) {
|
|
134
554
|
detections = detections.filter(d => !DATE_TYPES.has(d.entityType));
|
|
135
555
|
}
|
|
556
|
+
let exempted;
|
|
557
|
+
[detections, exempted] = applyAllowlist(text, detections, allowed, domains);
|
|
136
558
|
detections.sort((a, b) => a.start - b.start || b.end - a.end);
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
// contract. The string itself is then assembled in a single forward pass:
|
|
140
|
-
// rebuilding it per detection copied the whole document each time, which is
|
|
141
|
-
// O(document x detections) — 1.68 s of pure copying on a 1 MB document with
|
|
142
|
-
// 15,000 detections, versus 2 ms here.
|
|
143
|
-
const replacements = new Array(detections.length);
|
|
144
|
-
for (let i = detections.length - 1; i >= 0; i--) {
|
|
145
|
-
const det = detections[i];
|
|
146
|
-
replacements[i] = referentialIntegrity
|
|
147
|
-
? this.referentialMapper.getLabel(det.text, det.entityType)
|
|
148
|
-
: `[${det.entityType}]`;
|
|
149
|
-
}
|
|
150
|
-
const parts = [];
|
|
151
|
-
let pos = 0;
|
|
152
|
-
for (let i = 0; i < detections.length; i++) {
|
|
153
|
-
const det = detections[i];
|
|
154
|
-
// Spans reach here deduplicated and non-overlapping; one starting behind
|
|
155
|
-
// the cursor would splice into the middle of a label, so drop it.
|
|
156
|
-
if (det.start < pos)
|
|
157
|
-
continue;
|
|
158
|
-
parts.push(text.slice(pos, det.start), replacements[i]);
|
|
159
|
-
pos = det.end;
|
|
160
|
-
}
|
|
161
|
-
parts.push(text.slice(pos));
|
|
162
|
-
const redacted = parts.join("");
|
|
559
|
+
const tokenMapper = tokenize ? new TokenMapper(text, detections) : null;
|
|
560
|
+
const redacted = applyReplacements(text, detections, this.labelFor(referentialIntegrity, tokenMapper));
|
|
163
561
|
// Report the inference so it can be audited. Spans in `evidence` are
|
|
164
562
|
// offsets into the normalised text, matching `detections`.
|
|
165
563
|
const inferredCountries = [...(0, evidence_js_1.weightsToRanking)(scores).entries()]
|
|
@@ -172,12 +570,27 @@ class EuRedact {
|
|
|
172
570
|
inferredCountries,
|
|
173
571
|
evidence,
|
|
174
572
|
detectionMode: countries && countries.length ? "declared" : "inferred",
|
|
573
|
+
tokens: tokenMapper ? tokenMapper.tokens : {},
|
|
574
|
+
exempted,
|
|
175
575
|
};
|
|
176
576
|
if (cache && cacheKey) {
|
|
177
577
|
this.cache.put(cacheKey, result);
|
|
178
578
|
}
|
|
179
579
|
return result;
|
|
180
580
|
}
|
|
581
|
+
/**
|
|
582
|
+
* Redact, awaiting the cloud tier when `mode: "cloud"` is asked for.
|
|
583
|
+
*
|
|
584
|
+
* `mode: "rules"` resolves immediately with exactly what `redact()` returns,
|
|
585
|
+
* so callers that may or may not use the cloud tier can hold one code path.
|
|
586
|
+
*/
|
|
587
|
+
async redactAsync(text, options = {}) {
|
|
588
|
+
const mode = options.mode ?? "rules";
|
|
589
|
+
if (mode !== "cloud")
|
|
590
|
+
return this.redact(text, options);
|
|
591
|
+
checkLabelOptions(options);
|
|
592
|
+
return this.redactViaCloud(text, options);
|
|
593
|
+
}
|
|
181
594
|
redactBatch(texts, options = {}) {
|
|
182
595
|
// Generation is country-blind, so every country's patterns are loaded
|
|
183
596
|
// regardless of what the caller declared.
|
package/dist/cjs/types.js
CHANGED
|
@@ -1,9 +1,25 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
3
|
exports.DetectionSource = exports.LEGACY_TYPE_ALIASES = exports.EntityType = void 0;
|
|
4
|
+
exports.canonicalType = canonicalType;
|
|
4
5
|
var EntityType;
|
|
5
6
|
(function (EntityType) {
|
|
6
|
-
|
|
7
|
+
/**
|
|
8
|
+
* Canonical name for a person's name. `NAME` is kept below as a legacy alias
|
|
9
|
+
* with the same value, exactly as `IBAN` aliases `BANK_ACCOUNT`, so
|
|
10
|
+
* `EntityType.NAME === EntityType.PERSON_NAME` and existing code keeps
|
|
11
|
+
* working — but the emitted value, and therefore the `[PERSON_NAME]`
|
|
12
|
+
* placeholder written into redacted text, is canonical.
|
|
13
|
+
*
|
|
14
|
+
* Renamed rather than translated at the cloud boundary: two names for one
|
|
15
|
+
* type is how a whole category goes missing when someone filters on the
|
|
16
|
+
* spelling they happened to know. Nothing could have depended on the old
|
|
17
|
+
* value — the type is cloud-only and the cloud tier was stubbed, so it was
|
|
18
|
+
* never emitted.
|
|
19
|
+
*/
|
|
20
|
+
EntityType["PERSON_NAME"] = "PERSON_NAME";
|
|
21
|
+
/** @deprecated legacy alias of {@link EntityType.PERSON_NAME} */
|
|
22
|
+
EntityType["NAME"] = "PERSON_NAME";
|
|
7
23
|
EntityType["ADDRESS"] = "ADDRESS";
|
|
8
24
|
/**
|
|
9
25
|
* Canonical name for a bank account identifier. `IBAN` is kept below as a
|
|
@@ -51,6 +67,20 @@ var EntityType;
|
|
|
51
67
|
EntityType["UUID"] = "UUID";
|
|
52
68
|
EntityType["SOCIAL_HANDLE"] = "SOCIAL_HANDLE";
|
|
53
69
|
EntityType["SECRET"] = "SECRET";
|
|
70
|
+
// ── [CLOUD EXTENSION] ────────────────────────────────────────────────────
|
|
71
|
+
// Types only the cloud tier can detect. The rule engine never emits these —
|
|
72
|
+
// there is no shape to match on — which is exactly why they exist: the
|
|
73
|
+
// model's job is what the rules cannot catch. Named to match the detection
|
|
74
|
+
// canon the service is trained and evaluated against.
|
|
75
|
+
EntityType["ORGANISATION_NAME"] = "ORGANISATION_NAME";
|
|
76
|
+
EntityType["JOB_TITLE"] = "JOB_TITLE";
|
|
77
|
+
EntityType["MEDICAL_CONDITION"] = "MEDICAL_CONDITION";
|
|
78
|
+
EntityType["SENSITIVE_ATTRIBUTE"] = "SENSITIVE_ATTRIBUTE";
|
|
79
|
+
EntityType["BIOMETRIC_REF"] = "BIOMETRIC_REF";
|
|
80
|
+
EntityType["FINANCIAL_AMOUNT"] = "FINANCIAL_AMOUNT";
|
|
81
|
+
EntityType["QUASI_IDENTIFIER"] = "QUASI_IDENTIFIER";
|
|
82
|
+
EntityType["CREDENTIAL"] = "CREDENTIAL";
|
|
83
|
+
EntityType["URL"] = "URL";
|
|
54
84
|
EntityType["OTHER"] = "OTHER";
|
|
55
85
|
})(EntityType || (exports.EntityType = EntityType = {}));
|
|
56
86
|
/**
|
|
@@ -60,7 +90,21 @@ var EntityType;
|
|
|
60
90
|
*/
|
|
61
91
|
exports.LEGACY_TYPE_ALIASES = {
|
|
62
92
|
IBAN: "BANK_ACCOUNT",
|
|
93
|
+
NAME: "PERSON_NAME",
|
|
94
|
+
STREET_ADDRESS: "ADDRESS",
|
|
95
|
+
NATIONALITY_ETHNICITY: "SENSITIVE_ATTRIBUTE",
|
|
63
96
|
};
|
|
97
|
+
/**
|
|
98
|
+
* Resolve a possibly-legacy type name to its canonical form.
|
|
99
|
+
*
|
|
100
|
+
* Mirrors `EntityType._missing_` in the Python SDK. An unrecognised name is
|
|
101
|
+
* returned unchanged rather than dropped: a client one release behind the
|
|
102
|
+
* service must not silently lose a whole category of PII.
|
|
103
|
+
*/
|
|
104
|
+
function canonicalType(name) {
|
|
105
|
+
const upper = String(name ?? "").trim().toUpperCase();
|
|
106
|
+
return exports.LEGACY_TYPE_ALIASES[upper] ?? upper;
|
|
107
|
+
}
|
|
64
108
|
var DetectionSource;
|
|
65
109
|
(function (DetectionSource) {
|
|
66
110
|
DetectionSource["RULES"] = "rules";
|
package/dist/esm/cache.d.ts
CHANGED
package/dist/esm/cache.js
CHANGED
|
@@ -4,10 +4,14 @@ import { createHash } from "node:crypto";
|
|
|
4
4
|
// gigabyte of PII-bearing text held live. ~16M characters is a few tens of MB.
|
|
5
5
|
const DEFAULT_MAX_CHARS = 16_000_000;
|
|
6
6
|
/** Approximate retained size of a cached result, in characters. */
|
|
7
|
-
function resultChars(result) {
|
|
7
|
+
export function resultChars(result) {
|
|
8
8
|
let chars = result.redactedText.length;
|
|
9
9
|
for (const d of result.detections)
|
|
10
10
|
chars += d.text.length;
|
|
11
|
+
for (const [token, value] of Object.entries(result.tokens))
|
|
12
|
+
chars += token.length + value.length;
|
|
13
|
+
for (const e of result.exempted)
|
|
14
|
+
chars += e.text.length + e.rule.length;
|
|
11
15
|
return chars;
|
|
12
16
|
}
|
|
13
17
|
export class ResultCache {
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* [CLOUD EXTENSION] The cloud client.
|
|
3
|
+
*
|
|
4
|
+
* Speaks to the euRedact inference service: a deterministic rules engine
|
|
5
|
+
* followed by a fine-tuned model asked only *what did the rules miss?* The
|
|
6
|
+
* service returns both the redacted document and located spans, so this
|
|
7
|
+
* module's job is transport and translation, not detection.
|
|
8
|
+
*
|
|
9
|
+
* Three things it hides from the caller:
|
|
10
|
+
*
|
|
11
|
+
* - **Whether the document finished inside the service's sync window.** A short
|
|
12
|
+
* document comes back on the original request; a long one, or one arriving
|
|
13
|
+
* while a GPU node is cold, returns a job handle and is polled. Callers
|
|
14
|
+
* should never write that branch, so `redact()` resolves either way.
|
|
15
|
+
* - **Retries.** Every request carries an `Idempotency-Key`, so a retry after a
|
|
16
|
+
* timeout cannot produce a second job or a second usage row.
|
|
17
|
+
* - **`Retry-After`.** When the service says how long to wait, that is obeyed
|
|
18
|
+
* rather than second-guessed with a backoff curve.
|
|
19
|
+
*
|
|
20
|
+
* Zero dependencies, like the rest of the package: this uses the platform's
|
|
21
|
+
* own `fetch`.
|
|
22
|
+
*/
|
|
23
|
+
import { type CloudConfig } from "./config.js";
|
|
24
|
+
import { type RedactResult } from "../types.js";
|
|
25
|
+
export interface CloudRedactOptions {
|
|
26
|
+
country: string;
|
|
27
|
+
language?: string;
|
|
28
|
+
priority?: "interactive" | "batch";
|
|
29
|
+
rulesOnly?: boolean;
|
|
30
|
+
idempotencyKey?: string;
|
|
31
|
+
/** Injectable for tests. Defaults to the platform `fetch`. */
|
|
32
|
+
fetchImpl?: typeof fetch;
|
|
33
|
+
}
|
|
34
|
+
export declare class CloudClient {
|
|
35
|
+
readonly config: CloudConfig;
|
|
36
|
+
constructor(config?: CloudConfig | null);
|
|
37
|
+
private headers;
|
|
38
|
+
private raiseFor;
|
|
39
|
+
redact(text: string, options: CloudRedactOptions): Promise<RedactResult>;
|
|
40
|
+
private poll;
|
|
41
|
+
private withTimeout;
|
|
42
|
+
}
|