@link-foundation/sensitive-data-sanitizer 0.12.0 → 0.12.2

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 CHANGED
@@ -1,5 +1,17 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.12.2
4
+
5
+ ### Patch Changes
6
+
7
+ - 704c4b7: Preserve Authorization placeholders, matching brackets and control-byte boundaries. Bound repeated WASM input copies when scanning dense credentials by continuing with the existing RE2JS engine.
8
+
9
+ ## 0.12.1
10
+
11
+ ### Patch Changes
12
+
13
+ - 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.
14
+
3
15
  ## 0.12.0
4
16
 
5
17
  ### 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 and NUL-containing binary input are refused. 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.
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 = { lossless: config.lossless };
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 only `{event: 'detected', count}`.
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 only |
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 keeps the previous selection behavior; `[]` selects nothing.
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 still identify binary input and
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, C0 controls (including NUL), DEL, quotes, backslashes and commas end the value, preserving subsequent log text in shell and escaped JSON prompts. Matching brackets opened within a token are included in its redaction; unmatched closing brackets remain part of the surrounding log. Already-redacted tokens and known credential references remain exempt after an optional scheme, including `<token>` placeholders. The debug callback receives value-free `gitleaks-rule` events with rule ID, input length and elapsed milliseconds; tracing is off by default.
package/docs/COVERAGE.md CHANGED
@@ -6,7 +6,7 @@ Coverage is a union of maintained detectors, explicit known values, context and
6
6
 
7
7
  The native provider supplements in `src/rules.js` cover GitHub, GitLab, OpenAI, Anthropic, AWS access IDs, Google API/OAuth, Slack, Stripe, SendGrid, Twilio, npm, PyPI, Telegram, Discord, Hugging Face, Shopify, Databricks, Square, Mailchimp, DigitalOcean, Vault, Grafana, Linear, Notion, Vercel, and JWT-shaped values. Prefix matches also protect selected truncated tokens; they do not validate a token with a provider.
8
8
 
9
- The default native engine bundles 221 text rules from Gitleaks 8.30.1 (222 upstream rules; the path-only PKCS12 rule is outside text detection), compiled unchanged with RE2/WASM or RE2JS for inputs above 4 MiB. The MIT license, commit and sync script are retained in `src/vendor/gitleaks`. Keyword prefilters and configured entropy thresholds run natively; upstream comment/path allowlists cannot suppress a sanitizer finding. Quoted hex/base64 entropy detection supplements those rules. The default asynchronous engine additionally uses the pinned Secretlint recommended scanners, excluding its comment-filter rule. Embedded disable directives in untrusted text cannot suppress findings. Optional Gitleaks and TruffleHog invoke their installed detector sets, and custom detectors can add formats. Service counts are not a measure of comparative recall.
9
+ The default native engine bundles 221 text rules from Gitleaks 8.30.1 (222 upstream rules; the path-only PKCS12 rule is outside text detection), compiled unchanged with RE2/WASM or RE2JS for inputs above 4 MiB. Dense rule matches switch to RE2JS after at most eight WASM matches per view, bounding repeated whole-input copies without truncating secrets or changing anchors. The MIT license, commit and sync script are retained in `src/vendor/gitleaks`. Keyword prefilters and configured entropy thresholds run natively; upstream comment/path allowlists cannot suppress a sanitizer finding. Quoted hex/base64 entropy detection supplements those rules. The default asynchronous engine additionally uses the pinned Secretlint recommended scanners, excluding its comment-filter rule. Embedded disable directives in untrusted text cannot suppress findings. Optional Gitleaks and TruffleHog invoke their installed detector sets, and custom detectors can add formats. Service counts are not a measure of comparative recall.
10
10
 
11
11
  Context rules recognize credential labels with bounded key affixes, JSON and nested escaped JSON, YAML/env/INI assignments, shell flags, XML values, URL userinfo, authorization headers, cookies, webhook paths, and complete or truncated private-key PEM blocks. Low-entropy contextual values such as `password=1234` are protected. Generic token counters, booleans/null/language literals and ordinary Git hashes are excluded to retain useful logs. Supply literal known values for ambiguous credentials or unusual key names.
12
12
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@link-foundation/sensitive-data-sanitizer",
3
- "version": "0.12.0",
3
+ "version": "0.12.2",
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,39 @@ 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
+ const scheme = /^[a-z][a-z0-9_-]*[ \t]+/i.exec(text.slice(start));
167
+ const tokenStart = start + (scheme?.[0].length ?? 0);
168
+ const brackets = [];
169
+ let end = tokenStart;
170
+ for (; end < text.length; end++) {
171
+ const char = text[end];
172
+ const code = text.charCodeAt(end);
173
+ if (code < 32 || code === 127 || /[\s"'\\,]/.test(char)) {
174
+ break;
175
+ }
176
+ if (char === '<' && end !== tokenStart && !brackets.length) {
177
+ break;
178
+ }
179
+ const closing = { '{': '}', '[': ']', '<': '>' }[char];
180
+ if (closing) {
181
+ brackets.push(closing);
182
+ } else if (/[}\]>]/.test(char) && char !== brackets.pop()) {
183
+ // An unmatched closer belongs to the enclosing log structure.
184
+ break;
185
+ }
186
+ }
187
+ const token = text.slice(tokenStart, end);
188
+ return !token || credentialPlaceholder(token) ? undefined : { start, end };
189
+ }
153
190
  function quotedRange(text, start, opening, maxLength) {
154
191
  const depth = opening[1].length,
155
192
  quote = opening[2];
@@ -301,7 +338,9 @@ function detectCredentials(text, emit) {
301
338
  : /[::]/.test(match[2])
302
339
  ? 'line'
303
340
  : 'prose';
304
- let range = valueRange(text, start, mode);
341
+ let range = /^(?:proxy-)?authorization$/i.test(match[1])
342
+ ? authorizationRange(text, start)
343
+ : valueRange(text, start, mode);
305
344
  if (range && mode === 'prose' && genericLabel.test(match[1])) {
306
345
  range = generatedWord(text, range);
307
346
  }
@@ -416,7 +455,9 @@ function detectCommandPasswords(text, emit) {
416
455
  }
417
456
 
418
457
  export function detectContext(text, emit, credentialsOnly = false) {
419
- detectCredentials(text, emit);
458
+ if (credentialHint.test(text)) {
459
+ detectCredentials(text, emit);
460
+ }
420
461
  if (!credentialsOnly) {
421
462
  detectPersonalLabels(text, emit);
422
463
  }
@@ -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"',;])/i.exec(
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(text, emit);
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
- collectMatches(
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
- /"(?:[^"\\\r\n]|\\[^\r\n]){1,8192}"/g,
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.matchAll(pattern)) {
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
- const projection = projectText(text);
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
- for (const [locale, data] of Object.entries(locales)) {
108
- for (const name of data.person?.first_name?.female ?? []) {
109
- female.add(latinName(name));
102
+ let indexed = false;
103
+ function indexNames() {
104
+ if (indexed) {
105
+ return;
110
106
  }
111
- for (const names of Object.values(data.person?.last_name ?? {})) {
112
- for (const name of names) {
113
- if (!surnames.has(latinName(name))) {
114
- surnames.set(latinName(name), locale);
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(bytes, { lossless = false } = {}) {
10
- if (bytes.includes(0)) {
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/gitleaks.js CHANGED
@@ -12,7 +12,7 @@ import { keywordMatcher } from './keywords.js';
12
12
 
13
13
  export const gitleaksRules = [...r0, ...r1, ...r2, ...r3, ...r4, ...r5];
14
14
  const compiled = new Map();
15
- const largePatterns = new Map();
15
+ const linearPatterns = new Map();
16
16
  const keywordIndex = keywordMatcher(
17
17
  gitleaksRules.flatMap((r) =>
18
18
  (r.keywords ?? []).map((word) => word.toLowerCase())
@@ -119,17 +119,6 @@ function* boundedViews(text, prefix, id) {
119
119
  }
120
120
 
121
121
  function scanRule(text, base, rule, emit, large) {
122
- // re2-wasm 1.0.2 returns codepoint indices despite its RegExp-like API.
123
- // Convert once per view, and keep its search cursor in codepoints too.
124
- let offsets;
125
- if (!large && /[\uD800-\uDBFF]/.test(text)) {
126
- offsets = [0];
127
- let offset = 0;
128
- for (const char of text) {
129
- offset += char.length;
130
- offsets.push(offset);
131
- }
132
- }
133
122
  for (const match of ruleMatches(text, rule, large)) {
134
123
  const secret = capturedSecret(rule, match);
135
124
  if (!secret) {
@@ -140,12 +129,8 @@ function scanRule(text, base, rule, emit, large) {
140
129
  const offset = match[0].indexOf(secret),
141
130
  last = match[0].lastIndexOf(secret);
142
131
  emit({
143
- start: base + (offsets ? offsets[match.index] : match.index) + offset,
144
- end:
145
- base +
146
- (offsets ? offsets[match.index] : match.index) +
147
- last +
148
- secret.length,
132
+ start: base + match.index + offset,
133
+ end: base + match.index + last + secret.length,
149
134
  type: 'SECRET',
150
135
  category: 'credential',
151
136
  rule: `gitleaks/${rule.id}`,
@@ -158,28 +143,57 @@ function* ruleMatches(text, rule, large) {
158
143
  if (large) {
159
144
  // re2-wasm has a fixed 16 MiB heap. RE2JS implements the same linear-time
160
145
  // syntax without copying the input into WASM. Its indices are UTF-16.
161
- let linear = largePatterns.get(rule.id);
162
- if (!linear) {
163
- linear = RE2JS.compile(rule.regex);
164
- largePatterns.set(rule.id, linear);
165
- }
166
- if (linear.test(text)) {
167
- yield* linear.matchAll(text);
168
- }
146
+ yield* linearMatches(text, rule);
169
147
  return;
170
148
  }
149
+ // re2-wasm 1.0.2 returns codepoint indices despite its RegExp-like API.
150
+ // Normalize matches here so switching engines never changes public offsets.
151
+ let offsets;
152
+ if (/[\uD800-\uDBFF]/.test(text)) {
153
+ offsets = [0];
154
+ let offset = 0;
155
+ for (const char of text) {
156
+ offset += char.length;
157
+ offsets.push(offset);
158
+ }
159
+ }
171
160
  let pattern = compiled.get(rule.id);
172
161
  if (!pattern) {
173
162
  pattern = new RE2(rule.regex, 'gu');
174
163
  compiled.set(rule.id, pattern);
175
164
  }
176
165
  pattern.lastIndex = 0;
177
- let match;
178
- while ((match = pattern.exec(text)) !== null) {
179
- pattern.lastIndex =
180
- match.index + Array.from(match[0]).length || pattern.lastIndex + 1;
166
+ for (let attempts = 0; attempts < 8; attempts++) {
167
+ const match = pattern.exec(text);
168
+ if (!match) {
169
+ return;
170
+ }
171
+ pattern.lastIndex = match.index + Math.max(1, Array.from(match[0]).length);
172
+ match.index = offsets ? offsets[match.index] : match.index;
181
173
  yield match;
182
174
  }
175
+ // Every exec recopies the whole view into WASM. Bound those copies even
176
+ // for dense inputs, then replay at most eight matches in the JS engine.
177
+ // Keep the original subject: slicing would change anchors and boundaries.
178
+ const resume = offsets ? offsets[pattern.lastIndex] : pattern.lastIndex;
179
+ if (resume !== undefined && resume <= text.length) {
180
+ yield* linearMatches(text, rule, resume);
181
+ }
182
+ }
183
+
184
+ function* linearMatches(text, rule, resume = 0) {
185
+ let linear = linearPatterns.get(rule.id);
186
+ if (!linear) {
187
+ linear = RE2JS.compile(rule.regex);
188
+ linearPatterns.set(rule.id, linear);
189
+ }
190
+ if (linear.test(text)) {
191
+ for (const match of linear.matchAll(text)) {
192
+ if (match.index >= resume) {
193
+ yield match;
194
+ }
195
+ }
196
+ }
183
197
  }
184
198
 
185
199
  function capturedSecret(rule, match) {
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: { event: 'detected'; count: number }) => void;
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
+ }
@@ -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.workerHeapMb ?? 256,
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((name) => secretEnvName.test(name))
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
@@ -31,6 +31,9 @@ export function decodeLogBytes(input) {
31
31
  }
32
32
 
33
33
  export function encodeLogText(text) {
34
+ if (!/[\uDC80-\uDCFF]/.test(text)) {
35
+ return Buffer.from(text);
36
+ }
34
37
  const parts = [];
35
38
  let start = 0;
36
39
  for (let i = 0; i < text.length; i++) {
package/src/sanitizer.js CHANGED
@@ -329,6 +329,9 @@ function encodedDetect(text, options, emit) {
329
329
  }
330
330
 
331
331
  function locations(text, findings) {
332
+ if (!findings.length) {
333
+ return findings;
334
+ }
332
335
  const starts = [0];
333
336
  for (let i = 0; i < text.length; i++) {
334
337
  if (text[i] === '\n') {
@@ -5,7 +5,8 @@ try {
5
5
  value: await sanitizeFileToFile(
6
6
  workerData.source,
7
7
  workerData.target,
8
- workerData.options
8
+ workerData.options,
9
+ () => parentPort.postMessage({ progress: true })
9
10
  ),
10
11
  });
11
12
  } catch (error) {
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 jsonl =
44
- (options.sanitizerOptions?.structured ??
45
- suppliedOptions?.structured ??
46
- options.structured) === 'jsonl';
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 || bytes.includes(0)) {
248
- throw failure(bytes.includes(0) ? 'ERR_BINARY' : 'ERR_LIMIT');
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(source, path, options = {}) {
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(source, target, options = {}) {
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
- }