@link-foundation/sensitive-data-sanitizer 0.0.0-stage → 0.12.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.
Files changed (78) hide show
  1. package/CHANGELOG.md +578 -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
@@ -0,0 +1,34 @@
1
+ import { fakeIdentityValue } from './identity.js';
2
+
3
+ export function fakeDocumentSpans(text, options) {
4
+ if (options.fakeIdentity !== 'specimen-and-synthetic') {
5
+ return [];
6
+ }
7
+ const spans = [];
8
+ // Group adjacent name/data lines using the document-number field as evidence.
9
+ // A specimen header beside a real document must never exempt the latter.
10
+ const documents =
11
+ /([PVIA][A-Z<]([A-Z<]{3})[A-Z<]{8,44})(?:\r?\n|\\n|[ \t]+)([A-Z0-9<]{9}[\d<]([A-Z<]{3})[\d<]{6}[\d<][MF<][A-Z0-9<]{7,25})/g;
12
+ for (const match of text.matchAll(documents)) {
13
+ if (fakeIdentityValue(match[3], 'PASSPORT_MRZ', options)) {
14
+ spans.push({ start: match.index, end: match.index + match[0].length });
15
+ }
16
+ }
17
+ const td1 =
18
+ /[IAC][A-Z<][A-Z<]{3}[A-Z0-9<]{9}[\d<][A-Z0-9<]{15}(?:\r?\n|\\n|[ \t]+)[\d<]{6}[\d<][MF<][\d<]{6}[\d<][A-Z<]{3}[A-Z0-9<]{12}(?:\r?\n|\\n|[ \t]+)[A-Z]+<<[A-Z<]{2,27}/g;
19
+ for (const match of text.matchAll(td1)) {
20
+ if (fakeIdentityValue(match[0].slice(0, 30), 'PASSPORT_MRZ', options)) {
21
+ spans.push({ start: match.index, end: match.index + match[0].length });
22
+ }
23
+ }
24
+ // Published German specimen fields are a bounded fragment, not an
25
+ // exemption for other identities elsewhere on the same log record.
26
+ for (const match of text.matchAll(
27
+ /(?:(?:MUSTERMANN|ERIKA|SPECIMEN|ОБРАЗЕЦ|REISEPASS)[ \t]+){0,5}(?:C01X00T47|T22000129)(?:[ \t]+(?:MUSTERMANN|ERIKA|SPECIMEN|ОБРАЗЕЦ)){0,5}/gi
28
+ )) {
29
+ if (/(?:SPECIMEN|MUSTERMANN|ОБРАЗЕЦ)/i.test(match[0])) {
30
+ spans.push({ start: match.index, end: match.index + match[0].length });
31
+ }
32
+ }
33
+ return spans;
34
+ }
@@ -0,0 +1,129 @@
1
+ import { createHmac, randomBytes } from 'node:crypto';
2
+ import { inspect, redact, applyProfile, validateOptions } from './sanitizer.js';
3
+ import { confirmedSeeds } from './propagation.js';
4
+ import { failure } from './detection.js';
5
+ import { nameAliases } from './transliteration.js';
6
+ import { structuredBatches } from './structured.js';
7
+ import { actionable } from './confidence.js';
8
+
9
+ // Per-stream values remain private. Prior unredacted tokens are represented
10
+ // only by keyed digests; a later confirmation blocks one-pass publication.
11
+ // A complete registry from discovery needs no prior-token history in pass 2.
12
+ export function streamIdentity(
13
+ options,
14
+ limit = 100000,
15
+ collecting = false,
16
+ complete = false
17
+ ) {
18
+ options = applyProfile(options);
19
+ validateOptions(options);
20
+ if (!Number.isSafeInteger(limit) || limit < 1) {
21
+ throw failure('ERR_CONFIG');
22
+ }
23
+ if ((options.confirmedPersonal?.length ?? 0) > limit) {
24
+ throw failure('ERR_LIMIT');
25
+ }
26
+ const key = randomBytes(32),
27
+ seen = new Set(),
28
+ values = new Map(
29
+ (options.confirmedPersonal ?? []).map((entry) => [
30
+ entry.value.trim().normalize('NFC').toLowerCase(),
31
+ entry.type,
32
+ ])
33
+ );
34
+ const personal = (options.confirmedPersonal ?? []).map((entry) => ({
35
+ ...entry,
36
+ value: entry.value.trim(),
37
+ }));
38
+ const configuration = { ...options, confirmedPersonal: personal };
39
+ function fingerprint(value) {
40
+ return createHmac('sha256', key)
41
+ .update(value.trim().normalize('NFC').toLowerCase())
42
+ .digest('base64');
43
+ }
44
+ function checkLimit() {
45
+ if (seen.size + values.size > limit) {
46
+ throw failure('ERR_LIMIT');
47
+ }
48
+ }
49
+ function appearedEarlier(value) {
50
+ // Compound usernames/addresses can have been emitted before any label.
51
+ // Their components use the same private history as confirmed name tokens.
52
+ const components = value.match(/[\p{L}\p{N}]{2,128}/gu) ?? [];
53
+ return [value, ...components].some((part) => seen.has(fingerprint(part)));
54
+ }
55
+ function confirm(text, findings) {
56
+ for (const [value, finding] of confirmedSeeds(
57
+ text,
58
+ findings,
59
+ configuration
60
+ )) {
61
+ const normalized = value.trim().normalize('NFC').toLowerCase();
62
+ if (values.has(normalized)) {
63
+ continue;
64
+ }
65
+ if (!collecting && !complete && appearedEarlier(normalized)) {
66
+ throw failure('ERR_LATE_PERSONAL');
67
+ }
68
+ if (complete) {
69
+ options.debug?.({ event: 'two-pass-confirmation', type: finding.type });
70
+ }
71
+ values.set(normalized, finding.type);
72
+ personal.push({ value: normalized, type: finding.type });
73
+ checkLimit();
74
+ }
75
+ }
76
+ checkLimit();
77
+ return {
78
+ options: configuration,
79
+ prepare(text, findings = inspect(text, configuration)) {
80
+ if (configuration.credentialsOnly) {
81
+ return [];
82
+ }
83
+ confirm(text, findings);
84
+ if (collecting || complete) {
85
+ return [...personal];
86
+ }
87
+ const unredacted = redact(text, findings, {
88
+ ...configuration,
89
+ identityMask: false,
90
+ transformation: undefined,
91
+ transformations: undefined,
92
+ preserveEncoding: false,
93
+ });
94
+ const parts = configuration.structured
95
+ ? structuredBatches(unredacted, configuration).flatMap((batch) =>
96
+ batch.entries.map((entry) => JSON.parse(entry.raw))
97
+ )
98
+ : [unredacted];
99
+ const tokens = new Set();
100
+ for (const finding of findings) {
101
+ if (
102
+ finding.category === 'personal' &&
103
+ !actionable(finding, configuration)
104
+ ) {
105
+ seen.add(fingerprint(text.slice(finding.start, finding.end)));
106
+ checkLimit();
107
+ }
108
+ }
109
+ for (const part of parts) {
110
+ for (const match of part.matchAll(
111
+ /(?<![\p{L}\p{N}])[\p{L}\p{N}]{2,128}(?![\p{L}\p{N}])/gu
112
+ )) {
113
+ if (tokens.has(match[0])) {
114
+ continue;
115
+ }
116
+ tokens.add(match[0]);
117
+ if (tokens.size > limit) {
118
+ throw failure('ERR_LIMIT');
119
+ }
120
+ for (const alias of nameAliases(match[0])) {
121
+ seen.add(fingerprint(alias));
122
+ checkLimit();
123
+ }
124
+ }
125
+ }
126
+ return [...personal];
127
+ },
128
+ };
129
+ }
@@ -0,0 +1,18 @@
1
+ import { parentPort, workerData } from 'node:worker_threads';
2
+ import { sanitizeFileToFile } from './stream.js';
3
+ try {
4
+ parentPort.postMessage({
5
+ value: await sanitizeFileToFile(
6
+ workerData.source,
7
+ workerData.target,
8
+ workerData.options
9
+ ),
10
+ });
11
+ } catch (error) {
12
+ parentPort.postMessage({
13
+ error: /^ERR_[A-Z_]+$/.test(error.code) ? error.code : 'ERR_WORKER',
14
+ record: error.record,
15
+ offset: error.offset,
16
+ recordOffset: error.recordOffset,
17
+ });
18
+ }
package/src/stream.js ADDED
@@ -0,0 +1,449 @@
1
+ import { LogDecoder, encodeLogText } from './log-bytes.js';
2
+ import { constants, createReadStream } from 'node:fs';
3
+ import {
4
+ open,
5
+ lstat,
6
+ unlink,
7
+ link,
8
+ rename,
9
+ mkdtemp,
10
+ rm,
11
+ } from 'node:fs/promises';
12
+ import { URL } from 'node:url';
13
+ import { dirname, resolve, join } from 'node:path';
14
+ import { randomUUID } from 'node:crypto';
15
+ import { Worker } from 'node:worker_threads';
16
+ import {
17
+ createSanitizer,
18
+ sanitizerConfiguration,
19
+ prepareStreamEngine,
20
+ } from './engines.js';
21
+ import { failure, internalFailure } from './detection.js';
22
+ import { sanitizeJsonlStream } from './jsonl-stream.js';
23
+ import { workerExecArgv } from './worker-options.js';
24
+ import { streamIdentity } from './stream-identity.js';
25
+
26
+ const streamKeys = new Set([
27
+ 'sanitizer',
28
+ 'sanitizerOptions',
29
+ 'maxRecordBytes',
30
+ 'batchBytes',
31
+ 'maxTotalBytes',
32
+ 'worker',
33
+ 'workers',
34
+ 'workerHeapMb',
35
+ 'workerTimeoutMs',
36
+ 'replace',
37
+ 'maxRegistryValues',
38
+ 'twoPass',
39
+ ]);
40
+ function configuration(options, collecting = false, complete) {
41
+ validatePass(options);
42
+ const suppliedOptions = sanitizerConfiguration(options.sanitizer);
43
+ const jsonl =
44
+ (options.sanitizerOptions?.structured ??
45
+ suppliedOptions?.structured ??
46
+ options.structured) === 'jsonl';
47
+ const batchBytes = options.batchBytes ?? 256 * 1024;
48
+ const maxTotalBytes = options.maxTotalBytes ?? 1024 * 1024 * 1024;
49
+ const maxRecordBytes =
50
+ options.maxRecordBytes ?? (jsonl ? 8 * 1024 * 1024 : maxTotalBytes);
51
+ for (const limit of [maxRecordBytes, batchBytes, maxTotalBytes]) {
52
+ if (!Number.isSafeInteger(limit) || limit <= 0) {
53
+ throw failure('ERR_CONFIG');
54
+ }
55
+ }
56
+ const identity = streamIdentity(
57
+ identityConfiguration(options, suppliedOptions, maxRecordBytes, batchBytes),
58
+ options.maxRegistryValues,
59
+ collecting,
60
+ complete
61
+ );
62
+ const sanitizerOptions = identity.options;
63
+ const engine = createSanitizer(sanitizerOptions);
64
+ return {
65
+ maxRecordBytes,
66
+ batchBytes,
67
+ maxTotalBytes,
68
+ sanitizerOptions,
69
+ jsonl,
70
+ identity,
71
+ collecting,
72
+ engine:
73
+ !options.sanitizer || suppliedOptions
74
+ ? engine
75
+ : {
76
+ async sanitize(text) {
77
+ const result = await options.sanitizer.sanitize(text);
78
+ return engine.sanitize(result.text);
79
+ },
80
+ },
81
+ };
82
+ }
83
+ function validatePass(options) {
84
+ if (options.twoPass !== undefined && typeof options.twoPass !== 'boolean') {
85
+ throw failure('ERR_CONFIG');
86
+ }
87
+ }
88
+ function identityConfiguration(
89
+ options,
90
+ suppliedOptions,
91
+ maxRecordBytes,
92
+ batchBytes
93
+ ) {
94
+ return {
95
+ ...suppliedOptions,
96
+ ...(options.sanitizerOptions ??
97
+ Object.fromEntries(
98
+ Object.entries(options).filter(([k]) => !streamKeys.has(k))
99
+ )),
100
+ maxInputLength: Math.max(maxRecordBytes, batchBytes) + 1,
101
+ };
102
+ }
103
+ // Hold an incomplete record, multiline quotes, PEM blocks and wrapped base64.
104
+ // Held records have a finite byte limit; exceeding it blocks publication.
105
+ function advanceQuotes(line, state) {
106
+ for (let i = 0; i < line.length; i++) {
107
+ const char = line[i];
108
+ if (state.escaped) {
109
+ state.escaped = false;
110
+ continue;
111
+ }
112
+ if (char === '\\' && state.quote) {
113
+ state.escaped = true;
114
+ continue;
115
+ }
116
+ if (state.quote && char === state.quote) {
117
+ state.quote = '';
118
+ } else if (!state.quote && opensQuote(char, line[i - 1])) {
119
+ state.quote = char;
120
+ }
121
+ }
122
+ }
123
+ function opensQuote(char, previous) {
124
+ return (
125
+ char === '"' || (char === "'" && !/[\p{L}\p{N}]/u.test(previous ?? ''))
126
+ );
127
+ }
128
+ function advanceCredentials(line, text, offset, state) {
129
+ if (/-----BEGIN [A-Z0-9 ]*PRIVATE KEY-----/.test(line)) {
130
+ state.pem = true;
131
+ }
132
+ if (/-----END [A-Z0-9 ]*PRIVATE KEY-----/.test(line)) {
133
+ state.pem = false;
134
+ }
135
+ if (/^PuTTY-User-Key-File-[23]:/.test(line)) {
136
+ state.putty = true;
137
+ }
138
+ if (/^Private-MAC:[ \t]*[a-fA-F0-9]+/.test(line)) {
139
+ state.putty = false;
140
+ }
141
+ if (
142
+ state.netrcStart < 0 &&
143
+ /^\s*(?:machine\s+\S+|default)(?:\s|$)/.test(line)
144
+ ) {
145
+ state.netrcStart = offset;
146
+ }
147
+ if (
148
+ state.netrcStart >= 0 &&
149
+ /(?:machine\s+\S+|default)\s+login\s+\S+\s+password\s+\S+/.test(
150
+ text.slice(state.netrcStart, offset + line.length)
151
+ )
152
+ ) {
153
+ state.netrcStart = -1;
154
+ }
155
+ }
156
+ function releaseBoundary(text, final) {
157
+ let offset = 0,
158
+ boundary = 0,
159
+ base64Start = -1;
160
+ const state = {
161
+ quote: '',
162
+ escaped: false,
163
+ pem: false,
164
+ putty: false,
165
+ netrcStart: -1,
166
+ };
167
+ for (const match of text.matchAll(/[^\n]*\n|[^\n]+$/g)) {
168
+ const line = match[0];
169
+ const base64 = (
170
+ base64Start >= 0
171
+ ? /^[A-Za-z0-9+/_-]{1,128}={0,2}\r?\n?$/
172
+ : /^[A-Za-z0-9+/_-]{16,128}={0,2}\r?\n?$/
173
+ ).test(line);
174
+ if (base64 && base64Start < 0) {
175
+ base64Start = offset;
176
+ }
177
+ if (!base64) {
178
+ base64Start = -1;
179
+ }
180
+ advanceCredentials(line, text, offset, state);
181
+ advanceQuotes(line, state);
182
+ offset += line.length;
183
+ if (state.quote || state.pem || state.putty || state.netrcStart >= 0) {
184
+ continue;
185
+ }
186
+ if (base64Start >= 0) {
187
+ boundary = Math.min(boundary, base64Start);
188
+ } else if (line.endsWith('\n') || final) {
189
+ boundary = offset;
190
+ }
191
+ }
192
+ return final ? text.length : boundary;
193
+ }
194
+ export async function* sanitizeStream(source, options = {}) {
195
+ if (options.twoPass) {
196
+ throw failure('ERR_CONFIG');
197
+ }
198
+ try {
199
+ yield* streamWithConfiguration(source, options, configuration(options));
200
+ } catch (error) {
201
+ throw internalFailure(error);
202
+ }
203
+ }
204
+ async function* streamWithConfiguration(source, options, config) {
205
+ if (config.jsonl) {
206
+ yield* sanitizeJsonlStream(source, options, config);
207
+ return;
208
+ }
209
+ const { engine, maxRecordBytes, batchBytes, maxTotalBytes } = config;
210
+ const decoder = new LogDecoder();
211
+ let pending = '',
212
+ total = 0,
213
+ pendingBytes = 0,
214
+ nextScan = Math.min(batchBytes, maxRecordBytes / 2);
215
+ async function* process(final) {
216
+ if (pendingBytes > maxRecordBytes) {
217
+ throw failure('ERR_LIMIT');
218
+ }
219
+ // Until a batch can be released, rescanning every partial chunk only
220
+ // repeats work. The byte bound above still applies on every chunk.
221
+ if (!final && pending.length < nextScan) {
222
+ return;
223
+ }
224
+ const boundary = releaseBoundary(pending, final);
225
+ if (
226
+ boundary &&
227
+ (final || boundary >= batchBytes || pending.length >= maxRecordBytes / 2)
228
+ ) {
229
+ const text = pending.slice(0, boundary);
230
+ pending = pending.slice(boundary);
231
+ pendingBytes -= encodeLogText(text).length;
232
+ config.identity.prepare(text, await prepareStreamEngine(engine, text));
233
+ if (!config.collecting) {
234
+ yield (await engine.sanitize(text)).text;
235
+ }
236
+ }
237
+ // An incomplete logical record is rescanned at geometrically increasing
238
+ // sizes, so arbitrary chunking cannot turn one long line quadratic.
239
+ nextScan = Math.max(
240
+ Math.min(batchBytes, maxRecordBytes / 2),
241
+ pending.length * 2
242
+ );
243
+ }
244
+ for await (const chunk of source) {
245
+ const bytes = Buffer.from(chunk);
246
+ total += bytes.length;
247
+ if (total > maxTotalBytes || bytes.includes(0)) {
248
+ throw failure(bytes.includes(0) ? 'ERR_BINARY' : 'ERR_LIMIT');
249
+ }
250
+ for (
251
+ let i = 0;
252
+ i < bytes.length;
253
+ i += Math.min(65536, Math.max(1, maxRecordBytes / 4))
254
+ ) {
255
+ try {
256
+ const decoded = decoder.decode(
257
+ bytes.subarray(
258
+ i,
259
+ i + Math.min(65536, Math.max(1, maxRecordBytes / 4))
260
+ ),
261
+ { stream: true }
262
+ );
263
+ pending += decoded;
264
+ pendingBytes += encodeLogText(decoded).length;
265
+ } catch {
266
+ throw failure('ERR_ENCODING');
267
+ }
268
+ yield* process(false);
269
+ }
270
+ }
271
+ try {
272
+ const decoded = decoder.decode();
273
+ pending += decoded;
274
+ pendingBytes += encodeLogText(decoded).length;
275
+ } catch {
276
+ throw failure('ERR_ENCODING');
277
+ }
278
+ yield* process(true);
279
+ }
280
+ export async function sanitizeStreamToFile(source, path, options = {}) {
281
+ const target = resolve(path),
282
+ temporary = join(dirname(target), `.sanitizer-${randomUUID()}.tmp`);
283
+ let handle,
284
+ outputBytes = 0;
285
+ try {
286
+ await validateTarget(target, options.replace);
287
+ handle = await open(temporary, 'wx', 0o600);
288
+ const stream = options.twoPass
289
+ ? twoPassStream(source, temporary, options)
290
+ : sanitizeStream(source, options);
291
+ for await (const chunk of stream) {
292
+ const bytes = encodeLogText(chunk);
293
+ outputBytes += bytes.length;
294
+ await handle.writeFile(bytes);
295
+ }
296
+ await handle.sync();
297
+ await handle.close();
298
+ handle = undefined;
299
+ if (options.replace) {
300
+ await rename(temporary, target);
301
+ } else {
302
+ await link(temporary, target);
303
+ await unlink(temporary);
304
+ }
305
+ return { outputBytes };
306
+ } catch (error) {
307
+ throw /^ERR_[A-Z_]+$/.test(error?.code) ? error : failure('ERR_OUTPUT');
308
+ } finally {
309
+ await handle?.close();
310
+ await unlink(temporary).catch(() => {});
311
+ }
312
+ }
313
+ async function* twoPassStream(source, temporary, options) {
314
+ const spool = `${temporary}.input`;
315
+ let input;
316
+ try {
317
+ const discovery = configuration(options, true);
318
+ input = await open(spool, 'wx', 0o600);
319
+ async function* capture() {
320
+ let total = 0;
321
+ for await (const chunk of source) {
322
+ const bytes = Buffer.from(chunk);
323
+ total += bytes.length;
324
+ if (total > discovery.maxTotalBytes) {
325
+ throw failure('ERR_LIMIT');
326
+ }
327
+ await input.writeFile(bytes);
328
+ yield bytes;
329
+ }
330
+ }
331
+ for await (const chunk of streamWithConfiguration(
332
+ capture(),
333
+ options,
334
+ discovery
335
+ )) {
336
+ void chunk;
337
+ }
338
+ await input.close();
339
+ input = undefined;
340
+ const complete = {
341
+ ...options,
342
+ twoPass: false,
343
+ sanitizerOptions: discovery.sanitizerOptions,
344
+ };
345
+ yield* streamWithConfiguration(
346
+ createReadStream(spool),
347
+ complete,
348
+ configuration(complete, false, true)
349
+ );
350
+ } catch (error) {
351
+ throw internalFailure(error);
352
+ } finally {
353
+ await input?.close();
354
+ await unlink(spool).catch(() => {});
355
+ }
356
+ }
357
+ async function validateTarget(target, replace) {
358
+ try {
359
+ const info = await lstat(target);
360
+ if (!replace || !info.isFile() || info.isSymbolicLink()) {
361
+ throw failure('ERR_OUTPUT');
362
+ }
363
+ } catch (error) {
364
+ if (error.code !== 'ENOENT') {
365
+ throw error;
366
+ }
367
+ }
368
+ }
369
+ export async function sanitizeFileToFile(source, target, options = {}) {
370
+ let handle;
371
+ try {
372
+ handle = await open(
373
+ source,
374
+ constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0)
375
+ );
376
+ const info = await handle.stat();
377
+ if (!info.isFile()) {
378
+ throw failure('ERR_FILE');
379
+ }
380
+ const result = await sanitizeStreamToFile(
381
+ handle.createReadStream({ highWaterMark: 65536, autoClose: false }),
382
+ target,
383
+ options
384
+ );
385
+ return { ...result, inputBytes: info.size };
386
+ } catch (error) {
387
+ throw /^ERR_[A-Z_]+$/.test(error?.code) ? error : failure('ERR_FILE');
388
+ } finally {
389
+ await handle?.close();
390
+ }
391
+ }
392
+ export async function sanitizeFileBounded(source, target, options = {}) {
393
+ if (options.worker === false || options.sanitizer) {
394
+ return sanitizeFileToFile(source, target, options);
395
+ }
396
+ const workerHeapMb = options.workerHeapMb ?? 256,
397
+ workerTimeoutMs = options.workerTimeoutMs ?? 60000;
398
+ if (
399
+ !Number.isSafeInteger(workerHeapMb) ||
400
+ workerHeapMb < 32 ||
401
+ !Number.isSafeInteger(workerTimeoutMs) ||
402
+ workerTimeoutMs <= 0
403
+ ) {
404
+ throw failure('ERR_CONFIG');
405
+ }
406
+ const staging = await mkdtemp(
407
+ join(dirname(resolve(target)), '.sanitizer-worker-')
408
+ );
409
+ let worker;
410
+ try {
411
+ await validateTarget(resolve(target), options.replace);
412
+ worker = new Worker(new URL('./stream-worker.js', import.meta.url), {
413
+ workerData: {
414
+ source,
415
+ target: join(staging, 'output'),
416
+ options: { ...options, replace: false },
417
+ },
418
+ resourceLimits: { maxOldGenerationSizeMb: workerHeapMb, stackSizeMb: 4 },
419
+ execArgv: workerExecArgv(),
420
+ });
421
+ const result = await workerResult(worker, workerTimeoutMs);
422
+ if (options.replace) {
423
+ await rename(join(staging, 'output'), target);
424
+ } else {
425
+ await link(join(staging, 'output'), target);
426
+ }
427
+ return result;
428
+ } finally {
429
+ await worker?.terminate();
430
+ await rm(staging, { recursive: true, force: true });
431
+ }
432
+ }
433
+ function workerResult(worker, timeoutMs) {
434
+ return new Promise((accept, reject) => {
435
+ const timer = setTimeout(() => reject(failure('ERR_WORKER')), timeoutMs);
436
+ const fail = () => {
437
+ clearTimeout(timer);
438
+ reject(failure('ERR_WORKER'));
439
+ };
440
+ worker.once('message', (result) => {
441
+ clearTimeout(timer);
442
+ result.error
443
+ ? reject(failure(result.error, result))
444
+ : accept(result.value);
445
+ });
446
+ worker.once('error', fail);
447
+ worker.once('exit', fail);
448
+ });
449
+ }