@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.
Files changed (78) hide show
  1. package/CHANGELOG.md +530 -0
  2. package/LICENSE +24 -0
  3. package/README.md +184 -2
  4. package/bin/legacy-example-package-name.js +95 -0
  5. package/bin/sensitive-data-sanitizer.js +430 -0
  6. package/bin/stream.js +56 -0
  7. package/docs/API.md +336 -0
  8. package/docs/COVERAGE.md +136 -0
  9. package/docs/HISTORY-REMEDIATION.md +41 -0
  10. package/examples/combined-scanners.mjs +23 -0
  11. package/examples/presidio-bridge.py +32 -0
  12. package/examples/realistic-fakes.mjs +18 -0
  13. package/examples/sanitize-session.mjs +27 -0
  14. package/examples/shared-tool-logs.mjs +21 -0
  15. package/examples/stream-jsonl.mjs +17 -0
  16. package/package.json +124 -4
  17. package/src/adapters.js +204 -0
  18. package/src/arithmetic.js +11 -0
  19. package/src/confidence.js +65 -0
  20. package/src/context.js +424 -0
  21. package/src/credentials.js +54 -0
  22. package/src/detection.js +293 -0
  23. package/src/encoded.js +233 -0
  24. package/src/engines.js +348 -0
  25. package/src/entities.js +166 -0
  26. package/src/escape-spans.js +20 -0
  27. package/src/eslint-plugin.d.ts +10 -0
  28. package/src/eslint-plugin.js +193 -0
  29. package/src/external.js +169 -0
  30. package/src/fake-names.js +222 -0
  31. package/src/fake.js +524 -0
  32. package/src/files.js +131 -0
  33. package/src/gitleaks.js +194 -0
  34. package/src/history-rewrite.js +339 -0
  35. package/src/history.js +103 -0
  36. package/src/identity.js +274 -0
  37. package/src/index.d.ts +321 -0
  38. package/src/index.js +40 -0
  39. package/src/jsonl-stream.js +198 -0
  40. package/src/keywords.js +45 -0
  41. package/src/known.js +164 -0
  42. package/src/legacy.d.ts +3 -0
  43. package/src/legacy.js +2 -0
  44. package/src/locales.js +217 -0
  45. package/src/log-bytes.js +85 -0
  46. package/src/names.js +65 -0
  47. package/src/national-checks.js +129 -0
  48. package/src/national.js +128 -0
  49. package/src/outbound.js +28 -0
  50. package/src/personal-context.js +85 -0
  51. package/src/personal.js +248 -0
  52. package/src/projection.js +126 -0
  53. package/src/propagation.js +163 -0
  54. package/src/public-verifier.js +77 -0
  55. package/src/public.js +153 -0
  56. package/src/record-worker.js +20 -0
  57. package/src/rules.js +210 -0
  58. package/src/sanitizer.js +794 -0
  59. package/src/specimens.js +34 -0
  60. package/src/stream-identity.js +129 -0
  61. package/src/stream-worker.js +18 -0
  62. package/src/stream.js +449 -0
  63. package/src/structured.js +278 -0
  64. package/src/transforms.js +192 -0
  65. package/src/transliteration.js +75 -0
  66. package/src/utf8.js +60 -0
  67. package/src/vendor/entities/aws.json +42 -0
  68. package/src/vendor/entities/azure.json +182 -0
  69. package/src/vendor/entities/google.json +219 -0
  70. package/src/vendor/gitleaks/LICENSE +21 -0
  71. package/src/vendor/gitleaks/provenance.json +8 -0
  72. package/src/vendor/gitleaks/rules-0.json +268 -0
  73. package/src/vendor/gitleaks/rules-1.json +1794 -0
  74. package/src/vendor/gitleaks/rules-2.json +292 -0
  75. package/src/vendor/gitleaks/rules-3.json +263 -0
  76. package/src/vendor/gitleaks/rules-4.json +277 -0
  77. package/src/vendor/gitleaks/rules-5.json +154 -0
  78. package/src/worker-options.js +27 -0
package/src/fake.js ADDED
@@ -0,0 +1,524 @@
1
+ import { createHmac } from 'node:crypto';
2
+ import { parsePhoneNumberFromString } from 'libphonenumber-js/max';
3
+ import { mrzCheck } from './identity.js';
4
+ import { fakeName } from './fake-names.js';
5
+ import * as checks from './national-checks.js';
6
+ import { REDACTED, failure } from './detection.js';
7
+
8
+ const generated = new WeakMap();
9
+ export function generatedValues(options) {
10
+ const transforms = [
11
+ options.transformation,
12
+ ...Object.values(options.transformations ?? {}),
13
+ ];
14
+ return transforms.flatMap((t) =>
15
+ t?.mode === 'fake'
16
+ ? [...(generated.get(t) ?? [])].map(([value, credential]) => ({
17
+ value,
18
+ credential,
19
+ }))
20
+ : []
21
+ );
22
+ }
23
+ function remember(value, transform, credential) {
24
+ let values = generated.get(transform);
25
+ if (!values) {
26
+ values = new Map();
27
+ generated.set(transform, values);
28
+ }
29
+ if (values.size >= 100000 && !values.has(value)) {
30
+ throw failure('ERR_LIMIT');
31
+ }
32
+ values.set(value, credential);
33
+ return value;
34
+ }
35
+ function digest(value, key, purpose = 'value') {
36
+ return createHmac('sha256', key).update(`${purpose}\0${value}`).digest();
37
+ }
38
+ function patternFake(value, key) {
39
+ const bytes = digest(value, key);
40
+ return Array.from(value, (c, i) => {
41
+ const alphabet = /\d/.test(c)
42
+ ? '0123456789'
43
+ : /[A-Z]/.test(c)
44
+ ? 'ABCDEFGHIJKLMNOPQRSTUVWXYZ'
45
+ : /[a-z]/.test(c)
46
+ ? 'abcdefghijklmnopqrstuvwxyz'
47
+ : undefined;
48
+ return alphabet
49
+ ? alphabet[
50
+ (alphabet.indexOf(c) +
51
+ 1 +
52
+ (bytes[i % bytes.length] % (alphabet.length - 1))) %
53
+ alphabet.length
54
+ ]
55
+ : c;
56
+ }).join('');
57
+ }
58
+ function replaceDigits(value, digits) {
59
+ let i = 0;
60
+ return value.replace(/\d/g, () => digits[i++]);
61
+ }
62
+ export function fakeDate(value, key, short = false, birth = true) {
63
+ const iso = /^(\d{4})-(\d{1,2})-(\d{1,2})$/.exec(value),
64
+ local = /^(\d{1,2})([/.])(\d{1,2})\2(\d{4})$/.exec(value);
65
+ let year, month, day;
66
+ if (short && /^\d{6}$/.test(value)) {
67
+ year =
68
+ (birth && Number(value.slice(0, 2)) > 26 ? 1900 : 2000) +
69
+ Number(value.slice(0, 2));
70
+ month = Number(value.slice(2, 4));
71
+ day = Number(value.slice(4, 6));
72
+ } else if (iso) {
73
+ [, year, month, day] = iso.map(Number);
74
+ } else if (local) {
75
+ year = Number(local[4]);
76
+ month = Number(local[3]);
77
+ day = Number(local[1]);
78
+ } else {
79
+ return REDACTED;
80
+ }
81
+ const date = new Date(Date.UTC(year, month - 1, day));
82
+ if (
83
+ date.getUTCFullYear() !== year ||
84
+ date.getUTCMonth() !== month - 1 ||
85
+ date.getUTCDate() !== day
86
+ ) {
87
+ return REDACTED;
88
+ }
89
+ // One private offset for the entire key, independent of type and spelling.
90
+ const days = -((digest('calendar', key, 'date').readUInt16BE(0) % 365) + 1);
91
+ date.setUTCDate(date.getUTCDate() + days);
92
+ const y = String(date.getUTCFullYear()).padStart(4, '0'),
93
+ m = String(date.getUTCMonth() + 1).padStart(2, '0'),
94
+ d = String(date.getUTCDate()).padStart(2, '0');
95
+ return short
96
+ ? y.slice(-2) + m + d
97
+ : iso
98
+ ? `${y}-${m}-${d}`
99
+ : `${d}${local[2]}${m}${local[2]}${y}`;
100
+ }
101
+ const validators = {
102
+ UK_NHS: checks.nhs,
103
+ KR_RRN: checks.rrn,
104
+ IT_FISCAL_CODE: checks.fiscalCode,
105
+ SG_NRIC_FIN: checks.nric,
106
+ PL_PESEL: checks.pesel,
107
+ SE_PERSONNUMMER: checks.personnummer,
108
+ ZA_ID_NUMBER: checks.southAfricanId,
109
+ AU_TFN: checks.tfn,
110
+ CA_SIN: checks.shortLuhn,
111
+ RU_SNILS: checks.snils,
112
+ RU_INN: checks.inn,
113
+ CREDIT_CARD: checks.shortLuhn,
114
+ US_HEALTHCARE_NPI: (v) => checks.shortLuhn(`80840${v}`),
115
+ SPAIN_DNI_NUMBER: (v) =>
116
+ 'TRWAGMYFPDXBNJZSQVHLCKE'[Number(v.slice(0, 8)) % 23] === v.at(-1),
117
+ BRAZIL_CPF_NUMBER: cpf,
118
+ };
119
+ function cpf(d) {
120
+ for (const n of [9, 10]) {
121
+ const sum = Array.from(d.slice(0, n)).reduce(
122
+ (s, c, i) => s + Number(c) * (n + 1 - i),
123
+ 0
124
+ );
125
+ if (((sum * 10) % 11) % 10 !== Number(d[n])) {
126
+ return false;
127
+ }
128
+ }
129
+ return true;
130
+ }
131
+ function fakeId(value, type, key) {
132
+ type =
133
+ {
134
+ UK_NHS_NUMBER: 'UK_NHS',
135
+ AU_TAX_FILE_NUMBER: 'AU_TFN',
136
+ SOUTH_AFRICA_ID_NUMBER: 'ZA_ID_NUMBER',
137
+ ITALY_FISCAL_CODE: 'IT_FISCAL_CODE',
138
+ CANADA_SOCIAL_INSURANCE_NUMBER: 'CA_SIN',
139
+ CREDIT_CARD_NUMBER: 'CREDIT_CARD',
140
+ CREDIT_DEBIT_NUMBER: 'CREDIT_CARD',
141
+ }[type] ?? type;
142
+ const compact = value.replace(/[^A-Za-z0-9]/g, '');
143
+ let candidate = patternFake(compact, key);
144
+ if (/^IBAN(?:_CODE)?$/.test(type)) {
145
+ candidate = fakeIban(compact, candidate);
146
+ }
147
+ candidate = idDate(compact, candidate, type, key);
148
+ if (candidate === REDACTED) {
149
+ return REDACTED;
150
+ }
151
+ candidate = idShape(compact, candidate, type);
152
+ candidate = idChecks(compact, candidate, type);
153
+ if (candidate === REDACTED) {
154
+ return REDACTED;
155
+ }
156
+ let i = 0;
157
+ return value.replace(/[A-Za-z0-9]/g, () => candidate[i++]);
158
+ }
159
+ function fakeIban(compact, candidate) {
160
+ if (!/^[A-Z]{2}\d{2}[A-Z0-9]{11,30}$/.test(compact)) {
161
+ return REDACTED;
162
+ }
163
+ const country = compact.slice(0, 2),
164
+ account = candidate.slice(4);
165
+ let remainder = 0;
166
+ for (const c of `${account}${country}00`) {
167
+ const digits = /\d/.test(c) ? c : String(c.charCodeAt(0) - 55);
168
+ for (const d of digits) {
169
+ remainder = (remainder * 10 + Number(d)) % 97;
170
+ }
171
+ }
172
+ return `${country}${String(98 - remainder).padStart(2, '0')}${account}`;
173
+ }
174
+ function idDate(compact, candidate, type, key) {
175
+ if (
176
+ !['KR_RRN', 'PL_PESEL', 'SE_PERSONNUMMER', 'ZA_ID_NUMBER'].includes(type)
177
+ ) {
178
+ return candidate;
179
+ }
180
+ const width = type === 'SE_PERSONNUMMER' && compact.length === 12 ? 8 : 6;
181
+ const original = compact.slice(0, width),
182
+ yy = original.slice(-6);
183
+ const offset =
184
+ type === 'PL_PESEL' ? Math.floor(Number(yy.slice(2, 4)) / 20) * 20 : 0;
185
+ const century =
186
+ type === 'PL_PESEL'
187
+ ? [1900, 2000, 2100, 2200, 1800][offset / 20]
188
+ : width === 8
189
+ ? Number(original.slice(0, 2)) * 100
190
+ : Number(yy.slice(0, 2)) > 26
191
+ ? 1900
192
+ : 2000;
193
+ const dayOffset =
194
+ type === 'SE_PERSONNUMMER' && Number(yy.slice(4)) > 60 ? 60 : 0;
195
+ const shifted = fakeDate(
196
+ `${century + Number(yy.slice(0, 2))}-${String(Number(yy.slice(2, 4)) - offset).padStart(2, '0')}-${String(Number(yy.slice(4)) - dayOffset).padStart(2, '0')}`,
197
+ key
198
+ );
199
+ if (shifted === REDACTED) {
200
+ return REDACTED;
201
+ }
202
+ const [year, month, day] = shifted.split('-').map(Number);
203
+ const monthOffset =
204
+ type === 'PL_PESEL'
205
+ ? { 1800: 80, 1900: 0, 2000: 20, 2100: 40, 2200: 60 }[
206
+ Math.floor(year / 100) * 100
207
+ ]
208
+ : 0;
209
+ if (monthOffset === undefined) {
210
+ return REDACTED;
211
+ }
212
+ const date =
213
+ (width === 8
214
+ ? String(year).padStart(4, '0')
215
+ : String(year % 100).padStart(2, '0')) +
216
+ String(month + monthOffset).padStart(2, '0') +
217
+ String(day + dayOffset).padStart(2, '0');
218
+ return date + candidate.slice(width);
219
+ }
220
+ function idShape(compact, candidate, type) {
221
+ if (type === 'KR_RRN') {
222
+ candidate = candidate.slice(0, 6) + compact[6] + candidate.slice(7);
223
+ }
224
+ if (type === 'ZA_ID_NUMBER') {
225
+ candidate =
226
+ candidate.slice(0, 10) + compact.slice(10, 12) + candidate.slice(12);
227
+ }
228
+ if (type === 'SG_NRIC_FIN') {
229
+ candidate = compact[0] + candidate.slice(1);
230
+ }
231
+ if (type === 'CREDIT_CARD') {
232
+ candidate = compact.slice(0, 6) + candidate.slice(6);
233
+ }
234
+ if (type === 'US_HEALTHCARE_NPI') {
235
+ candidate = compact[0] + candidate.slice(1);
236
+ }
237
+ if (type === 'IT_FISCAL_CODE') {
238
+ candidate =
239
+ candidate.slice(0, 8) +
240
+ compact[8] +
241
+ candidate.slice(9, 11) +
242
+ compact[11] +
243
+ candidate.slice(12);
244
+ }
245
+ if (type === 'INDIA_PAN_INDIVIDUAL') {
246
+ candidate = candidate.slice(0, 3) + compact[3] + candidate.slice(4);
247
+ }
248
+ return candidate;
249
+ }
250
+ function idChecks(compact, candidate, type) {
251
+ const validate = validators[type];
252
+ if (validate) {
253
+ const letters = [
254
+ 'SG_NRIC_FIN',
255
+ 'IT_FISCAL_CODE',
256
+ 'SPAIN_DNI_NUMBER',
257
+ ].includes(type);
258
+ const count =
259
+ (type === 'RU_INN' && compact.length === 12) ||
260
+ ['RU_SNILS', 'BRAZIL_CPF_NUMBER'].includes(type)
261
+ ? 2
262
+ : 1;
263
+ const prefix = candidate.slice(0, -count);
264
+ let found;
265
+ // Fixed upper bound, including a second prefix when mod-11 has no digit.
266
+ for (let attempt = 0; attempt < 20 && !found; attempt++) {
267
+ const stem = attempt
268
+ ? prefix.slice(0, -1) + String(attempt % 10)
269
+ : prefix;
270
+ for (let check = 0; check < (letters ? 26 : 10 ** count); check++) {
271
+ const next =
272
+ stem +
273
+ (letters
274
+ ? String.fromCharCode(65 + check)
275
+ : String(check).padStart(count, '0'));
276
+ if (validate(next) && next !== compact) {
277
+ found = next;
278
+ break;
279
+ }
280
+ }
281
+ }
282
+ if (!found) {
283
+ return REDACTED;
284
+ }
285
+ candidate = found;
286
+ }
287
+ return candidate;
288
+ }
289
+ function ukDramaPhone(digits, code, suffix) {
290
+ const international = code === '44' && digits.length === 12;
291
+ const local = !code && digits.length === 11 && digits.startsWith('0');
292
+ if (!international && !local) {
293
+ return undefined;
294
+ }
295
+ const national = digits.slice(international ? 2 : 1);
296
+ let prefix;
297
+ if (/^7[1-57-9]/.test(national)) {
298
+ prefix = '7700900';
299
+ } else if (/^80[08]/.test(national)) {
300
+ prefix = '8081570';
301
+ } else if (national.startsWith('20')) {
302
+ prefix = '2079460';
303
+ } else if (/^[12]/.test(national)) {
304
+ prefix = '1632960';
305
+ } else if (national.startsWith('3')) {
306
+ prefix = '3069990';
307
+ }
308
+ return prefix ? `${international ? '44' : '0'}${prefix}${suffix}` : undefined;
309
+ }
310
+ function fictionalPhone(digits, code, bytes) {
311
+ const suffix = String(bytes.readUInt16BE(0) % 1000).padStart(3, '0');
312
+ const uk = ukDramaPhone(digits, code, suffix);
313
+ if (uk) {
314
+ return uk;
315
+ }
316
+ if (code === '1' && digits.length === 11) {
317
+ return `1${digits.slice(1, 4)}55501${String(bytes[0] % 100).padStart(2, '0')}`;
318
+ }
319
+ if (!code && digits.length === 10 && /^[2-9]\d{2}[2-9]/.test(digits)) {
320
+ return `${digits.slice(0, 3)}55501${String(bytes[0] % 100).padStart(2, '0')}`;
321
+ }
322
+ return undefined;
323
+ }
324
+ function plausiblePhone(value, digits, code, key, parsed) {
325
+ if (!parsed?.isValid()) {
326
+ return undefined;
327
+ }
328
+ // Retain the national destination prefix when no fictional range exists.
329
+ const prefix = digits.slice(0, code.length + 3);
330
+ for (let attempt = 0; attempt < 32; attempt++) {
331
+ const next =
332
+ prefix +
333
+ patternFake(digits, `${key}:phone:${attempt}`).slice(prefix.length);
334
+ const check = parsePhoneNumberFromString(
335
+ `${value.startsWith('+') ? '+' : ''}${next}`
336
+ );
337
+ if (
338
+ next !== digits &&
339
+ check?.isValid() &&
340
+ check.getType() === parsed.getType()
341
+ ) {
342
+ return next;
343
+ }
344
+ }
345
+ return undefined;
346
+ }
347
+ function russianMobile(digits, code) {
348
+ return (
349
+ (code === '7' && /^79\d{9}$/.test(digits)) ||
350
+ (!code && /^89\d{9}$/.test(digits))
351
+ );
352
+ }
353
+ function fakePhone(value, key) {
354
+ const digits = value.replace(/\D/g, '');
355
+ const parsed = parsePhoneNumberFromString(value);
356
+ const code = value.startsWith('+')
357
+ ? (parsed?.countryCallingCode ?? (/^\+7/.test(value) ? '7' : undefined))
358
+ : '';
359
+ if (code === undefined) {
360
+ return REDACTED;
361
+ }
362
+ const bytes = digest(digits, key, 'phone');
363
+ let number = fictionalPhone(digits, code, bytes);
364
+ if (!number) {
365
+ if (russianMobile(digits, code)) {
366
+ number = digits.slice(0, 4) + patternFake(digits, key).slice(4);
367
+ } else if (parsed?.isValid()) {
368
+ number = plausiblePhone(value, digits, code, key, parsed);
369
+ if (!number) {
370
+ return REDACTED;
371
+ }
372
+ } else {
373
+ number = code + patternFake(digits, key).slice(code.length);
374
+ if (!code && digits.startsWith('0')) {
375
+ number = `0${number.slice(1)}`;
376
+ }
377
+ }
378
+ }
379
+ if (number === digits) {
380
+ number = number.slice(0, -1) + String((Number(number.at(-1)) + 1) % 10);
381
+ }
382
+ return replaceDigits(value, number);
383
+ }
384
+ function fakeMrz(value, transform) {
385
+ const parts = value.split(/(\r?\n|\\n)/),
386
+ country = transform.mrzCountry;
387
+ if (
388
+ parts.length === 5 &&
389
+ parts.every((line, i) => i % 2 || line.length === 30) &&
390
+ /^[IAC][A-Z<]/.test(parts[0])
391
+ ) {
392
+ return fakeTd1(parts, transform);
393
+ }
394
+ for (let i = 0; i < parts.length; i += 2) {
395
+ const line = parts[i],
396
+ header =
397
+ /^([PVIA][A-Z<])([A-Z<]{3})([A-Z]+)<<([A-Z]+(?:<[A-Z]+)*)(<*)$/.exec(
398
+ line
399
+ );
400
+ if (header) {
401
+ const name = fakeName(
402
+ `${header[3]} ${header[4].replaceAll('<', ' ')}`,
403
+ transform.key
404
+ )
405
+ .toUpperCase()
406
+ .replaceAll(' ', '<');
407
+ const firstSpace = name.indexOf('<');
408
+ parts[i] = `${
409
+ header[1] + (country ?? header[2]) + name.slice(0, firstSpace)
410
+ }<<${name.slice(firstSpace + 1)}`
411
+ .slice(0, line.length)
412
+ .padEnd(line.length, '<');
413
+ } else if (/^[A-Z0-9<]{9}[\d<][A-Z<]{3}\d{6}[\d<][MF<]\d{6}/.test(line)) {
414
+ const number = fakeId(line.slice(0, 9), 'PASSPORT_NUMBER', transform.key);
415
+ const birth = fakeDate(line.slice(13, 19), transform.key, true),
416
+ expiry = fakeDate(line.slice(21, 27), transform.key, true, false);
417
+ if ([number, birth, expiry].includes(REDACTED)) {
418
+ return REDACTED;
419
+ }
420
+ let result =
421
+ number +
422
+ mrzCheck(number) +
423
+ (country ?? line.slice(10, 13)) +
424
+ birth +
425
+ mrzCheck(birth) +
426
+ line[20] +
427
+ expiry +
428
+ mrzCheck(expiry);
429
+ if (line.length === 44 && !value.startsWith('V')) {
430
+ const optional = '<'.repeat(14);
431
+ result += `${optional}0`;
432
+ result += mrzCheck(
433
+ result.slice(0, 10) + result.slice(13, 20) + result.slice(21, 43)
434
+ );
435
+ } else if (line.length === 36 && !value.startsWith('V')) {
436
+ result += '<'.repeat(7);
437
+ result += mrzCheck(
438
+ result.slice(0, 10) + result.slice(13, 20) + result.slice(21, 35)
439
+ );
440
+ } else {
441
+ result = result.padEnd(line.length, '<');
442
+ }
443
+ parts[i] = result;
444
+ } else {
445
+ return REDACTED;
446
+ }
447
+ }
448
+ return parts.join('');
449
+ }
450
+ function fakeTd1(parts, transform) {
451
+ const [header, , data, , names] = parts;
452
+ const number = fakeId(header.slice(5, 14), 'PASSPORT_NUMBER', transform.key);
453
+ const birth = fakeDate(data.slice(0, 6), transform.key, true);
454
+ const expiry = fakeDate(data.slice(8, 14), transform.key, true, false);
455
+ parts[0] =
456
+ header.slice(0, 2) +
457
+ (transform.mrzCountry ?? header.slice(2, 5)) +
458
+ number +
459
+ mrzCheck(number) +
460
+ '<'.repeat(15);
461
+ let result =
462
+ birth +
463
+ mrzCheck(birth) +
464
+ data[7] +
465
+ expiry +
466
+ mrzCheck(expiry) +
467
+ (transform.mrzCountry ?? data.slice(15, 18)) +
468
+ '<'.repeat(11);
469
+ result += mrzCheck(
470
+ parts[0].slice(5, 30) +
471
+ result.slice(0, 7) +
472
+ result.slice(8, 15) +
473
+ result.slice(18, 29)
474
+ );
475
+ parts[2] = result;
476
+ const [surname, given] = names.replace(/<+$/, '').split('<<');
477
+ if (!given || birth === REDACTED || expiry === REDACTED) {
478
+ return REDACTED;
479
+ }
480
+ parts[4] = `${fakeName(surname, transform.key).toUpperCase()}<<${fakeName(
481
+ given.replaceAll('<', ' '),
482
+ transform.key
483
+ )
484
+ .toUpperCase()
485
+ .replaceAll(' ', '<')}`
486
+ .slice(0, 30)
487
+ .padEnd(30, '<');
488
+ return parts.join('');
489
+ }
490
+ export function realisticFake(value, finding, transform) {
491
+ let output;
492
+ if (finding.category === 'credential') {
493
+ if (!transform.credentials) {
494
+ return REDACTED;
495
+ }
496
+ const prefix =
497
+ /^(ghp_|github_pat_|sk-|xox[baprs]-)/.exec(value)?.[0] ?? 'credential_';
498
+ // Punctuation intentionally violates every provider token alphabet/length.
499
+ output = `${prefix}FAKE_${digest(value, transform.key)
500
+ .toString('hex')
501
+ .slice(0, 8)}!`;
502
+ } else if (finding.type === 'PERSON') {
503
+ output = fakeName(value, transform.key);
504
+ } else if (/DATE|BIRTH/.test(finding.type)) {
505
+ output = fakeDate(value, transform.key);
506
+ } else if (finding.type === 'PASSPORT_MRZ') {
507
+ output = fakeMrz(value, transform);
508
+ } else if (finding.type === 'PHONE') {
509
+ output = fakePhone(value, transform.key);
510
+ } else if (finding.type === 'EMAIL') {
511
+ output = `fake.${digest(value, transform.key)
512
+ .toString('hex')
513
+ .slice(0, 12)}@example.com`;
514
+ } else if (/DOMAIN|URL/.test(finding.type)) {
515
+ output = `fake-${digest(value, transform.key)
516
+ .toString('hex')
517
+ .slice(0, 12)}.test`;
518
+ } else {
519
+ output = fakeId(value, finding.type, transform.key);
520
+ }
521
+ return output === value || output === REDACTED
522
+ ? REDACTED
523
+ : remember(output, transform, finding.category === 'credential');
524
+ }
package/src/files.js ADDED
@@ -0,0 +1,131 @@
1
+ import { constants } from 'node:fs';
2
+ import { open, rename, unlink, lstat, readdir } from 'node:fs/promises';
3
+ import { dirname, join, resolve } from 'node:path';
4
+ import { randomUUID } from 'node:crypto';
5
+ import { TextDecoder } from 'node:util';
6
+ import { failure } from './detection.js';
7
+ import { decodeLogBytes } from './log-bytes.js';
8
+
9
+ export function decodeText(bytes, { lossless = false } = {}) {
10
+ if (bytes.includes(0)) {
11
+ throw failure('ERR_BINARY');
12
+ }
13
+ if (lossless) {
14
+ return decodeLogBytes(bytes);
15
+ }
16
+ try {
17
+ return new TextDecoder('utf-8', { fatal: true, ignoreBOM: true }).decode(
18
+ bytes
19
+ );
20
+ } catch {
21
+ throw failure('ERR_ENCODING');
22
+ }
23
+ }
24
+
25
+ export async function readTextFile(path, maxBytes = 10 * 1024 * 1024, options) {
26
+ let handle;
27
+ try {
28
+ const info = await lstat(path);
29
+ if (!info.isFile() || info.isSymbolicLink()) {
30
+ throw failure('ERR_FILE');
31
+ }
32
+ handle = await open(path, constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0));
33
+ const opened = await handle.stat();
34
+ if (!opened.isFile() || opened.size > maxBytes) {
35
+ throw failure('ERR_LIMIT');
36
+ }
37
+ const bytes = Buffer.alloc(Math.min(opened.size + 1, maxBytes + 1));
38
+ let read = 0;
39
+ while (read < bytes.length) {
40
+ const part = await handle.read(bytes, read, bytes.length - read, null);
41
+ if (!part.bytesRead) {
42
+ break;
43
+ }
44
+ read += part.bytesRead;
45
+ }
46
+ if (read > maxBytes || read > opened.size) {
47
+ throw failure('ERR_LIMIT');
48
+ }
49
+ return decodeText(bytes.subarray(0, read), options);
50
+ } catch (error) {
51
+ if (error.code?.startsWith('ERR_')) {
52
+ throw error;
53
+ }
54
+ throw failure('ERR_FILE');
55
+ } finally {
56
+ await handle?.close();
57
+ }
58
+ }
59
+
60
+ export async function atomicWrite(path, text, { replace = false } = {}) {
61
+ const target = resolve(path),
62
+ temporary = join(dirname(target), `.sanitizer-${randomUUID()}.tmp`);
63
+ let handle;
64
+ try {
65
+ try {
66
+ const info = await lstat(target);
67
+ if (!replace || !info.isFile() || info.isSymbolicLink()) {
68
+ throw failure('ERR_OUTPUT');
69
+ }
70
+ } catch (error) {
71
+ if (error.code !== 'ENOENT') {
72
+ throw error;
73
+ }
74
+ }
75
+ handle = await open(temporary, 'wx', 0o600);
76
+ await handle.writeFile(text, 'utf8');
77
+ await handle.sync();
78
+ await handle.close();
79
+ handle = undefined;
80
+ if (replace) {
81
+ await rename(temporary, target);
82
+ } else {
83
+ // link() provides an atomic no-clobber publication; rename() alone
84
+ // would silently overwrite a file created between the checks above.
85
+ const { link } = await import('node:fs/promises');
86
+ await link(temporary, target);
87
+ await unlink(temporary);
88
+ }
89
+ } catch {
90
+ throw failure('ERR_OUTPUT');
91
+ } finally {
92
+ await handle?.close();
93
+ await unlink(temporary).catch(() => {});
94
+ }
95
+ }
96
+
97
+ export async function listTextCandidates(root, maxFiles = 10000) {
98
+ const files = [];
99
+ async function visit(path) {
100
+ const info = await lstat(path);
101
+ if (info.isSymbolicLink()) {
102
+ throw failure('ERR_FILE');
103
+ }
104
+ if (info.isFile()) {
105
+ files.push(path);
106
+ if (files.length > maxFiles) {
107
+ throw failure('ERR_LIMIT');
108
+ }
109
+ return;
110
+ }
111
+ if (!info.isDirectory()) {
112
+ throw failure('ERR_FILE');
113
+ }
114
+ const entries = await readdir(path, { withFileTypes: true });
115
+ for (const entry of entries.sort((a, b) => a.name.localeCompare(b.name))) {
116
+ if (['.git', 'node_modules'].includes(entry.name)) {
117
+ continue;
118
+ }
119
+ await visit(join(path, entry.name));
120
+ }
121
+ }
122
+ try {
123
+ await visit(root);
124
+ } catch (error) {
125
+ if (error.code?.startsWith('ERR_')) {
126
+ throw error;
127
+ }
128
+ throw failure('ERR_FILE');
129
+ }
130
+ return files;
131
+ }