@link-foundation/sensitive-data-sanitizer 0.0.0-stage → 0.11.31
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/CHANGELOG.md +530 -0
- package/LICENSE +24 -0
- package/README.md +184 -2
- package/bin/legacy-example-package-name.js +95 -0
- package/bin/sensitive-data-sanitizer.js +430 -0
- package/bin/stream.js +56 -0
- package/docs/API.md +336 -0
- package/docs/COVERAGE.md +136 -0
- package/docs/HISTORY-REMEDIATION.md +41 -0
- package/examples/combined-scanners.mjs +23 -0
- package/examples/presidio-bridge.py +32 -0
- package/examples/realistic-fakes.mjs +18 -0
- package/examples/sanitize-session.mjs +27 -0
- package/examples/shared-tool-logs.mjs +21 -0
- package/examples/stream-jsonl.mjs +17 -0
- package/package.json +124 -4
- package/src/adapters.js +204 -0
- package/src/arithmetic.js +11 -0
- package/src/confidence.js +65 -0
- package/src/context.js +424 -0
- package/src/credentials.js +54 -0
- package/src/detection.js +293 -0
- package/src/encoded.js +233 -0
- package/src/engines.js +348 -0
- package/src/entities.js +166 -0
- package/src/escape-spans.js +20 -0
- package/src/eslint-plugin.d.ts +10 -0
- package/src/eslint-plugin.js +193 -0
- package/src/external.js +169 -0
- package/src/fake-names.js +222 -0
- package/src/fake.js +524 -0
- package/src/files.js +131 -0
- package/src/gitleaks.js +194 -0
- package/src/history-rewrite.js +339 -0
- package/src/history.js +103 -0
- package/src/identity.js +274 -0
- package/src/index.d.ts +321 -0
- package/src/index.js +40 -0
- package/src/jsonl-stream.js +198 -0
- package/src/keywords.js +45 -0
- package/src/known.js +164 -0
- package/src/legacy.d.ts +3 -0
- package/src/legacy.js +2 -0
- package/src/locales.js +217 -0
- package/src/log-bytes.js +85 -0
- package/src/names.js +65 -0
- package/src/national-checks.js +129 -0
- package/src/national.js +128 -0
- package/src/outbound.js +28 -0
- package/src/personal-context.js +85 -0
- package/src/personal.js +248 -0
- package/src/projection.js +126 -0
- package/src/propagation.js +163 -0
- package/src/public-verifier.js +77 -0
- package/src/public.js +153 -0
- package/src/record-worker.js +20 -0
- package/src/rules.js +210 -0
- package/src/sanitizer.js +794 -0
- package/src/specimens.js +34 -0
- package/src/stream-identity.js +129 -0
- package/src/stream-worker.js +18 -0
- package/src/stream.js +449 -0
- package/src/structured.js +278 -0
- package/src/transforms.js +192 -0
- package/src/transliteration.js +75 -0
- package/src/utf8.js +60 -0
- package/src/vendor/entities/aws.json +42 -0
- package/src/vendor/entities/azure.json +182 -0
- package/src/vendor/entities/google.json +219 -0
- package/src/vendor/gitleaks/LICENSE +21 -0
- package/src/vendor/gitleaks/provenance.json +8 -0
- package/src/vendor/gitleaks/rules-0.json +268 -0
- package/src/vendor/gitleaks/rules-1.json +1794 -0
- package/src/vendor/gitleaks/rules-2.json +292 -0
- package/src/vendor/gitleaks/rules-3.json +263 -0
- package/src/vendor/gitleaks/rules-4.json +277 -0
- package/src/vendor/gitleaks/rules-5.json +154 -0
- package/src/worker-options.js +27 -0
package/src/sanitizer.js
ADDED
|
@@ -0,0 +1,794 @@
|
|
|
1
|
+
import { generatedValues } from './fake.js';
|
|
2
|
+
import { alignEscapes } from './escape-spans.js';
|
|
3
|
+
import { actionable } from './confidence.js';
|
|
4
|
+
import { propagate, confirmationOptions } from './propagation.js';
|
|
5
|
+
import { fakeDocumentSpans } from './specimens.js';
|
|
6
|
+
import {
|
|
7
|
+
failure,
|
|
8
|
+
internalFailure,
|
|
9
|
+
nativeDetect,
|
|
10
|
+
REDACTED,
|
|
11
|
+
residualRounds,
|
|
12
|
+
} from './detection.js';
|
|
13
|
+
import { projectText } from './projection.js';
|
|
14
|
+
import { decodedRuns, encodeRun, encodedValue } from './encoded.js';
|
|
15
|
+
import { URL } from 'node:url';
|
|
16
|
+
import { isPublic } from './public.js';
|
|
17
|
+
import { validateTransforms, renderReplacement } from './transforms.js';
|
|
18
|
+
import { fakeIdentityValue, isIdentityType } from './identity.js';
|
|
19
|
+
import {
|
|
20
|
+
structuredBatches,
|
|
21
|
+
mapStructuredFindings,
|
|
22
|
+
preserveIdentifierKeys,
|
|
23
|
+
structuredValueRanges,
|
|
24
|
+
verifyStructured,
|
|
25
|
+
} from './structured.js';
|
|
26
|
+
|
|
27
|
+
const fakedResults = new WeakMap();
|
|
28
|
+
const publicTypes = new Set(['PERSON', 'ORGANIZATION', 'EMAIL']);
|
|
29
|
+
const optionNames = new Set([
|
|
30
|
+
'credentialsOnly',
|
|
31
|
+
'knownSecrets',
|
|
32
|
+
'knownPersonal',
|
|
33
|
+
'confirmedPersonal',
|
|
34
|
+
'publicEntities',
|
|
35
|
+
'findings',
|
|
36
|
+
'paranoid',
|
|
37
|
+
'decode',
|
|
38
|
+
'maxInputLength',
|
|
39
|
+
'maxFindings',
|
|
40
|
+
'debug',
|
|
41
|
+
'detectors',
|
|
42
|
+
'secretlint',
|
|
43
|
+
'publicKnowledge',
|
|
44
|
+
'transformation',
|
|
45
|
+
'transformations',
|
|
46
|
+
'preserveEncoding',
|
|
47
|
+
'verifyPublic',
|
|
48
|
+
'identityMask',
|
|
49
|
+
'fakeIdentity',
|
|
50
|
+
'fakeValues',
|
|
51
|
+
'structured',
|
|
52
|
+
'structuralFields',
|
|
53
|
+
'minConfidence',
|
|
54
|
+
'threshold',
|
|
55
|
+
'profile',
|
|
56
|
+
]);
|
|
57
|
+
|
|
58
|
+
export function applyProfile(options = {}) {
|
|
59
|
+
return options?.profile === 'publication'
|
|
60
|
+
? {
|
|
61
|
+
...options,
|
|
62
|
+
minConfidence: options.minConfidence ?? options.threshold ?? 0.3,
|
|
63
|
+
identityMask: options.identityMask ?? true,
|
|
64
|
+
fakeIdentity: options.fakeIdentity ?? false,
|
|
65
|
+
}
|
|
66
|
+
: options;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function validateCollections(options) {
|
|
70
|
+
for (const key of [
|
|
71
|
+
'knownSecrets',
|
|
72
|
+
'knownPersonal',
|
|
73
|
+
'confirmedPersonal',
|
|
74
|
+
'publicEntities',
|
|
75
|
+
'findings',
|
|
76
|
+
'detectors',
|
|
77
|
+
'fakeValues',
|
|
78
|
+
'structuralFields',
|
|
79
|
+
]) {
|
|
80
|
+
if (options[key] !== undefined && !Array.isArray(options[key])) {
|
|
81
|
+
throw failure('ERR_CONFIG');
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
for (const key of ['knownSecrets', 'fakeValues', 'structuralFields']) {
|
|
85
|
+
if (
|
|
86
|
+
(options[key] ?? []).some((value) => typeof value !== 'string' || !value)
|
|
87
|
+
) {
|
|
88
|
+
throw failure('ERR_CONFIG');
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
for (const entry of [
|
|
92
|
+
...(options.knownPersonal ?? []),
|
|
93
|
+
...(options.confirmedPersonal ?? []),
|
|
94
|
+
]) {
|
|
95
|
+
if (
|
|
96
|
+
!entry ||
|
|
97
|
+
typeof entry.value !== 'string' ||
|
|
98
|
+
!entry.value ||
|
|
99
|
+
!validType(entry.type)
|
|
100
|
+
) {
|
|
101
|
+
throw failure('ERR_CONFIG');
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
export function validatePublic(entry) {
|
|
107
|
+
if (
|
|
108
|
+
!entry ||
|
|
109
|
+
!publicTypes.has(entry.type) ||
|
|
110
|
+
typeof entry.value !== 'string' ||
|
|
111
|
+
!entry.value ||
|
|
112
|
+
typeof entry.source !== 'string' ||
|
|
113
|
+
typeof entry.reviewedAt !== 'string' ||
|
|
114
|
+
!/^\d{4}-\d{2}-\d{2}$/.test(entry.reviewedAt)
|
|
115
|
+
) {
|
|
116
|
+
throw failure('ERR_CONFIG');
|
|
117
|
+
}
|
|
118
|
+
const reviewedAt = new Date(entry.reviewedAt);
|
|
119
|
+
if (
|
|
120
|
+
Number.isNaN(reviewedAt.getTime()) ||
|
|
121
|
+
reviewedAt.toISOString().slice(0, 10) !== entry.reviewedAt
|
|
122
|
+
) {
|
|
123
|
+
throw failure('ERR_CONFIG');
|
|
124
|
+
}
|
|
125
|
+
let url;
|
|
126
|
+
try {
|
|
127
|
+
url = new URL(entry.source);
|
|
128
|
+
} catch {
|
|
129
|
+
throw failure('ERR_CONFIG');
|
|
130
|
+
}
|
|
131
|
+
if (url.protocol !== 'https:' || url.username || url.password) {
|
|
132
|
+
throw failure('ERR_CONFIG');
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
export function validateOptions(options = {}) {
|
|
137
|
+
if (
|
|
138
|
+
!options ||
|
|
139
|
+
typeof options !== 'object' ||
|
|
140
|
+
Array.isArray(options) ||
|
|
141
|
+
Object.keys(options).some((k) => !optionNames.has(k))
|
|
142
|
+
) {
|
|
143
|
+
throw failure('ERR_CONFIG');
|
|
144
|
+
}
|
|
145
|
+
for (const key of [
|
|
146
|
+
'paranoid',
|
|
147
|
+
'credentialsOnly',
|
|
148
|
+
'decode',
|
|
149
|
+
'secretlint',
|
|
150
|
+
'publicKnowledge',
|
|
151
|
+
'identityMask',
|
|
152
|
+
]) {
|
|
153
|
+
if (options[key] !== undefined && typeof options[key] !== 'boolean') {
|
|
154
|
+
throw failure('ERR_CONFIG');
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
validateConfidence(options);
|
|
158
|
+
validatePolicy(options);
|
|
159
|
+
if (
|
|
160
|
+
options.verifyPublic !== undefined &&
|
|
161
|
+
typeof options.verifyPublic !== 'function'
|
|
162
|
+
) {
|
|
163
|
+
throw failure('ERR_CONFIG');
|
|
164
|
+
}
|
|
165
|
+
validateCollections(options);
|
|
166
|
+
validateTransforms(options);
|
|
167
|
+
validateLimits(options);
|
|
168
|
+
for (const entry of options.publicEntities ?? []) {
|
|
169
|
+
validatePublic(entry);
|
|
170
|
+
}
|
|
171
|
+
return options;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
function validateConfidence(options) {
|
|
175
|
+
for (const key of ['minConfidence', 'threshold']) {
|
|
176
|
+
if (
|
|
177
|
+
options[key] !== undefined &&
|
|
178
|
+
(typeof options[key] !== 'number' ||
|
|
179
|
+
!Number.isFinite(options[key]) ||
|
|
180
|
+
options[key] < 0 ||
|
|
181
|
+
options[key] > 1)
|
|
182
|
+
) {
|
|
183
|
+
throw failure('ERR_CONFIG');
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
if (
|
|
187
|
+
options.minConfidence !== undefined &&
|
|
188
|
+
options.threshold !== undefined &&
|
|
189
|
+
options.minConfidence !== options.threshold
|
|
190
|
+
) {
|
|
191
|
+
throw failure('ERR_CONFIG');
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
function validatePolicy(options) {
|
|
196
|
+
for (const [key, values] of [
|
|
197
|
+
['fakeIdentity', [false, 'specimen-and-synthetic']],
|
|
198
|
+
['structured', ['json', 'jsonl']],
|
|
199
|
+
['profile', ['publication']],
|
|
200
|
+
]) {
|
|
201
|
+
if (options[key] !== undefined && !values.includes(options[key])) {
|
|
202
|
+
throw failure('ERR_CONFIG');
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
function validType(value) {
|
|
208
|
+
return typeof value === 'string' && /^[A-Z][A-Z0-9_]{0,63}$/.test(value);
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
export function validateInput(text, options) {
|
|
212
|
+
if (typeof text !== 'string') {
|
|
213
|
+
throw failure('ERR_INPUT');
|
|
214
|
+
}
|
|
215
|
+
if (text.length > (options.maxInputLength ?? 32 * 1024 * 1024)) {
|
|
216
|
+
throw failure('ERR_LIMIT');
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
export function sanitizeFinding(finding, length) {
|
|
221
|
+
if (
|
|
222
|
+
!finding ||
|
|
223
|
+
!Number.isInteger(finding.start) ||
|
|
224
|
+
!Number.isInteger(finding.end) ||
|
|
225
|
+
finding.start < 0 ||
|
|
226
|
+
finding.end > length ||
|
|
227
|
+
finding.end <= finding.start ||
|
|
228
|
+
!validType(finding.type) ||
|
|
229
|
+
!['personal', 'credential'].includes(finding.category) ||
|
|
230
|
+
typeof finding.rule !== 'string' ||
|
|
231
|
+
!/^[a-z0-9@/._-]{1,120}$/.test(finding.rule)
|
|
232
|
+
) {
|
|
233
|
+
throw failure('ERR_FINDING');
|
|
234
|
+
}
|
|
235
|
+
return {
|
|
236
|
+
start: finding.start,
|
|
237
|
+
end: finding.end,
|
|
238
|
+
type: finding.type,
|
|
239
|
+
category: finding.category,
|
|
240
|
+
rule: finding.rule,
|
|
241
|
+
confidence: validConfidence(finding),
|
|
242
|
+
likelihood: finding.likelihood ?? likelihood(validConfidence(finding)),
|
|
243
|
+
...(finding.kept === 'fake' ? { kept: 'fake' } : {}),
|
|
244
|
+
};
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
function validConfidence(finding) {
|
|
248
|
+
const confidence =
|
|
249
|
+
finding.confidence ?? (finding.category === 'credential' ? 0.95 : 0.85);
|
|
250
|
+
if (
|
|
251
|
+
typeof confidence !== 'number' ||
|
|
252
|
+
!Number.isFinite(confidence) ||
|
|
253
|
+
confidence < 0 ||
|
|
254
|
+
confidence > 1 ||
|
|
255
|
+
(finding.likelihood &&
|
|
256
|
+
![
|
|
257
|
+
'VERY_UNLIKELY',
|
|
258
|
+
'UNLIKELY',
|
|
259
|
+
'POSSIBLE',
|
|
260
|
+
'LIKELY',
|
|
261
|
+
'VERY_LIKELY',
|
|
262
|
+
].includes(finding.likelihood))
|
|
263
|
+
) {
|
|
264
|
+
throw failure('ERR_FINDING');
|
|
265
|
+
}
|
|
266
|
+
return confidence;
|
|
267
|
+
}
|
|
268
|
+
function likelihood(confidence) {
|
|
269
|
+
return confidence >= 0.9
|
|
270
|
+
? 'VERY_LIKELY'
|
|
271
|
+
: confidence >= 0.7
|
|
272
|
+
? 'LIKELY'
|
|
273
|
+
: confidence >= 0.4
|
|
274
|
+
? 'POSSIBLE'
|
|
275
|
+
: confidence >= 0.2
|
|
276
|
+
? 'UNLIKELY'
|
|
277
|
+
: 'VERY_UNLIKELY';
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
function allowed(text, finding, policy) {
|
|
281
|
+
if (
|
|
282
|
+
finding.category === 'credential' ||
|
|
283
|
+
['known-personal', 'confirmed-personal'].includes(finding.rule) ||
|
|
284
|
+
!publicTypes.has(finding.type)
|
|
285
|
+
) {
|
|
286
|
+
return false;
|
|
287
|
+
}
|
|
288
|
+
const value = text.slice(finding.start, finding.end).normalize('NFC');
|
|
289
|
+
return policy.some(
|
|
290
|
+
(entry) =>
|
|
291
|
+
entry.type === finding.type && entry.value.normalize('NFC') === value
|
|
292
|
+
);
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
function encodedDetect(text, options, emit) {
|
|
296
|
+
if (options.decode === false) {
|
|
297
|
+
return;
|
|
298
|
+
}
|
|
299
|
+
for (const run of decodedRuns(text)) {
|
|
300
|
+
if (run.limited) {
|
|
301
|
+
emit({
|
|
302
|
+
start: run.start,
|
|
303
|
+
end: run.end,
|
|
304
|
+
type: 'ENCODED_LIMIT',
|
|
305
|
+
category: 'credential',
|
|
306
|
+
rule: 'encoded-limit',
|
|
307
|
+
confidence: 0.4,
|
|
308
|
+
});
|
|
309
|
+
continue;
|
|
310
|
+
}
|
|
311
|
+
const found = [];
|
|
312
|
+
const view = projectText(run.text).text;
|
|
313
|
+
nativeDetect(view, options, (f) => found.push(f));
|
|
314
|
+
const sensitive = reviewFindings(view, found, options).filter((f) =>
|
|
315
|
+
actionable(f, options)
|
|
316
|
+
);
|
|
317
|
+
if (sensitive.length) {
|
|
318
|
+
emit({
|
|
319
|
+
start: run.start,
|
|
320
|
+
end: run.end,
|
|
321
|
+
type: 'ENCODED_SENSITIVE',
|
|
322
|
+
category: sensitive.some((f) => f.category === 'credential')
|
|
323
|
+
? 'credential'
|
|
324
|
+
: 'personal',
|
|
325
|
+
rule: 'encoded',
|
|
326
|
+
});
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
function locations(text, findings) {
|
|
332
|
+
const starts = [0];
|
|
333
|
+
for (let i = 0; i < text.length; i++) {
|
|
334
|
+
if (text[i] === '\n') {
|
|
335
|
+
starts.push(i + 1);
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
return findings.map((f) => {
|
|
339
|
+
let lo = 0,
|
|
340
|
+
hi = starts.length;
|
|
341
|
+
while (lo + 1 < hi) {
|
|
342
|
+
const mid = (lo + hi) >> 1;
|
|
343
|
+
if (starts[mid] <= f.start) {
|
|
344
|
+
lo = mid;
|
|
345
|
+
} else {
|
|
346
|
+
hi = mid;
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
return { ...f, line: lo + 1, column: f.start - starts[lo] + 1 };
|
|
350
|
+
});
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
export function inspect(text, options = {}) {
|
|
354
|
+
try {
|
|
355
|
+
return inspectText(text, options);
|
|
356
|
+
} catch (error) {
|
|
357
|
+
throw internalFailure(error);
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
function inspectText(text, options = {}) {
|
|
361
|
+
options = applyProfile(options);
|
|
362
|
+
validateOptions(options);
|
|
363
|
+
validateInput(text, options);
|
|
364
|
+
if (options.structured) {
|
|
365
|
+
const findings = structuredBatches(text, options).flatMap((batch) =>
|
|
366
|
+
mapStructuredFindings(
|
|
367
|
+
batch,
|
|
368
|
+
inspect(batch.text, {
|
|
369
|
+
...options,
|
|
370
|
+
structured: undefined,
|
|
371
|
+
findings: [],
|
|
372
|
+
}),
|
|
373
|
+
options
|
|
374
|
+
)
|
|
375
|
+
);
|
|
376
|
+
return inspectFindingsOnly(text, findings, options);
|
|
377
|
+
}
|
|
378
|
+
const findings = [];
|
|
379
|
+
const emit = (f) => {
|
|
380
|
+
if (findings.length >= (options.maxFindings ?? 100000)) {
|
|
381
|
+
throw failure('ERR_LIMIT');
|
|
382
|
+
}
|
|
383
|
+
findings.push(sanitizeFinding(f, text.length));
|
|
384
|
+
};
|
|
385
|
+
const projection = projectText(text);
|
|
386
|
+
const nativeOptions = {
|
|
387
|
+
...options,
|
|
388
|
+
knownSecrets: (options.knownSecrets ?? []).map(
|
|
389
|
+
(value) => projectText(value).text
|
|
390
|
+
),
|
|
391
|
+
knownPersonal: (options.knownPersonal ?? []).map((entry) => ({
|
|
392
|
+
...entry,
|
|
393
|
+
value: projectText(entry.value).text,
|
|
394
|
+
})),
|
|
395
|
+
confirmedPersonal: (options.confirmedPersonal ?? []).map((entry) => ({
|
|
396
|
+
...entry,
|
|
397
|
+
value: projectText(entry.value).text,
|
|
398
|
+
})),
|
|
399
|
+
};
|
|
400
|
+
const mapped = (f) =>
|
|
401
|
+
emit(
|
|
402
|
+
projection.changed
|
|
403
|
+
? {
|
|
404
|
+
...f,
|
|
405
|
+
start: projection.starts[f.start],
|
|
406
|
+
end: projection.ends[f.end - 1],
|
|
407
|
+
}
|
|
408
|
+
: f
|
|
409
|
+
);
|
|
410
|
+
if (projection.changed) {
|
|
411
|
+
nativeDetect(text, options, emit);
|
|
412
|
+
}
|
|
413
|
+
nativeDetect(projection.text, nativeOptions, mapped);
|
|
414
|
+
encodedDetect(projection.text, nativeOptions, mapped);
|
|
415
|
+
for (const f of options.findings ?? []) {
|
|
416
|
+
emit(f);
|
|
417
|
+
}
|
|
418
|
+
return reviewFindings(text, findings, options);
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
// Reapply policy to the native/required-engine union without rescanning text.
|
|
422
|
+
export function reviewFindings(text, findings, options) {
|
|
423
|
+
if (findings.length > (options.maxFindings ?? 100000)) {
|
|
424
|
+
throw failure('ERR_LIMIT');
|
|
425
|
+
}
|
|
426
|
+
const unique = new Map();
|
|
427
|
+
for (const f of findings) {
|
|
428
|
+
if (
|
|
429
|
+
!allowed(text, f, options.publicEntities ?? []) &&
|
|
430
|
+
!isPublic(text, f, options)
|
|
431
|
+
) {
|
|
432
|
+
unique.set(`${f.start}:${f.end}:${f.category}:${f.type}:${f.rule}`, f);
|
|
433
|
+
}
|
|
434
|
+
}
|
|
435
|
+
const result = locations(
|
|
436
|
+
text,
|
|
437
|
+
markFake(
|
|
438
|
+
text,
|
|
439
|
+
propagate(text, markFake(text, [...unique.values()], options), options),
|
|
440
|
+
options
|
|
441
|
+
).sort(
|
|
442
|
+
(a, b) =>
|
|
443
|
+
a.start - b.start || b.end - a.end || a.rule.localeCompare(b.rule)
|
|
444
|
+
)
|
|
445
|
+
);
|
|
446
|
+
options.debug?.({ event: 'detected', count: result.length });
|
|
447
|
+
return result;
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
export function inspectFindingsOnly(text, findings, options) {
|
|
451
|
+
const all = [...findings, ...(options.findings ?? [])].map((f) =>
|
|
452
|
+
sanitizeFinding(f, text.length)
|
|
453
|
+
);
|
|
454
|
+
if (all.length > (options.maxFindings ?? 100000)) {
|
|
455
|
+
throw failure('ERR_LIMIT');
|
|
456
|
+
}
|
|
457
|
+
return locations(
|
|
458
|
+
text,
|
|
459
|
+
markFake(
|
|
460
|
+
text,
|
|
461
|
+
preserveIdentifierKeys(
|
|
462
|
+
text,
|
|
463
|
+
propagate(
|
|
464
|
+
text,
|
|
465
|
+
markFake(text, all, options),
|
|
466
|
+
options,
|
|
467
|
+
options.structured ? structuredValueRanges(text, options) : undefined
|
|
468
|
+
),
|
|
469
|
+
options
|
|
470
|
+
),
|
|
471
|
+
options
|
|
472
|
+
).sort((a, b) => a.start - b.start || b.end - a.end)
|
|
473
|
+
);
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
function selectedFindings(findings, credentialsOnly) {
|
|
477
|
+
return credentialsOnly
|
|
478
|
+
? findings.filter((finding) => finding.category === 'credential')
|
|
479
|
+
: findings;
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
export function markFake(text, findings, options) {
|
|
483
|
+
findings = selectedFindings(findings, options.credentialsOnly);
|
|
484
|
+
const generated = generatedValues(options);
|
|
485
|
+
if (
|
|
486
|
+
!options.fakeIdentity &&
|
|
487
|
+
!options.fakeValues?.length &&
|
|
488
|
+
!generated.length
|
|
489
|
+
) {
|
|
490
|
+
return findings.map((f) => ({ ...f, kept: undefined }));
|
|
491
|
+
}
|
|
492
|
+
const documents = fakeDocumentSpans(text, options);
|
|
493
|
+
const trusted = [];
|
|
494
|
+
for (const record of generated) {
|
|
495
|
+
for (const value of new Set([
|
|
496
|
+
record.value,
|
|
497
|
+
JSON.stringify(record.value).slice(1, -1),
|
|
498
|
+
])) {
|
|
499
|
+
let start = text.indexOf(value);
|
|
500
|
+
while (start >= 0) {
|
|
501
|
+
trusted.push({
|
|
502
|
+
start,
|
|
503
|
+
end: start + value.length,
|
|
504
|
+
credential: record.credential,
|
|
505
|
+
});
|
|
506
|
+
start = text.indexOf(value, start + value.length);
|
|
507
|
+
}
|
|
508
|
+
}
|
|
509
|
+
}
|
|
510
|
+
const reviewed = [];
|
|
511
|
+
for (const value of options.fakeValues ?? []) {
|
|
512
|
+
let start = text.indexOf(value);
|
|
513
|
+
while (start >= 0) {
|
|
514
|
+
const end = start + value.length;
|
|
515
|
+
if (
|
|
516
|
+
!/[\p{L}\p{N}]/u.test(text[start - 1] ?? '') &&
|
|
517
|
+
!/[\p{L}\p{N}]/u.test(text[end] ?? '')
|
|
518
|
+
) {
|
|
519
|
+
reviewed.push({ start, end });
|
|
520
|
+
}
|
|
521
|
+
start = text.indexOf(value, end);
|
|
522
|
+
}
|
|
523
|
+
}
|
|
524
|
+
const candidates = findings.filter(
|
|
525
|
+
(f) =>
|
|
526
|
+
f.category === 'personal' &&
|
|
527
|
+
fakeIdentityValue(text.slice(f.start, f.end), f.type, options)
|
|
528
|
+
);
|
|
529
|
+
// Tolerant MRZ matches can cross a line boundary. An automatic specimen
|
|
530
|
+
// exemption must never cover an overlapping real identity value. Exact
|
|
531
|
+
// reviewed values authorize their full span, including contained fragments.
|
|
532
|
+
const safe = [
|
|
533
|
+
...documents,
|
|
534
|
+
...reviewed,
|
|
535
|
+
...candidates.filter(
|
|
536
|
+
(candidate) =>
|
|
537
|
+
options.fakeValues?.includes(
|
|
538
|
+
text.slice(candidate.start, candidate.end)
|
|
539
|
+
) ||
|
|
540
|
+
!findings.some(
|
|
541
|
+
(other) =>
|
|
542
|
+
isIdentityType(other.type) &&
|
|
543
|
+
other.type !== 'ID' &&
|
|
544
|
+
other.start < candidate.end &&
|
|
545
|
+
other.end > candidate.start &&
|
|
546
|
+
!candidates.includes(other)
|
|
547
|
+
)
|
|
548
|
+
),
|
|
549
|
+
];
|
|
550
|
+
return findings.map((f) => {
|
|
551
|
+
if (
|
|
552
|
+
trusted.some(
|
|
553
|
+
(span) =>
|
|
554
|
+
span.start <= f.start &&
|
|
555
|
+
span.end >= f.end &&
|
|
556
|
+
(f.category !== 'credential' || span.credential)
|
|
557
|
+
)
|
|
558
|
+
) {
|
|
559
|
+
return { ...f, kept: 'fake', faked: true };
|
|
560
|
+
}
|
|
561
|
+
const kept =
|
|
562
|
+
f.category === 'personal' &&
|
|
563
|
+
safe.some((span) => span.start <= f.start && span.end >= f.end) &&
|
|
564
|
+
!findings.some(
|
|
565
|
+
(other) =>
|
|
566
|
+
other.category === 'credential' &&
|
|
567
|
+
other.start < f.end &&
|
|
568
|
+
other.end > f.start
|
|
569
|
+
);
|
|
570
|
+
return kept ? { ...f, kept: 'fake' } : { ...f, kept: undefined };
|
|
571
|
+
});
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
function validatedSpans(text, findings, options) {
|
|
575
|
+
validateOptions(options);
|
|
576
|
+
validateInput(text, options);
|
|
577
|
+
if (
|
|
578
|
+
!Array.isArray(findings) ||
|
|
579
|
+
findings.length > (options.maxFindings ?? 100000)
|
|
580
|
+
) {
|
|
581
|
+
throw failure('ERR_FINDING');
|
|
582
|
+
}
|
|
583
|
+
const sorted = alignEscapes(text, findings)
|
|
584
|
+
.map((f) => sanitizeFinding(f, text.length))
|
|
585
|
+
.map((f) => ({ ...f, kept: undefined }));
|
|
586
|
+
const retained = markFake(text, sorted, options)
|
|
587
|
+
.filter((f) => actionable(f, options))
|
|
588
|
+
.sort((a, b) => a.start - b.start || b.end - a.end);
|
|
589
|
+
const spans = [];
|
|
590
|
+
for (const f of retained) {
|
|
591
|
+
const last = spans.at(-1);
|
|
592
|
+
if (last && f.start < last.end) {
|
|
593
|
+
// A credential value covering a whole escaped JSON run is the secret
|
|
594
|
+
// itself; re-encoding its sanitized content would leave part of that
|
|
595
|
+
// value. Self-contained payloads such as base64 are still re-encoded.
|
|
596
|
+
const covered = (outer, inner) =>
|
|
597
|
+
outer.category === 'credential' &&
|
|
598
|
+
outer.type !== 'ENCODED_SENSITIVE' &&
|
|
599
|
+
inner.type === 'ENCODED_SENSITIVE' &&
|
|
600
|
+
outer.start <= inner.start &&
|
|
601
|
+
outer.end >= inner.end &&
|
|
602
|
+
encodedValue(text.slice(inner.start, inner.end))?.encoding ===
|
|
603
|
+
'json-content';
|
|
604
|
+
const credentialRun = covered(last, f) || covered(f, last);
|
|
605
|
+
last.end = Math.max(last.end, f.end);
|
|
606
|
+
if (f.category === 'credential') {
|
|
607
|
+
last.category = 'credential';
|
|
608
|
+
}
|
|
609
|
+
if (credentialRun) {
|
|
610
|
+
last.type = 'SECRET';
|
|
611
|
+
} else if (preferredType(last.type, f.type, options)) {
|
|
612
|
+
last.type = f.type;
|
|
613
|
+
}
|
|
614
|
+
} else {
|
|
615
|
+
spans.push({ ...f });
|
|
616
|
+
}
|
|
617
|
+
}
|
|
618
|
+
return spans;
|
|
619
|
+
}
|
|
620
|
+
|
|
621
|
+
function preferredType(current, candidate, options) {
|
|
622
|
+
if (current === 'ENCODED_SENSITIVE') {
|
|
623
|
+
return false;
|
|
624
|
+
}
|
|
625
|
+
if (candidate === 'PHONE' && current !== 'PASSPORT_MRZ') {
|
|
626
|
+
return true;
|
|
627
|
+
}
|
|
628
|
+
if (candidate === 'ENCODED_SENSITIVE') {
|
|
629
|
+
return true;
|
|
630
|
+
}
|
|
631
|
+
if (current === 'ENCODED_SENSITIVE' || options.transformations?.[current]) {
|
|
632
|
+
return false;
|
|
633
|
+
}
|
|
634
|
+
if (options.transformations?.[candidate] || candidate === 'PASSPORT_MRZ') {
|
|
635
|
+
return true;
|
|
636
|
+
}
|
|
637
|
+
const broad = (type) =>
|
|
638
|
+
['ID', 'PASSPORT'].includes(type) ||
|
|
639
|
+
(/PASSPORT/.test(type) &&
|
|
640
|
+
!['PASSPORT_MRZ', 'PASSPORT_NUMBER'].includes(type));
|
|
641
|
+
return broad(current) && isIdentityType(candidate) && !broad(candidate);
|
|
642
|
+
}
|
|
643
|
+
export function redactResult(
|
|
644
|
+
text,
|
|
645
|
+
findings,
|
|
646
|
+
options = {},
|
|
647
|
+
replacements = new Map()
|
|
648
|
+
) {
|
|
649
|
+
options = applyProfile(options);
|
|
650
|
+
const spans = validatedSpans(text, findings, options);
|
|
651
|
+
const parts = [];
|
|
652
|
+
const faked = [];
|
|
653
|
+
let cursor = 0;
|
|
654
|
+
for (const span of spans) {
|
|
655
|
+
const replacement =
|
|
656
|
+
replacements.get(`${span.start}:${span.end}`) ??
|
|
657
|
+
renderReplacement(text, span, options, (decoded) =>
|
|
658
|
+
sanitize(decoded, {
|
|
659
|
+
...confirmationOptions(text, findings, options),
|
|
660
|
+
findings: [],
|
|
661
|
+
preserveEncoding: true,
|
|
662
|
+
structured: undefined,
|
|
663
|
+
})
|
|
664
|
+
);
|
|
665
|
+
parts.push(text.slice(cursor, span.start), replacement);
|
|
666
|
+
const transform =
|
|
667
|
+
options.transformations?.[span.type] ?? options.transformation;
|
|
668
|
+
if (
|
|
669
|
+
transform?.mode === 'fake' &&
|
|
670
|
+
replacement !== REDACTED &&
|
|
671
|
+
(span.category === 'personal' || transform.credentials)
|
|
672
|
+
) {
|
|
673
|
+
faked.push(span);
|
|
674
|
+
}
|
|
675
|
+
cursor = span.end;
|
|
676
|
+
}
|
|
677
|
+
parts.push(text.slice(cursor));
|
|
678
|
+
const result = { text: parts.join(''), redactions: spans.length };
|
|
679
|
+
fakedResults.set(result, faked);
|
|
680
|
+
return result;
|
|
681
|
+
}
|
|
682
|
+
|
|
683
|
+
export async function redactResultAsync(
|
|
684
|
+
text,
|
|
685
|
+
findings,
|
|
686
|
+
options,
|
|
687
|
+
sanitizeDecoded
|
|
688
|
+
) {
|
|
689
|
+
const replacements = new Map();
|
|
690
|
+
{
|
|
691
|
+
for (const span of validatedSpans(text, findings, options)) {
|
|
692
|
+
if (span.type !== 'ENCODED_SENSITIVE') {
|
|
693
|
+
continue;
|
|
694
|
+
}
|
|
695
|
+
const run = encodedValue(text.slice(span.start, span.end));
|
|
696
|
+
if (!run) {
|
|
697
|
+
if (options.preserveEncoding) {
|
|
698
|
+
throw failure('ERR_ENCODING');
|
|
699
|
+
}
|
|
700
|
+
continue;
|
|
701
|
+
}
|
|
702
|
+
if (!options.preserveEncoding && run.encoding !== 'json-content') {
|
|
703
|
+
continue;
|
|
704
|
+
}
|
|
705
|
+
const result = await sanitizeDecoded(
|
|
706
|
+
run.text,
|
|
707
|
+
confirmationOptions(text, findings, options)
|
|
708
|
+
);
|
|
709
|
+
replacements.set(
|
|
710
|
+
`${span.start}:${span.end}`,
|
|
711
|
+
encodeRun(run, result.text)
|
|
712
|
+
);
|
|
713
|
+
}
|
|
714
|
+
}
|
|
715
|
+
return redactResult(text, findings, options, replacements);
|
|
716
|
+
}
|
|
717
|
+
|
|
718
|
+
export function redact(text, findings, options = {}) {
|
|
719
|
+
return redactResult(text, findings, options).text;
|
|
720
|
+
}
|
|
721
|
+
|
|
722
|
+
export function sanitize(text, options = {}) {
|
|
723
|
+
try {
|
|
724
|
+
return sanitizeText(text, options);
|
|
725
|
+
} catch (error) {
|
|
726
|
+
throw internalFailure(error);
|
|
727
|
+
}
|
|
728
|
+
}
|
|
729
|
+
function sanitizeText(text, options = {}, round = 0) {
|
|
730
|
+
options = applyProfile(options);
|
|
731
|
+
const findings = inspect(text, options);
|
|
732
|
+
const result = redactResult(text, findings, options);
|
|
733
|
+
const sanitized = redactResult(text, findings, {
|
|
734
|
+
...options,
|
|
735
|
+
transformation: undefined,
|
|
736
|
+
transformations: undefined,
|
|
737
|
+
identityMask: false,
|
|
738
|
+
preserveEncoding: false,
|
|
739
|
+
}).text;
|
|
740
|
+
if (
|
|
741
|
+
inspect(sanitized, { ...options, findings: [] }).some((f) =>
|
|
742
|
+
actionable(f, options)
|
|
743
|
+
)
|
|
744
|
+
) {
|
|
745
|
+
return redactAgain(result, findings, options, round, (next, nextRound) =>
|
|
746
|
+
sanitizeText(result.text, next, nextRound)
|
|
747
|
+
);
|
|
748
|
+
}
|
|
749
|
+
if (options.structured) {
|
|
750
|
+
verifyStructured(result.text, options);
|
|
751
|
+
}
|
|
752
|
+
return { ...result, findings: auditFaked(findings, options, result) };
|
|
753
|
+
}
|
|
754
|
+
|
|
755
|
+
function validateLimits(options) {
|
|
756
|
+
for (const key of ['maxInputLength', 'maxFindings']) {
|
|
757
|
+
if (
|
|
758
|
+
options[key] !== undefined &&
|
|
759
|
+
(!Number.isSafeInteger(options[key]) || options[key] <= 0)
|
|
760
|
+
) {
|
|
761
|
+
throw failure('ERR_CONFIG');
|
|
762
|
+
}
|
|
763
|
+
}
|
|
764
|
+
if (options.debug !== undefined && typeof options.debug !== 'function') {
|
|
765
|
+
throw failure('ERR_CONFIG');
|
|
766
|
+
}
|
|
767
|
+
}
|
|
768
|
+
|
|
769
|
+
// A value found again after redaction, for example when raw and decoded
|
|
770
|
+
// layers disagree about a span, is redacted in another round. Only a run that
|
|
771
|
+
// does not converge within residualRounds fails closed.
|
|
772
|
+
export function redactAgain(result, findings, options, round, rerun) {
|
|
773
|
+
if (round >= residualRounds) {
|
|
774
|
+
throw failure('ERR_RESIDUAL');
|
|
775
|
+
}
|
|
776
|
+
options.debug?.({ event: 'residual-redaction', round: round + 1 });
|
|
777
|
+
const merge = (next) => ({
|
|
778
|
+
...next,
|
|
779
|
+
redactions: result.redactions + next.redactions,
|
|
780
|
+
findings: auditFaked(findings, options, result),
|
|
781
|
+
});
|
|
782
|
+
const next = rerun({ ...options, findings: [] }, round + 1);
|
|
783
|
+
return next instanceof Promise ? next.then(merge) : merge(next);
|
|
784
|
+
}
|
|
785
|
+
|
|
786
|
+
export function auditFaked(findings, options, result) {
|
|
787
|
+
const spans = fakedResults.get(result) ?? [];
|
|
788
|
+
return findings.map((f) =>
|
|
789
|
+
actionable(f, options) &&
|
|
790
|
+
spans.some((span) => span.start <= f.start && span.end >= f.end)
|
|
791
|
+
? { ...f, faked: true }
|
|
792
|
+
: f
|
|
793
|
+
);
|
|
794
|
+
}
|