@link-foundation/sensitive-data-sanitizer 0.12.0 → 0.12.1
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 +6 -0
- package/README.md +4 -2
- package/bin/sensitive-data-sanitizer.js +33 -2
- package/bin/stream.js +2 -0
- package/docs/API.md +8 -7
- package/package.json +1 -1
- package/src/context.js +27 -2
- package/src/credentials.js +1 -1
- package/src/detection.js +20 -10
- package/src/encoded.js +7 -2
- package/src/engines.js +3 -1
- package/src/fake-names.js +21 -12
- package/src/file-worker-result.js +34 -0
- package/src/files.js +8 -2
- package/src/index.d.ts +11 -1
- package/src/json-string-runs.js +42 -0
- package/src/jsonl-stream.js +18 -10
- package/src/known.js +8 -1
- package/src/log-bytes.js +3 -0
- package/src/sanitizer.js +3 -0
- package/src/stream-worker.js +2 -1
- package/src/stream.js +45 -29
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,11 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.12.1
|
|
4
|
+
|
|
5
|
+
### Patch Changes
|
|
6
|
+
|
|
7
|
+
- e54098b: Bound Authorization values at the scheme and credential token, preserve authentication environment configuration, and support NUL log bytes with explicit opt-in. Reset file-worker deadlines on verified progress, expose the worker timeout in the CLI, and speed up ordinary and escaped-JSON logs without removing detection rules.
|
|
8
|
+
|
|
3
9
|
## 0.12.0
|
|
4
10
|
|
|
5
11
|
### Minor Changes
|
package/README.md
CHANGED
|
@@ -65,7 +65,7 @@ sensitive-data-sanitizer history rewrite ./repository --output ./private-preview
|
|
|
65
65
|
sensitive-data-sanitizer --help
|
|
66
66
|
```
|
|
67
67
|
|
|
68
|
-
`--output` creates a new file without clobbering an existing one. `--in-place` replaces the input atomically after verification. Intermediate and output files use mode `0600` on POSIX. Symlinks
|
|
68
|
+
`--output` creates a new file without clobbering an existing one. `--in-place` replaces the input atomically after verification. Intermediate and output files use mode `0600` on POSIX. Symlinks are refused. NUL-containing input is refused by default; `--allow-binary` explicitly preserves NUL bytes in plain logs while credentials are still sanitized. JSON/JSONL remain strict. Plain logs preserve invalid UTF-8 bytes exactly; JSON/JSONL and configuration files require valid UTF-8. Directory scans omit `.git` and `node_modules`; skipped binary/malformed-structured/oversized files make the report incomplete. Stdout is written after the complete operation succeeds.
|
|
69
69
|
|
|
70
70
|
| Exit | Meaning |
|
|
71
71
|
| ---- | ------------------------------------------------------------------------ |
|
|
@@ -73,7 +73,7 @@ sensitive-data-sanitizer --help
|
|
|
73
73
|
| 1 | A complete scan/history audit found sensitive data |
|
|
74
74
|
| 2 | Error, detector failure, or incomplete scan; redaction output is blocked |
|
|
75
75
|
|
|
76
|
-
Defaults: 32 MiB whole-text input bytes, 100,000 findings and 10,000 files. `--stream` permits 1 GiB total with plain-text records up to the total input limit or 8 MiB JSONL records, bounded workers and atomic publication. `--max-bytes` sets the byte limit; JSON config sets the other sanitizer limits. `--paranoid` adds entropy heuristics and increases false positives. `--native-only` explicitly disables Secretlint.
|
|
76
|
+
Defaults: 32 MiB whole-text input bytes, 100,000 findings and 10,000 files. `--stream` permits 1 GiB total with plain-text records up to the total input limit or 8 MiB JSONL records, bounded workers and atomic publication. `--worker-timeout-ms N` sets the worker inactivity/per-batch deadline (default 60000 ms); verified file progress resets the deadline, so large files can run longer overall. `--max-bytes` sets the byte limit; JSON config sets the other sanitizer limits. `--paranoid` adds entropy heuristics and increases false positives. `--native-only` explicitly disables Secretlint.
|
|
77
77
|
|
|
78
78
|
## Configuration
|
|
79
79
|
|
|
@@ -166,6 +166,8 @@ See [contributing](docs/CONTRIBUTING.md). `add`, `multiply` and `delay` live in
|
|
|
166
166
|
|
|
167
167
|
The universal app's preview images regenerate on pushes to the default branch. Run `npm run example:web:preview-images` to regenerate them locally; see [the template guide](docs/TEMPLATE-PIPELINE.md) for setup.
|
|
168
168
|
|
|
169
|
+
Automatic `--env-secrets` discovery skips authentication configuration names such as `GIT_AUTH_TRANSPORT`, `AUTH_MODE` and `AUTH_METHOD`. Explicit `knownSecretsFromEnv(env, names)` selections still include their exact values.
|
|
170
|
+
|
|
169
171
|
For shared tool logs, `--credentials-only --env-secrets --gh-config` preserves
|
|
170
172
|
public account details while redacting credential patterns and exact local
|
|
171
173
|
secret values. Environment discovery matches credential-related variable names,
|
|
@@ -50,6 +50,8 @@ history rewrite SOURCE --output DIRECTORY [--apply]
|
|
|
50
50
|
--model NAME Installed model passed to the selected bridge
|
|
51
51
|
--language CODE Model language passed to the selected bridge
|
|
52
52
|
--stream Process text records; files use a bounded worker
|
|
53
|
+
--allow-binary Treat NUL as a log byte (plain text only)
|
|
54
|
+
--worker-timeout-ms N Stream worker inactivity/per-batch limit (default 60000)
|
|
53
55
|
--two-pass Collect identities first, then publish the complete stream
|
|
54
56
|
--json Sanitize JSON strings and keys; preserve session metadata
|
|
55
57
|
--jsonl Sanitize JSONL records; combine with --stream for sessions
|
|
@@ -84,6 +86,7 @@ function parseArgs(argv) {
|
|
|
84
86
|
['--gitleaks', 'gitleaks'],
|
|
85
87
|
['--trufflehog', 'trufflehog'],
|
|
86
88
|
['--stream', 'stream'],
|
|
89
|
+
['--allow-binary', 'allowBinary'],
|
|
87
90
|
['--two-pass', 'twoPass'],
|
|
88
91
|
['--json', 'json'],
|
|
89
92
|
['--jsonl', 'jsonl'],
|
|
@@ -103,6 +106,7 @@ function parseArgs(argv) {
|
|
|
103
106
|
['--model', 'model'],
|
|
104
107
|
['--language', 'language'],
|
|
105
108
|
['--max-record-bytes', 'maxRecordBytes'],
|
|
109
|
+
['--worker-timeout-ms', 'workerTimeoutMs'],
|
|
106
110
|
['--filter-repo', 'filterRepo'],
|
|
107
111
|
['--profile', 'profile'],
|
|
108
112
|
]);
|
|
@@ -169,6 +173,7 @@ function validateFeatureArgs(config) {
|
|
|
169
173
|
}
|
|
170
174
|
|
|
171
175
|
function validateStructuredArgs(config) {
|
|
176
|
+
validateStreamArgs(config);
|
|
172
177
|
if (config.twoPass && !config.stream) {
|
|
173
178
|
throw failure('ERR_ARGUMENT');
|
|
174
179
|
}
|
|
@@ -177,6 +182,26 @@ function validateStructuredArgs(config) {
|
|
|
177
182
|
}
|
|
178
183
|
}
|
|
179
184
|
|
|
185
|
+
function validateStreamArgs(config) {
|
|
186
|
+
if (config.workerTimeoutMs !== undefined) {
|
|
187
|
+
config.workerTimeoutMs = Number(config.workerTimeoutMs);
|
|
188
|
+
if (
|
|
189
|
+
!config.stream ||
|
|
190
|
+
!Number.isSafeInteger(config.workerTimeoutMs) ||
|
|
191
|
+
config.workerTimeoutMs <= 0 ||
|
|
192
|
+
config.workerTimeoutMs > 2147483647
|
|
193
|
+
) {
|
|
194
|
+
throw failure('ERR_ARGUMENT');
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
if (
|
|
198
|
+
config.allowBinary &&
|
|
199
|
+
(config.json || config.jsonl || config.command === 'history')
|
|
200
|
+
) {
|
|
201
|
+
throw failure('ERR_ARGUMENT');
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
|
|
180
205
|
function validateBridge(config) {
|
|
181
206
|
if (!config.presidio && (config.python || config.model || config.language)) {
|
|
182
207
|
throw failure('ERR_ARGUMENT');
|
|
@@ -228,7 +253,6 @@ export async function runCli(
|
|
|
228
253
|
}
|
|
229
254
|
const config = parseArgs(argv);
|
|
230
255
|
const options = await loadOptions(config);
|
|
231
|
-
config.lossless = !options.structured;
|
|
232
256
|
const engine = createSanitizer(options);
|
|
233
257
|
const context = { stdin, stdout };
|
|
234
258
|
if (config.command === 'history') {
|
|
@@ -281,6 +305,10 @@ async function loadOptions(config) {
|
|
|
281
305
|
options.maxInputLength ?? config.maxBytes,
|
|
282
306
|
config.maxBytes
|
|
283
307
|
);
|
|
308
|
+
config.lossless = !options.structured;
|
|
309
|
+
if (config.allowBinary && !config.lossless) {
|
|
310
|
+
throw failure('ERR_CONFIG');
|
|
311
|
+
}
|
|
284
312
|
return options;
|
|
285
313
|
}
|
|
286
314
|
|
|
@@ -350,7 +378,10 @@ function selectedDetectors(config, detectors = []) {
|
|
|
350
378
|
}
|
|
351
379
|
|
|
352
380
|
function inputText(path, config, stdin) {
|
|
353
|
-
const options = {
|
|
381
|
+
const options = {
|
|
382
|
+
lossless: config.lossless,
|
|
383
|
+
allowBinary: Boolean(config.allowBinary),
|
|
384
|
+
};
|
|
354
385
|
return path === '-'
|
|
355
386
|
? stdinText(stdin, config.maxBytes, options)
|
|
356
387
|
: readTextFile(path, config.maxBytes, options);
|
package/bin/stream.js
CHANGED
|
@@ -24,6 +24,8 @@ export async function streamCommand(
|
|
|
24
24
|
maxTotalBytes: config.maxBytes,
|
|
25
25
|
replace: Boolean(config.inPlace),
|
|
26
26
|
twoPass: Boolean(config.twoPass),
|
|
27
|
+
allowBinary: Boolean(config.allowBinary),
|
|
28
|
+
workerTimeoutMs: config.workerTimeoutMs,
|
|
27
29
|
};
|
|
28
30
|
if (config.path === '-') {
|
|
29
31
|
await sanitizeStreamToFile(stdin, target, {
|
package/docs/API.md
CHANGED
|
@@ -15,7 +15,7 @@ Import the ESM root entry; complete declarations are in `src/index.d.ts`. Inputs
|
|
|
15
15
|
| `redact(text, findings, options)` | Apply validated ranges without automatic detection/verification |
|
|
16
16
|
| `auditGitHistory(repository, options)` | Read-only audit of local reachable text objects |
|
|
17
17
|
|
|
18
|
-
`redactions` counts merged replacement spans. `findings` can contain several detector hits for one replacement. Findings have `{start, end, type, category, rule, line, column}`; no text, quote, match, entropy score, provider details, or raw diagnostic is copied. Debugging is off by default; `debug(event)` receives
|
|
18
|
+
`redactions` counts merged replacement spans. `findings` can contain several detector hits for one replacement. Findings have `{start, end, type, category, rule, line, column}`; no text, quote, match, entropy score, provider details, or raw diagnostic is copied. Debugging is off by default; `debug(event)` receives value-free counts, rule timings, residual rounds and registry event metadata (see `DebugEvent` in the TypeScript declarations).
|
|
19
19
|
|
|
20
20
|
## Options
|
|
21
21
|
|
|
@@ -34,14 +34,14 @@ Import the ESM root entry; complete declarations are in `src/index.d.ts`. Inputs
|
|
|
34
34
|
| `maxFindings` | 100,000 detector hits before deduplication |
|
|
35
35
|
| `minConfidence` | 0.5; personal findings below this score remain visible to inspect but are not replaced |
|
|
36
36
|
| `threshold` | Alias for minConfidence; both options must agree when supplied |
|
|
37
|
-
| `debug` | Optional trusted callback for counts
|
|
37
|
+
| `debug` | Optional trusted callback for value-free counts, timing and event metadata |
|
|
38
38
|
| `identityMask` | `false`; identity types keep 2+2 Unicode characters with fixed `***`, or redact below five |
|
|
39
39
|
| `fakeIdentity` | Off; `'specimen-and-synthetic'` retains explicitly fake identity values |
|
|
40
40
|
| `fakeValues` | `[]`; exact caller-reviewed identity values to retain, regardless of fakeIdentity |
|
|
41
41
|
| `structured` | Off; `'json'` or `'jsonl'` validates and sanitizes string keys/values separately |
|
|
42
42
|
| `structuralFields` | `[]`; declared property names suppress weak name/ID/entropy heuristics in their string values |
|
|
43
43
|
|
|
44
|
-
Unknown options and invalid limits are errors. `knownSecretsFromEnv(env, names?)` discovers credential-named variables when names are omitted, or uses an explicit selection, deduplicating nonempty values. It never invokes shell commands or reads auth files. `personalVariants(entries)` generates numeric and ISO/day-first date spellings; name case variants are already handled by detection. Neither helper invents transliterations.
|
|
44
|
+
Unknown options and invalid limits are errors. `knownSecretsFromEnv(env, names?)` discovers credential-named variables (excluding auth configuration names) when names are omitted, or uses an explicit selection, deduplicating nonempty values. It never invokes shell commands or reads auth files. `personalVariants(entries)` generates numeric and ISO/day-first date spellings; name case variants are already handled by detection. Neither helper invents transliterations.
|
|
45
45
|
|
|
46
46
|
## Required detectors
|
|
47
47
|
|
|
@@ -188,7 +188,7 @@ await sanitizeStreamToFile(
|
|
|
188
188
|
);
|
|
189
189
|
```
|
|
190
190
|
|
|
191
|
-
`sanitizeFileToFile` rejects source symlinks and writes private temporary output, publishing only after complete verification. `sanitizeFileBounded` additionally uses a worker with a 60-second deadline and requests a 256 MiB old-generation heap and 4 MiB stack on Node (Bun ignores those resourceLimits); `workerHeapMb` and `workerTimeoutMs` are configurable. Worker failure removes its private staging directory before returning. Limits bound package work, not arbitrary external detectors. Callbacks supplied through `sanitizer` run in process; set `worker: false` explicitly to disable isolation. Pass serializable options through `sanitizerOptions` for workers. Memory limits do not include all native/WASM allocation.
|
|
191
|
+
`sanitizeFileToFile` rejects source symlinks and writes private temporary output, publishing only after complete verification. `sanitizeFileBounded` additionally uses a worker with a 60-second inactivity deadline that resets after each verified batch (including discovery in two-pass mode) and requests a 256 MiB old-generation heap and 4 MiB stack on Node (Bun ignores those resourceLimits); `workerHeapMb` and `workerTimeoutMs` are configurable. `workerTimeoutMs` accepts integers from 1 to 2147483647; the CLI exposes it as `--worker-timeout-ms N`. A progressing file may take longer than that overall. One large record must finish within that interval; increase it for unusually costly records. JSONL record workers retain a per-batch deadline. In-process stdin/plain streams have no worker deadline. Worker failure removes its private staging directory before returning. Limits bound package work, not arbitrary external detectors. Callbacks supplied through `sanitizer` run in process; set `worker: false` explicitly to disable isolation. Pass serializable options through `sanitizerOptions` for workers. Memory limits do not include all native/WASM allocation.
|
|
192
192
|
|
|
193
193
|
```js
|
|
194
194
|
import { sanitizeFileBounded } from '@link-foundation/sensitive-data-sanitizer';
|
|
@@ -298,7 +298,7 @@ when `names` is omitted, using
|
|
|
298
298
|
`/(?:^|_)(?:TOKEN|(?:API|ACCESS|PRIVATE)?KEY|SECRET|PASSWORD|PASSWD|CREDENTIALS?|AUTH|PAT)(?:_|$)/i`.
|
|
299
299
|
This covers `MY_SERVICE_TOKEN`, `CLAUDE_CODE_OAUTH_TOKEN`, `CODEX_API_KEY`,
|
|
300
300
|
`OPENCODE_API_KEY`, `DASHSCOPE_API_KEY`, `HF_TOKEN` and `TELEGRAM_BOT_TOKEN`.
|
|
301
|
-
An explicit array
|
|
301
|
+
Automatic discovery excludes names ending in `AUTH_TRANSPORT`, `AUTH_MODE`, `AUTH_METHOD`, `AUTH_TYPE`, `AUTH_URL`, `AUTH_URI`, `AUTH_HOST`, `AUTH_HOSTNAME`, `AUTH_PATH`, `AUTH_ENABLED`, `AUTH_DISABLED`, `AUTH_PORT`, `AUTH_PROTOCOL` or `AUTH_SCHEME` (including prefixed forms such as `HIVE_MIND_GIT_AUTH_TRANSPORT`). Credential names such as `AUTH_TOKEN` still contribute their exact values, including short/low-entropy values; no value-shape filter can reliably distinguish passwords from configuration. An explicit array bypasses the name exclusions; `[]` selects nothing.
|
|
302
302
|
These values are loaded only when the helper or CLI `--env-secrets` is requested.
|
|
303
303
|
|
|
304
304
|
`await knownSecretsFromGitHubConfig({path?})` reads `oauth_token` and `api_token`
|
|
@@ -325,8 +325,7 @@ U+DC80–U+DCFF surrogate; `encodeLogText(string)` restores those bytes. Use the
|
|
|
325
325
|
encoder when collecting `sanitizeStream` string output containing such bytes.
|
|
326
326
|
Ordinary UTF-8 string encoding would replace them and lose information.
|
|
327
327
|
`sanitizeStreamToFile` and `sanitizeFileBounded` perform the encoding themselves,
|
|
328
|
-
including workers and both passes. NUL bytes
|
|
329
|
-
block publication. JSON/JSONL, scanner protocol files and configuration retain
|
|
328
|
+
including workers and both passes. NUL bytes identify binary input and block publication by default. Plain streams accept `allowBinary: true`, and CLI file/stdin/scan/output/in-place paths accept `--allow-binary`, to preserve NUL as an ordinary byte while continuing credential detection. The opt-in is rejected for JSON/JSONL and history; it does not relax configuration/scanner validation. JSON/JSONL, scanner protocol files and configuration retain
|
|
330
329
|
strict UTF-8 validation; malformed bytes cannot represent valid JSON text.
|
|
331
330
|
|
|
332
331
|
Default detection keeps RFC 1918, loopback, link-local and IPv6 local addresses,
|
|
@@ -334,3 +333,5 @@ recognized auth-mode metadata, npm dependency version declarations, Actions
|
|
|
334
333
|
permission values and literal credential references. `publicKnowledge: false`
|
|
335
334
|
restores IP detection; explicit `knownSecrets` and `knownPersonal` retain
|
|
336
335
|
priority. No shell command or workflow expression is executed.
|
|
336
|
+
|
|
337
|
+
Authorization headers (including Proxy-Authorization) redact the scheme and one credential token. Whitespace, quotes, backslashes and commas end the value, preserving subsequent log text in shell and escaped JSON prompts. Known credential references remain exempt. The debug callback receives value-free `gitleaks-rule` events with rule ID, input length and elapsed milliseconds; tracing is off by default.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@link-foundation/sensitive-data-sanitizer",
|
|
3
|
-
"version": "0.12.
|
|
3
|
+
"version": "0.12.1",
|
|
4
4
|
"description": "Offline sensitive-text sanitization with credential, multilingual personal-data and scanner union detection",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "src/index.js",
|
package/src/context.js
CHANGED
|
@@ -79,6 +79,10 @@ function generatedWord(text, range) {
|
|
|
79
79
|
}
|
|
80
80
|
const ignored =
|
|
81
81
|
/^(?:true|false|null|undefined|await|async|function|none|nil)$/i;
|
|
82
|
+
const credentialHint = new RegExp(
|
|
83
|
+
[...labels.credential, 'pass', 'pw'].map(escapePattern).join('|'),
|
|
84
|
+
'iu'
|
|
85
|
+
);
|
|
82
86
|
|
|
83
87
|
function emitValue(text, start, end, emit, details, keyName = '') {
|
|
84
88
|
while (end > start && /[ \t\r]/.test(text[end - 1])) {
|
|
@@ -150,6 +154,23 @@ export function valueRange(text, start, mode = 'token', maxLength = Infinity) {
|
|
|
150
154
|
const end = stop < 0 ? start + tail.length : start + stop;
|
|
151
155
|
return { start, end: Math.min(end, outerQuoteEnd(text, start, end) ?? end) };
|
|
152
156
|
}
|
|
157
|
+
// Header credentials occupy a scheme and one token even inside shell/JSON
|
|
158
|
+
// strings. A closing quote or escape belongs to the surrounding log record.
|
|
159
|
+
export function authorizationRange(text, start) {
|
|
160
|
+
const opening = /^(\\*)(["'])/.exec(text.slice(start));
|
|
161
|
+
start += opening?.[0].length ?? 0;
|
|
162
|
+
const reference = referencePrefix(text.slice(start, start + 512));
|
|
163
|
+
if (reference) {
|
|
164
|
+
return { start, end: start + reference[0].length };
|
|
165
|
+
}
|
|
166
|
+
if (text.startsWith(REDACTED, start)) {
|
|
167
|
+
return undefined;
|
|
168
|
+
}
|
|
169
|
+
const match = /^(?:[a-z][a-z0-9_-]*[ \t]+)?[^\s"'\\,<>}\]]+/i.exec(
|
|
170
|
+
text.slice(start)
|
|
171
|
+
);
|
|
172
|
+
return match ? { start, end: start + match[0].length } : undefined;
|
|
173
|
+
}
|
|
153
174
|
function quotedRange(text, start, opening, maxLength) {
|
|
154
175
|
const depth = opening[1].length,
|
|
155
176
|
quote = opening[2];
|
|
@@ -301,7 +322,9 @@ function detectCredentials(text, emit) {
|
|
|
301
322
|
: /[::]/.test(match[2])
|
|
302
323
|
? 'line'
|
|
303
324
|
: 'prose';
|
|
304
|
-
let range =
|
|
325
|
+
let range = /^(?:proxy-)?authorization$/i.test(match[1])
|
|
326
|
+
? authorizationRange(text, start)
|
|
327
|
+
: valueRange(text, start, mode);
|
|
305
328
|
if (range && mode === 'prose' && genericLabel.test(match[1])) {
|
|
306
329
|
range = generatedWord(text, range);
|
|
307
330
|
}
|
|
@@ -416,7 +439,9 @@ function detectCommandPasswords(text, emit) {
|
|
|
416
439
|
}
|
|
417
440
|
|
|
418
441
|
export function detectContext(text, emit, credentialsOnly = false) {
|
|
419
|
-
|
|
442
|
+
if (credentialHint.test(text)) {
|
|
443
|
+
detectCredentials(text, emit);
|
|
444
|
+
}
|
|
420
445
|
if (!credentialsOnly) {
|
|
421
446
|
detectPersonalLabels(text, emit);
|
|
422
447
|
}
|
package/src/credentials.js
CHANGED
|
@@ -10,7 +10,7 @@ export function credentialPlaceholder(value) {
|
|
|
10
10
|
// Only variable references and the documented gh lookup are exempt. Arbitrary
|
|
11
11
|
// shell commands and Actions expressions can contain literal credentials.
|
|
12
12
|
export function referencePrefix(value) {
|
|
13
|
-
return /^(?:(?:token|Bearer|Basic)\s+)?(?:\$\{\{\s*(?:secrets|env|vars|github|inputs)\.[A-Za-z_][A-Za-z0-9_.]*\s*\}\}|\$\(gh\s+auth\s+token(?:\s+--hostname\s+[a-z0-9.-]+)?\))(?=$|[\s"'
|
|
13
|
+
return /^(?:(?:token|Bearer|Basic)\s+)?(?:\$\{\{\s*(?:secrets|env|vars|github|inputs)\.[A-Za-z_][A-Za-z0-9_.]*\s*\}\}|\$\(gh\s+auth\s+token(?:\s+--hostname\s+[a-z0-9.-]+)?\))(?=$|[\s"',;\\])/i.exec(
|
|
14
14
|
value
|
|
15
15
|
);
|
|
16
16
|
}
|
package/src/detection.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { cardNetwork, cardEvidence } from './confidence.js';
|
|
2
2
|
import { isIP } from 'node:net';
|
|
3
3
|
import { serviceRules } from './rules.js';
|
|
4
|
-
import { detectContext, valueRange } from './context.js';
|
|
4
|
+
import { detectContext, valueRange, authorizationRange } from './context.js';
|
|
5
5
|
import { detectPersonal } from './personal.js';
|
|
6
6
|
import { detectGitleaks } from './gitleaks.js';
|
|
7
7
|
import { detectCatalog } from './entities.js';
|
|
@@ -132,7 +132,13 @@ function detectCookies(text, emit) {
|
|
|
132
132
|
|
|
133
133
|
export function nativeDetect(text, options, emit) {
|
|
134
134
|
const credential = { type: 'SECRET', category: 'credential' };
|
|
135
|
-
detectGitleaks(
|
|
135
|
+
detectGitleaks(
|
|
136
|
+
text,
|
|
137
|
+
emit,
|
|
138
|
+
options.debug
|
|
139
|
+
? (details) => options.debug({ event: 'gitleaks-rule', ...details })
|
|
140
|
+
: undefined
|
|
141
|
+
);
|
|
136
142
|
for (const [rule, pattern] of serviceRules) {
|
|
137
143
|
collectMatches(text, pattern, emit, { ...credential, rule });
|
|
138
144
|
}
|
|
@@ -186,14 +192,7 @@ export function nativeDetect(text, options, emit) {
|
|
|
186
192
|
emit,
|
|
187
193
|
{ ...credential, rule: 'private-key' }
|
|
188
194
|
);
|
|
189
|
-
|
|
190
|
-
text,
|
|
191
|
-
/(?:Proxy-)?Authorization[ \t]*:[ \t]*(?:Bearer|Basic|Digest)[ \t]+([^\r\n"',]+)/gi,
|
|
192
|
-
emit,
|
|
193
|
-
{ ...credential, rule: 'authorization' },
|
|
194
|
-
1,
|
|
195
|
-
(value) => !credentialPlaceholder(value.trim())
|
|
196
|
-
);
|
|
195
|
+
detectAuthorization(text, emit, credential);
|
|
197
196
|
detectCookies(text, emit);
|
|
198
197
|
collectMatches(
|
|
199
198
|
text,
|
|
@@ -229,6 +228,17 @@ export function nativeDetect(text, options, emit) {
|
|
|
229
228
|
}
|
|
230
229
|
}
|
|
231
230
|
|
|
231
|
+
function detectAuthorization(text, emit, credential) {
|
|
232
|
+
for (const match of text.matchAll(
|
|
233
|
+
/(?:Proxy-)?Authorization[ \t]*:[ \t]*/gi
|
|
234
|
+
)) {
|
|
235
|
+
const range = authorizationRange(text, match.index + match[0].length);
|
|
236
|
+
if (range && !credentialPlaceholder(text.slice(range.start, range.end))) {
|
|
237
|
+
emit({ ...range, ...credential, rule: 'authorization' });
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
|
|
232
242
|
function detectPersonalFormats(text, options, emit) {
|
|
233
243
|
collectMatches(
|
|
234
244
|
text,
|
package/src/encoded.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { TextDecoder } from 'node:util';
|
|
2
2
|
import { Buffer } from 'node:buffer';
|
|
3
3
|
import { failure } from './detection.js';
|
|
4
|
+
import { jsonStringRuns } from './json-string-runs.js';
|
|
4
5
|
|
|
5
6
|
export function* decodedRuns(text, depth = 0) {
|
|
6
7
|
if (depth >= 2) {
|
|
@@ -12,7 +13,7 @@ export function* decodedRuns(text, depth = 0) {
|
|
|
12
13
|
/(?<![A-Za-z0-9_+/-])[A-Za-z0-9_+/-]{16,128}(?:\r?\n[A-Za-z0-9_+/-]{1,128})+={0,2}(?![A-Za-z0-9_+/-])/g,
|
|
13
14
|
/(?:&#(?:x[\da-fA-F]{1,6}|\d{1,7});|&(?:amp|lt|gt|quot|apos|nbsp);){2,2048}/g,
|
|
14
15
|
/(?:\\x[\da-fA-F]{2}){2,2048}/g,
|
|
15
|
-
|
|
16
|
+
undefined, // JSON candidates use a linear scanner below.
|
|
16
17
|
];
|
|
17
18
|
const seen = new Set();
|
|
18
19
|
let count = 0;
|
|
@@ -21,7 +22,7 @@ export function* decodedRuns(text, depth = 0) {
|
|
|
21
22
|
if (marker && !text.includes(marker)) {
|
|
22
23
|
continue;
|
|
23
24
|
}
|
|
24
|
-
for (const match of text
|
|
25
|
+
for (const match of candidateMatches(text, pattern)) {
|
|
25
26
|
const raw = match[0];
|
|
26
27
|
if (raw.length > 8192) {
|
|
27
28
|
if (isLimitedTextRun(raw)) {
|
|
@@ -62,6 +63,10 @@ export function* decodedRuns(text, depth = 0) {
|
|
|
62
63
|
}
|
|
63
64
|
}
|
|
64
65
|
|
|
66
|
+
function candidateMatches(text, pattern) {
|
|
67
|
+
return pattern ? text.matchAll(pattern) : jsonStringRuns(text);
|
|
68
|
+
}
|
|
69
|
+
|
|
65
70
|
function decodedText(raw, decoded) {
|
|
66
71
|
return (
|
|
67
72
|
decoded !== raw &&
|
package/src/engines.js
CHANGED
|
@@ -180,7 +180,9 @@ export function createSanitizer(options = {}) {
|
|
|
180
180
|
for (const finding of initial) {
|
|
181
181
|
emit(finding);
|
|
182
182
|
}
|
|
183
|
-
|
|
183
|
+
// Native inspection performs its own normalization. Additional views are
|
|
184
|
+
// needed here only when a required external engine is enabled.
|
|
185
|
+
const projection = engines.length ? projectText(text) : undefined;
|
|
184
186
|
for (const engine of engines) {
|
|
185
187
|
for (const finding of await detect(
|
|
186
188
|
engine,
|
package/src/fake-names.js
CHANGED
|
@@ -97,21 +97,29 @@ export function latinName(value) {
|
|
|
97
97
|
.replace(/[а-яё]/g, (c) => transliteration[c]);
|
|
98
98
|
}
|
|
99
99
|
const given = new Map();
|
|
100
|
-
for (const [locale, list] of Object.entries(firstNames)) {
|
|
101
|
-
for (const name of list.split(' ')) {
|
|
102
|
-
given.set(latinName(name), locale);
|
|
103
|
-
}
|
|
104
|
-
}
|
|
105
100
|
const surnames = new Map(),
|
|
106
101
|
female = new Set();
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
102
|
+
let indexed = false;
|
|
103
|
+
function indexNames() {
|
|
104
|
+
if (indexed) {
|
|
105
|
+
return;
|
|
110
106
|
}
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
107
|
+
indexed = true;
|
|
108
|
+
for (const [locale, list] of Object.entries(firstNames)) {
|
|
109
|
+
for (const name of list.split(' ')) {
|
|
110
|
+
given.set(latinName(name), locale);
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
for (const [locale, data] of Object.entries(locales)) {
|
|
114
|
+
for (const name of data.person?.first_name?.female ?? []) {
|
|
115
|
+
female.add(latinName(name));
|
|
116
|
+
}
|
|
117
|
+
for (const names of Object.values(data.person?.last_name ?? {})) {
|
|
118
|
+
for (const name of names) {
|
|
119
|
+
const canonical = latinName(name);
|
|
120
|
+
if (!surnames.has(canonical)) {
|
|
121
|
+
surnames.set(canonical, locale);
|
|
122
|
+
}
|
|
115
123
|
}
|
|
116
124
|
}
|
|
117
125
|
}
|
|
@@ -137,6 +145,7 @@ function scriptLocale(value) {
|
|
|
137
145
|
return undefined;
|
|
138
146
|
}
|
|
139
147
|
export function fakeName(value, key) {
|
|
148
|
+
indexNames();
|
|
140
149
|
return value.replace(/[\p{L}\p{M}]+/gu, (word) => {
|
|
141
150
|
for (const locale of ['ja', 'zh', 'ko']) {
|
|
142
151
|
const family = firstNames[locale]
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { failure } from './detection.js';
|
|
2
|
+
|
|
3
|
+
export function workerResult(worker, timeoutMs) {
|
|
4
|
+
return new Promise((accept, reject) => {
|
|
5
|
+
let timer,
|
|
6
|
+
finished = false;
|
|
7
|
+
const fail = () => {
|
|
8
|
+
finished = true;
|
|
9
|
+
clearTimeout(timer);
|
|
10
|
+
reject(failure('ERR_WORKER'));
|
|
11
|
+
};
|
|
12
|
+
const renew = () => {
|
|
13
|
+
clearTimeout(timer);
|
|
14
|
+
timer = setTimeout(fail, timeoutMs);
|
|
15
|
+
};
|
|
16
|
+
renew();
|
|
17
|
+
worker.on('message', (result) => {
|
|
18
|
+
if (finished) {
|
|
19
|
+
return;
|
|
20
|
+
}
|
|
21
|
+
if (result.progress) {
|
|
22
|
+
renew();
|
|
23
|
+
return;
|
|
24
|
+
}
|
|
25
|
+
finished = true;
|
|
26
|
+
clearTimeout(timer);
|
|
27
|
+
result.error
|
|
28
|
+
? reject(failure(result.error, result))
|
|
29
|
+
: accept(result.value);
|
|
30
|
+
});
|
|
31
|
+
worker.once('error', fail);
|
|
32
|
+
worker.once('exit', fail);
|
|
33
|
+
});
|
|
34
|
+
}
|
package/src/files.js
CHANGED
|
@@ -6,8 +6,14 @@ import { TextDecoder } from 'node:util';
|
|
|
6
6
|
import { failure } from './detection.js';
|
|
7
7
|
import { decodeLogBytes } from './log-bytes.js';
|
|
8
8
|
|
|
9
|
-
export function decodeText(
|
|
10
|
-
|
|
9
|
+
export function decodeText(
|
|
10
|
+
bytes,
|
|
11
|
+
{ lossless = false, allowBinary = false } = {}
|
|
12
|
+
) {
|
|
13
|
+
if (typeof allowBinary !== 'boolean') {
|
|
14
|
+
throw failure('ERR_CONFIG');
|
|
15
|
+
}
|
|
16
|
+
if (!allowBinary && bytes.includes(0)) {
|
|
11
17
|
throw failure('ERR_BINARY');
|
|
12
18
|
}
|
|
13
19
|
if (lossless) {
|
package/src/index.d.ts
CHANGED
|
@@ -77,12 +77,19 @@ export interface SanitizerOptions {
|
|
|
77
77
|
decode?: boolean;
|
|
78
78
|
maxInputLength?: number;
|
|
79
79
|
maxFindings?: number;
|
|
80
|
-
debug?: (event:
|
|
80
|
+
debug?: (event: DebugEvent) => void;
|
|
81
81
|
/** Used by createSanitizer; synchronous functions use native rules only. */
|
|
82
82
|
detectors?: Detector[];
|
|
83
83
|
/** Required by default in createSanitizer. */
|
|
84
84
|
secretlint?: boolean;
|
|
85
85
|
}
|
|
86
|
+
/** Value-free diagnostics; enabled only when a debug callback is supplied. */
|
|
87
|
+
export type DebugEvent =
|
|
88
|
+
| { event: 'detected'; count: number }
|
|
89
|
+
| { event: 'residual-redaction'; round: number }
|
|
90
|
+
| { event: 'two-pass-confirmation'; type: string }
|
|
91
|
+
| { event: 'propagation-skipped'; reason: 'length'; type: string }
|
|
92
|
+
| { event: 'gitleaks-rule'; rule: string; length: number; ms: number };
|
|
86
93
|
export interface SanitizedText {
|
|
87
94
|
text: string;
|
|
88
95
|
findings: Finding[];
|
|
@@ -233,6 +240,8 @@ export type Transformation =
|
|
|
233
240
|
| { mode: 'date-shift'; days: number }
|
|
234
241
|
| { mode: 'bucket'; size: number };
|
|
235
242
|
export interface StreamOptions extends SanitizerOptions {
|
|
243
|
+
/** Treat NUL as a log byte; default false. Incompatible with JSON/JSONL. */
|
|
244
|
+
allowBinary?: boolean;
|
|
236
245
|
/** Atomic file APIs only: spool input privately, collect, then redact. */
|
|
237
246
|
twoPass?: boolean;
|
|
238
247
|
maxRegistryValues?: number;
|
|
@@ -245,6 +254,7 @@ export interface StreamOptions extends SanitizerOptions {
|
|
|
245
254
|
/** JSONL record workers, 1–16; default 2, or 1 for callbacks/Deno. */
|
|
246
255
|
workers?: number;
|
|
247
256
|
workerHeapMb?: number;
|
|
257
|
+
/** 1–2147483647 ms; file inactivity / JSONL per-batch deadline; default 60000. */
|
|
248
258
|
workerTimeoutMs?: number;
|
|
249
259
|
replace?: boolean;
|
|
250
260
|
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
// Match the bounded JSON-string candidates used by encoded detection in one
|
|
2
|
+
// pass. Escaped quotes are also possible openings after an earlier candidate
|
|
3
|
+
// exceeds its bound, matching RegExp's recovery on malformed log fragments.
|
|
4
|
+
export function* jsonStringRuns(text) {
|
|
5
|
+
let candidates = [],
|
|
6
|
+
head = 0,
|
|
7
|
+
units = 0;
|
|
8
|
+
for (let i = 0; i < text.length; i++) {
|
|
9
|
+
const char = text[i];
|
|
10
|
+
if (char === '\r' || char === '\n') {
|
|
11
|
+
candidates = [];
|
|
12
|
+
head = units = 0;
|
|
13
|
+
continue;
|
|
14
|
+
}
|
|
15
|
+
if (char === '\\' && i + 1 < text.length && !/[\r\n]/.test(text[i + 1])) {
|
|
16
|
+
units++;
|
|
17
|
+
if (text[++i] === '"') {
|
|
18
|
+
candidates.push({ index: i, units });
|
|
19
|
+
}
|
|
20
|
+
} else if (char === '"') {
|
|
21
|
+
const opening = candidates[head];
|
|
22
|
+
if (opening && units > opening.units) {
|
|
23
|
+
yield { index: opening.index, 0: text.slice(opening.index, i + 1) };
|
|
24
|
+
candidates = [];
|
|
25
|
+
head = units = 0;
|
|
26
|
+
} else {
|
|
27
|
+
candidates = [{ index: i, units: ++units }];
|
|
28
|
+
head = 0;
|
|
29
|
+
}
|
|
30
|
+
} else {
|
|
31
|
+
units++;
|
|
32
|
+
}
|
|
33
|
+
// Retain only potentially valid openings, even on huge unfinished lines.
|
|
34
|
+
while (head < candidates.length && units - candidates[head].units > 8192) {
|
|
35
|
+
head++;
|
|
36
|
+
}
|
|
37
|
+
if (head > 8192) {
|
|
38
|
+
candidates = candidates.slice(head);
|
|
39
|
+
head = 0;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
}
|
package/src/jsonl-stream.js
CHANGED
|
@@ -141,6 +141,20 @@ function workerCount(options, config) {
|
|
|
141
141
|
// Deno's Node compatibility does not implement resource-limited workers.
|
|
142
142
|
return typeof Deno !== 'undefined' ? 1 : count;
|
|
143
143
|
}
|
|
144
|
+
function workerLimits(options) {
|
|
145
|
+
const heap = options.workerHeapMb ?? 256,
|
|
146
|
+
timeout = options.workerTimeoutMs ?? 60000;
|
|
147
|
+
if (
|
|
148
|
+
!Number.isSafeInteger(heap) ||
|
|
149
|
+
heap < 32 ||
|
|
150
|
+
!Number.isSafeInteger(timeout) ||
|
|
151
|
+
timeout <= 0 ||
|
|
152
|
+
timeout > 2147483647
|
|
153
|
+
) {
|
|
154
|
+
throw failure('ERR_CONFIG');
|
|
155
|
+
}
|
|
156
|
+
return { heap, timeout };
|
|
157
|
+
}
|
|
144
158
|
export async function* sanitizeJsonlStream(source, options, config) {
|
|
145
159
|
const count = config.collecting ? 1 : workerCount(options, config);
|
|
146
160
|
if (count === 1) {
|
|
@@ -149,22 +163,16 @@ export async function* sanitizeJsonlStream(source, options, config) {
|
|
|
149
163
|
batch,
|
|
150
164
|
await prepareStreamEngine(config.engine, batch)
|
|
151
165
|
);
|
|
166
|
+
if (config.collecting) {
|
|
167
|
+
config.onProgress?.();
|
|
168
|
+
}
|
|
152
169
|
if (!config.collecting) {
|
|
153
170
|
yield (await config.engine.sanitize(batch)).text;
|
|
154
171
|
}
|
|
155
172
|
}
|
|
156
173
|
return;
|
|
157
174
|
}
|
|
158
|
-
const heap = options
|
|
159
|
-
timeout = options.workerTimeoutMs ?? 60000;
|
|
160
|
-
if (
|
|
161
|
-
!Number.isSafeInteger(heap) ||
|
|
162
|
-
heap < 32 ||
|
|
163
|
-
!Number.isSafeInteger(timeout) ||
|
|
164
|
-
timeout <= 0
|
|
165
|
-
) {
|
|
166
|
-
throw failure('ERR_CONFIG');
|
|
167
|
-
}
|
|
175
|
+
const { heap, timeout } = workerLimits(options);
|
|
168
176
|
const slots = [],
|
|
169
177
|
queue = [];
|
|
170
178
|
let sequence = 0;
|
package/src/known.js
CHANGED
|
@@ -73,12 +73,19 @@ function configTokens(config) {
|
|
|
73
73
|
|
|
74
74
|
const secretEnvName =
|
|
75
75
|
/(?:^|_)(?:TOKEN|(?:API|ACCESS|PRIVATE)?KEY|SECRET|PASSWORD|PASSWD|CREDENTIALS?|AUTH|PAT)(?:_|$)/i;
|
|
76
|
+
// Configuration suffixes describe how authentication works. Explicit names
|
|
77
|
+
// bypass this filter; credential suffixes such as AUTH_MODE_TOKEN still win.
|
|
78
|
+
const authConfigurationName =
|
|
79
|
+
/(?:^|_)AUTH_(?:TRANSPORT|MODE|METHOD|TYPE|URL|URI|HOST|HOSTNAME|PATH|ENABLED|DISABLED|PORT|PROTOCOL|SCHEME)$/i;
|
|
76
80
|
/** Opt-in literal values from credential-named keys, or an explicit selection. */
|
|
77
81
|
export function knownSecretsFromEnv(env = process.env, names) {
|
|
78
82
|
if (names === undefined) {
|
|
79
83
|
names =
|
|
80
84
|
env && typeof env === 'object'
|
|
81
|
-
? Object.keys(env).filter(
|
|
85
|
+
? Object.keys(env).filter(
|
|
86
|
+
(name) =>
|
|
87
|
+
secretEnvName.test(name) && !authConfigurationName.test(name)
|
|
88
|
+
)
|
|
82
89
|
: [];
|
|
83
90
|
}
|
|
84
91
|
if (
|
package/src/log-bytes.js
CHANGED
package/src/sanitizer.js
CHANGED
package/src/stream-worker.js
CHANGED
package/src/stream.js
CHANGED
|
@@ -22,6 +22,7 @@ import { failure, internalFailure } from './detection.js';
|
|
|
22
22
|
import { sanitizeJsonlStream } from './jsonl-stream.js';
|
|
23
23
|
import { workerExecArgv } from './worker-options.js';
|
|
24
24
|
import { streamIdentity } from './stream-identity.js';
|
|
25
|
+
import { workerResult } from './file-worker-result.js';
|
|
25
26
|
|
|
26
27
|
const streamKeys = new Set([
|
|
27
28
|
'sanitizer',
|
|
@@ -36,14 +37,17 @@ const streamKeys = new Set([
|
|
|
36
37
|
'replace',
|
|
37
38
|
'maxRegistryValues',
|
|
38
39
|
'twoPass',
|
|
40
|
+
'allowBinary',
|
|
39
41
|
]);
|
|
40
42
|
function configuration(options, collecting = false, complete) {
|
|
41
43
|
validatePass(options);
|
|
42
44
|
const suppliedOptions = sanitizerConfiguration(options.sanitizer);
|
|
43
|
-
const
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
45
|
+
const structured =
|
|
46
|
+
options.sanitizerOptions?.structured ??
|
|
47
|
+
suppliedOptions?.structured ??
|
|
48
|
+
options.structured;
|
|
49
|
+
const jsonl = structured === 'jsonl';
|
|
50
|
+
validateBinary(options, structured);
|
|
47
51
|
const batchBytes = options.batchBytes ?? 256 * 1024;
|
|
48
52
|
const maxTotalBytes = options.maxTotalBytes ?? 1024 * 1024 * 1024;
|
|
49
53
|
const maxRecordBytes =
|
|
@@ -80,6 +84,15 @@ function configuration(options, collecting = false, complete) {
|
|
|
80
84
|
},
|
|
81
85
|
};
|
|
82
86
|
}
|
|
87
|
+
function validateBinary(options, structured) {
|
|
88
|
+
if (
|
|
89
|
+
(options.allowBinary !== undefined &&
|
|
90
|
+
typeof options.allowBinary !== 'boolean') ||
|
|
91
|
+
(options.allowBinary && structured)
|
|
92
|
+
) {
|
|
93
|
+
throw failure('ERR_CONFIG');
|
|
94
|
+
}
|
|
95
|
+
}
|
|
83
96
|
function validatePass(options) {
|
|
84
97
|
if (options.twoPass !== undefined && typeof options.twoPass !== 'boolean') {
|
|
85
98
|
throw failure('ERR_CONFIG');
|
|
@@ -230,6 +243,9 @@ async function* streamWithConfiguration(source, options, config) {
|
|
|
230
243
|
pending = pending.slice(boundary);
|
|
231
244
|
pendingBytes -= encodeLogText(text).length;
|
|
232
245
|
config.identity.prepare(text, await prepareStreamEngine(engine, text));
|
|
246
|
+
if (config.collecting) {
|
|
247
|
+
config.onProgress?.();
|
|
248
|
+
}
|
|
233
249
|
if (!config.collecting) {
|
|
234
250
|
yield (await engine.sanitize(text)).text;
|
|
235
251
|
}
|
|
@@ -244,8 +260,11 @@ async function* streamWithConfiguration(source, options, config) {
|
|
|
244
260
|
for await (const chunk of source) {
|
|
245
261
|
const bytes = Buffer.from(chunk);
|
|
246
262
|
total += bytes.length;
|
|
247
|
-
if (total > maxTotalBytes
|
|
248
|
-
throw failure(
|
|
263
|
+
if (total > maxTotalBytes) {
|
|
264
|
+
throw failure('ERR_LIMIT');
|
|
265
|
+
}
|
|
266
|
+
if (!options.allowBinary && bytes.includes(0)) {
|
|
267
|
+
throw failure('ERR_BINARY');
|
|
249
268
|
}
|
|
250
269
|
for (
|
|
251
270
|
let i = 0;
|
|
@@ -277,7 +296,12 @@ async function* streamWithConfiguration(source, options, config) {
|
|
|
277
296
|
}
|
|
278
297
|
yield* process(true);
|
|
279
298
|
}
|
|
280
|
-
export async function sanitizeStreamToFile(
|
|
299
|
+
export async function sanitizeStreamToFile(
|
|
300
|
+
source,
|
|
301
|
+
path,
|
|
302
|
+
options = {},
|
|
303
|
+
onProgress
|
|
304
|
+
) {
|
|
281
305
|
const target = resolve(path),
|
|
282
306
|
temporary = join(dirname(target), `.sanitizer-${randomUUID()}.tmp`);
|
|
283
307
|
let handle,
|
|
@@ -286,12 +310,13 @@ export async function sanitizeStreamToFile(source, path, options = {}) {
|
|
|
286
310
|
await validateTarget(target, options.replace);
|
|
287
311
|
handle = await open(temporary, 'wx', 0o600);
|
|
288
312
|
const stream = options.twoPass
|
|
289
|
-
? twoPassStream(source, temporary, options)
|
|
313
|
+
? twoPassStream(source, temporary, options, onProgress)
|
|
290
314
|
: sanitizeStream(source, options);
|
|
291
315
|
for await (const chunk of stream) {
|
|
292
316
|
const bytes = encodeLogText(chunk);
|
|
293
317
|
outputBytes += bytes.length;
|
|
294
318
|
await handle.writeFile(bytes);
|
|
319
|
+
onProgress?.();
|
|
295
320
|
}
|
|
296
321
|
await handle.sync();
|
|
297
322
|
await handle.close();
|
|
@@ -310,11 +335,12 @@ export async function sanitizeStreamToFile(source, path, options = {}) {
|
|
|
310
335
|
await unlink(temporary).catch(() => {});
|
|
311
336
|
}
|
|
312
337
|
}
|
|
313
|
-
async function* twoPassStream(source, temporary, options) {
|
|
338
|
+
async function* twoPassStream(source, temporary, options, onProgress) {
|
|
314
339
|
const spool = `${temporary}.input`;
|
|
315
340
|
let input;
|
|
316
341
|
try {
|
|
317
342
|
const discovery = configuration(options, true);
|
|
343
|
+
discovery.onProgress = onProgress;
|
|
318
344
|
input = await open(spool, 'wx', 0o600);
|
|
319
345
|
async function* capture() {
|
|
320
346
|
let total = 0;
|
|
@@ -366,7 +392,12 @@ async function validateTarget(target, replace) {
|
|
|
366
392
|
}
|
|
367
393
|
}
|
|
368
394
|
}
|
|
369
|
-
export async function sanitizeFileToFile(
|
|
395
|
+
export async function sanitizeFileToFile(
|
|
396
|
+
source,
|
|
397
|
+
target,
|
|
398
|
+
options = {},
|
|
399
|
+
onProgress
|
|
400
|
+
) {
|
|
370
401
|
let handle;
|
|
371
402
|
try {
|
|
372
403
|
handle = await open(
|
|
@@ -380,7 +411,8 @@ export async function sanitizeFileToFile(source, target, options = {}) {
|
|
|
380
411
|
const result = await sanitizeStreamToFile(
|
|
381
412
|
handle.createReadStream({ highWaterMark: 65536, autoClose: false }),
|
|
382
413
|
target,
|
|
383
|
-
options
|
|
414
|
+
options,
|
|
415
|
+
onProgress
|
|
384
416
|
);
|
|
385
417
|
return { ...result, inputBytes: info.size };
|
|
386
418
|
} catch (error) {
|
|
@@ -399,7 +431,8 @@ export async function sanitizeFileBounded(source, target, options = {}) {
|
|
|
399
431
|
!Number.isSafeInteger(workerHeapMb) ||
|
|
400
432
|
workerHeapMb < 32 ||
|
|
401
433
|
!Number.isSafeInteger(workerTimeoutMs) ||
|
|
402
|
-
workerTimeoutMs <= 0
|
|
434
|
+
workerTimeoutMs <= 0 ||
|
|
435
|
+
workerTimeoutMs > 2147483647
|
|
403
436
|
) {
|
|
404
437
|
throw failure('ERR_CONFIG');
|
|
405
438
|
}
|
|
@@ -430,20 +463,3 @@ export async function sanitizeFileBounded(source, target, options = {}) {
|
|
|
430
463
|
await rm(staging, { recursive: true, force: true });
|
|
431
464
|
}
|
|
432
465
|
}
|
|
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
|
-
}
|