@mossbear/eval-core 0.1.0-alpha.70 → 0.1.0-alpha.72

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.
@@ -2,10 +2,10 @@
2
2
  * Secret redaction and length-capping for shell command text.
3
3
  *
4
4
  * Command text can embed secrets or whole file bodies (heredocs, inline env
5
- * vars). The CLI runs every logged command through this before it becomes an
6
- * action summary, so nothing that leaves the machine carries credentials or
7
- * bulk file content (`packages/cli/src/action-queue.ts`). File contents are
8
- * never read or included in the first place.
5
+ * vars). The CLI runs logged commands through this before creating an action
6
+ * summary. Recognized credential syntax is removed; arbitrary shell/tool
7
+ * programs remain an open language, so pattern scanning cannot prove that
8
+ * every surviving argument is credential-free.
9
9
  *
10
10
  * Lives in eval-core rather than the CLI because grading is what consumes the
11
11
  * result: this is the boundary that decides what a grader may ever see of a
@@ -20,10 +20,62 @@ import { PRIVATE_KEY_BLOCK_SOURCE, REDACTED } from './redaction.js';
20
20
  * must not leave the machine.
21
21
  */
22
22
  const MAX_COMMAND_CHARS = 200;
23
- const SENSITIVE_KEY = new RegExp(`^${SENSITIVE_KEY_SOURCE}$`, 'i');
23
+ // SSHPASS is sshpass's dedicated environment credential, despite not
24
+ // containing the usual password/token spelling.
25
+ const COMMAND_SENSITIVE_KEY_SOURCE = `(?:${SENSITIVE_KEY_SOURCE}|SSHPASS)`;
26
+ const SENSITIVE_KEY = new RegExp(`^${COMMAND_SENSITIVE_KEY_SOURCE}$`, 'i');
24
27
  const SENSITIVE_CONTENT = new RegExp(SENSITIVE_WORD_SOURCE, 'i');
28
+ // Only switches that consume no argument may precede an argument flag in
29
+ // curl's short-option clusters. For example, -sx is proxy auth, while -osx
30
+ // names an output file and must not be interpreted as a proxy option.
31
+ const CURL_SHORT_SWITCHES = '[012346aBfgGiIjJklLMnNOpqRsSvVZ:#]*';
32
+ const CURL_PAYLOAD_FLAG = new RegExp(`^(-${CURL_SHORT_SWITCHES}[dHF])(.+)?$|^(--(?:data|data-raw|data-binary|data-urlencode|json|form|form-string|header|proxy-header))(?:=(.*))?$`);
33
+ const CURL_CONFIG_FLAG = new RegExp(`^(?:-${CURL_SHORT_SWITCHES}K|--config(?:=|$))`);
34
+ const CURL_URL_OPTIONS = 'url|proxy|proxy1\\.0|preproxy|socks4|socks4a|socks5|socks5-hostname';
35
+ const CURL_URL_FIELD = new RegExp(`^(?:${CURL_URL_OPTIONS})$`, 'i');
36
+ const CREDENTIAL_TOOLS = ['curl', 'http', 'https', 'httpie', 'openssl', 'sshpass'];
37
+ // Valued wrapper options name users, files or scripts, never the child tool.
38
+ const WRAPPER_VALUE_FLAGS = {
39
+ sudo: /^-(?:[AbEHIKknPSVbveis]*[ughpDTRCrt])(.+)?$|^--(?:user|group|host|prompt|chdir|chroot|command-timeout|close-from|role|type)(?:=(.*))?$/,
40
+ doas: /^-[uC](.+)?$/,
41
+ env: /^-[uCSa](.+)?$|^--(?:unset|chdir|split-string|argv0)(?:=(.*))?$/,
42
+ time: /^-[of](.+)?$|^--(?:output|format)(?:=(.*))?$/,
43
+ xargs: /^-[adEILnPs](.+)?$|^--(?:arg-file|delimiter|eof|replace|max-lines|max-args|max-procs|max-chars)(?:=(.*))?$/,
44
+ };
45
+ const CURL_AUTH_OPTIONS = `user|proxy-user|${CURL_URL_OPTIONS}|cookie|oauth2-bearer|cert|proxy-cert|pass|proxy-pass`;
46
+ const CURL_AUTH_FLAG = new RegExp(`^(-${CURL_SHORT_SWITCHES}[uUbEx])(.+)?$|^(--(?:${CURL_AUTH_OPTIONS}))(?:=(.*))?$`);
47
+ const CURL_EXPANSION = /\{\{([A-Za-z0-9_]+)(?::[^{}]*)?\}\}/g;
48
+ const CURL_EXPAND_FLAG = new RegExp(`^--expand-(${CURL_AUTH_OPTIONS}|header|proxy-header|data|data-raw|data-binary|data-urlencode|json|form|form-string)(?:=(.*))?$`);
49
+ function hasPrivateConfigVariables(body) {
50
+ if (!/\b(?:expand-)?variable\s*[=: \t]/i.test(body))
51
+ return false;
52
+ if (/\bexpand-(?:user|proxy-user|oauth2-bearer|cookie|cert|proxy-cert|pass|proxy-pass|header|proxy-header|data(?:-raw|-binary|-urlencode)?|json|form(?:-string)?)\s*[=: \t]/i.test(body))
53
+ return true;
54
+ return (/\bexpand-(?:url|proxy|preproxy|socks[45](?:a|-hostname)?)\s*[=: \t]/i.test(body) &&
55
+ (/[^/\s:@]+:[^@\s]*@/.test(body) ||
56
+ new RegExp(`[?&#]${COMMAND_SENSITIVE_KEY_SOURCE}=`, 'i').test(body)));
57
+ }
25
58
  function decodeShellWord(word) {
26
- return word.replace(/'([^']*)'|"((?:\\.|[^"\\])*)"|\\([\s\S])|([^'"\\]+)/g, (_, single, double, escaped, plain) => {
59
+ return word.replace(/\$'((?:\\[\s\S]|[^'\\])*)'|'([^']*)'|"((?:\\.|[^"\\])*)"|\\([\s\S])|([^'"\\]+)/g, (_, ansi, single, double, escaped, plain) => {
60
+ if (ansi !== undefined)
61
+ return ansi.replace(/\\(?:x([\da-f]{1,2})|u([\da-f]{1,4})|U([\da-f]{1,8})|([0-7]{1,3})|([\s\S]))/gi, (escape, hex, unicode, wide, octal, character) => {
62
+ if (hex || unicode || wide || octal) {
63
+ const code = Number.parseInt(hex ?? unicode ?? wide ?? octal, octal ? 8 : 16);
64
+ return code <= 0x10ffff ? String.fromCodePoint(code) : escape;
65
+ }
66
+ const controls = {
67
+ a: '\x07',
68
+ b: '\b',
69
+ e: '\x1b',
70
+ E: '\x1b',
71
+ f: '\f',
72
+ n: '\n',
73
+ r: '\r',
74
+ t: '\t',
75
+ v: '\v',
76
+ };
77
+ return controls[character] ?? (/['"\\?]/.test(character) ? character : escape);
78
+ });
27
79
  if (single !== undefined)
28
80
  return single;
29
81
  if (double !== undefined)
@@ -31,6 +83,85 @@ function decodeShellWord(word) {
31
83
  return escaped ?? plain ?? '';
32
84
  });
33
85
  }
86
+ /** A program supplying a credential argument carries private produced content. */
87
+ function redactCommandSubstitutions(command) {
88
+ const marker = '__SUBSTITUTION_BOUNDARY__';
89
+ if (command.includes(marker))
90
+ return REDACTED;
91
+ const isCredentialInput = (start, end) => {
92
+ const probe = command.slice(0, start) + marker + command.slice(end + 1);
93
+ const inputs = getToolInputs(probe);
94
+ let redacted = redactCredentialAssignments(redactToolArguments(redactJsonArguments(probe), inputs), inputs);
95
+ for (const [pattern, replacement] of COMMAND_REDACTIONS)
96
+ redacted = redacted.replace(pattern, replacement);
97
+ return !redacted.includes(marker);
98
+ };
99
+ const findEnd = (start, closing, depth) => {
100
+ if (depth > 8)
101
+ return command.length;
102
+ let quote = null;
103
+ let groups = 0;
104
+ for (let index = start; index < command.length; index += 1) {
105
+ const char = command[index];
106
+ if (char === '\\' && quote !== "'") {
107
+ index += 1;
108
+ continue;
109
+ }
110
+ if (closing === '`' && char === '`')
111
+ return index;
112
+ if (quote === "'") {
113
+ if (char === "'")
114
+ quote = null;
115
+ continue;
116
+ }
117
+ if (char === '$' && command[index + 1] === '(') {
118
+ index = findEnd(index + 2, ')', depth + 1);
119
+ continue;
120
+ }
121
+ if (char === '`') {
122
+ index = findEnd(index + 1, '`', depth + 1);
123
+ continue;
124
+ }
125
+ if (char === "'" && quote === null)
126
+ quote = "'";
127
+ else if (char === '"')
128
+ quote = quote === '"' ? null : '"';
129
+ else if (quote === null && char === '(')
130
+ groups += 1;
131
+ else if (quote === null && char === ')') {
132
+ if (groups === 0 && closing === ')')
133
+ return index;
134
+ groups -= 1;
135
+ }
136
+ }
137
+ return command.length;
138
+ };
139
+ let quote = null;
140
+ let out = '';
141
+ let substitutions = 0;
142
+ for (let index = 0; index < command.length; index += 1) {
143
+ const char = command[index];
144
+ if (char === '\\' && quote !== "'") {
145
+ out += command.slice(index, index + 2);
146
+ index += 1;
147
+ continue;
148
+ }
149
+ if (quote !== "'" && (char === '`' || (char === '$' && command[index + 1] === '('))) {
150
+ if (++substitutions > 64)
151
+ return out + REDACTED;
152
+ const start = index;
153
+ index = findEnd(index + (char === '`' ? 1 : 2), char === '`' ? '`' : ')', 0);
154
+ out += isCredentialInput(start, index) ? REDACTED : command.slice(start, index + 1);
155
+ continue;
156
+ }
157
+ if (char === "'" && quote !== '"')
158
+ quote = quote === "'" ? null : "'";
159
+ else if (char === '"' && quote !== "'")
160
+ quote = quote === '"' ? null : '"';
161
+ out += char;
162
+ }
163
+ return out;
164
+ }
34
165
  function hasSensitiveContent(text) {
35
166
  // Even malformed JSON may spell a key with unicode escapes.
36
167
  return SENSITIVE_CONTENT.test(text.replace(/\\u([0-9a-f]{4})/gi, (_, hex) => String.fromCharCode(Number.parseInt(hex, 16))));
@@ -109,17 +240,20 @@ const COMMAND_REDACTIONS = [
109
240
  [/\b([a-z][a-z0-9+.-]*:\/\/)[^/\s:@]+@/gi, `$1${REDACTED}@`],
110
241
  [/\b(authorization\s*:\s*)(?:(?:bearer|basic|token)\s+)?\S+/gi, `$1${REDACTED}`],
111
242
  [new RegExp(COOKIE_HEADER_SOURCE, 'gi'), `$1${REDACTED}`],
112
- [new RegExp(`([?&]${SENSITIVE_KEY_SOURCE}=)[^&\\s"'<>]+`, 'gi'), `$1${REDACTED}`],
243
+ [
244
+ new RegExp(`([?&#]${COMMAND_SENSITIVE_KEY_SOURCE}=)[^&\\s"'<>]+`, 'gi'),
245
+ `$1${REDACTED}`,
246
+ ],
113
247
  // Space-separated credential flags have no assignment operator or token
114
248
  // prefix. Keep the flag for evidence, but never its value.
115
249
  [
116
- new RegExp(`(^|[\\s;&|(])(--${SENSITIVE_KEY_SOURCE}\\s+)${SHELL_WORD_SOURCE}`, 'gi'),
250
+ new RegExp(`(^|[\\s;&|(])(--${COMMAND_SENSITIVE_KEY_SOURCE}\\s+)${SHELL_WORD_SOURCE}`, 'gi'),
117
251
  `$1$2${REDACTED}`,
118
252
  ],
119
253
  // JSON bodies embedded in shell commands are still command text. Quoted
120
254
  // keys evade the shell-assignment scanner below, including escaped values.
121
255
  [
122
- new RegExp(`("${SENSITIVE_KEY_SOURCE}"\\s*:\\s*)"(?:\\\\.|[^"\\\\])*"`, 'gi'),
256
+ new RegExp(`("${COMMAND_SENSITIVE_KEY_SOURCE}"\\s*:\\s*)"(?:\\\\.|[^"\\\\])*"`, 'gi'),
123
257
  `$1"${REDACTED}"`,
124
258
  ],
125
259
  // Sensitive assignment: an env-var-ish key (the sensitive word optionally
@@ -130,7 +264,7 @@ const COMMAND_REDACTIONS = [
130
264
  // never fires mid-word; the whole value — quotes included — is redacted so
131
265
  // whitespace-containing passphrases can't slip through.
132
266
  [
133
- new RegExp(`(^|[\\s;&|(])(${SENSITIVE_KEY_SOURCE}\\s*[=:]\\s*)${SHELL_WORD_SOURCE}`, 'gi'),
267
+ new RegExp(`(^|[\\s;&|(])(${COMMAND_SENSITIVE_KEY_SOURCE}\\s*[=:]\\s*)${SHELL_WORD_SOURCE}`, 'gi'),
134
268
  `$1$2${REDACTED}`,
135
269
  ],
136
270
  // Our own CLI token (`API_KEY_PREFIX`, the prefix the dashboard mints CLI
@@ -156,23 +290,263 @@ const COMMAND_REDACTIONS = [
156
290
  [/\bxox[baprs]-[A-Za-z0-9-]{8,}/g, REDACTED],
157
291
  [/\bAKIA[0-9A-Z]{16}\b/g, REDACTED],
158
292
  ];
159
- /** Redact HTTP-client auth flags without reinterpreting other tools' flags. */
160
- function redactHttpClientArguments(command) {
293
+ /**
294
+ * Proxy addresses take userinfo with or without a URL scheme, so the general
295
+ * URL rule cannot see the scheme-less form. A bare `a:b@c` is not matched
296
+ * everywhere because ordinary text has that shape too (`image:tag@sha256:…`);
297
+ * only values in HTTP-client commands or proxy assignments are checked.
298
+ */
299
+ const PROXY_USERINFO = /^((?:[a-z][a-z\d+.-]*:\/\/)?[^/?#:@]*:)[^/?#]*@/i;
300
+ /**
301
+ * Redact the password from proxy environment assignments (`https_proxy=`,
302
+ * `ALL_PROXY=`, …), which HTTP clients read at startup. Keeps the user and
303
+ * host, matching the URL-userinfo rule, and leaves unauthenticated values as
304
+ * they are. Other inline curl authentication fields lose their whole value.
305
+ */
306
+ function redactCredentialAssignments(command, inputs) {
307
+ let assignments = command.replace(new RegExp(SHELL_WORD_SOURCE, 'g'), (word) => {
308
+ // `env` accepts a quoted whole NAME=value argument. Decode the complete
309
+ // word before identifying its key, just as for shell-encoded options.
310
+ const assignment = /^([^=\0]+)=([\s\S]*)$/.exec(decodeShellWord(word));
311
+ if (!assignment)
312
+ return word;
313
+ let key = assignment[1];
314
+ let operator = '=';
315
+ const proxy = assignment[2];
316
+ if (key.endsWith('+') && !inputs.env.has(key)) {
317
+ key = key.slice(0, -1);
318
+ operator = '+=';
319
+ }
320
+ if (inputs.env.has(key))
321
+ return `${key}${operator}${REDACTED}`;
322
+ if (!/^(?:\w+_)?proxy$/i.test(key))
323
+ return word;
324
+ if (!PROXY_USERINFO.test(proxy))
325
+ return word;
326
+ const redacted = proxy.replace(PROXY_USERINFO, `$1${REDACTED}@`);
327
+ return `${key}${operator}${redacted}`;
328
+ });
329
+ if (inputs.passSources.size || inputs.env.size)
330
+ assignments = assignments.replace(new RegExp(`(<<<[ \\t]*)${SHELL_WORD_SOURCE}`, 'g'), `$1${REDACTED}`);
331
+ // Config literals are produced by echo/printf before curl consumes -K.
332
+ // Keep that channel separate from executable flags: sort -u and OpenSSL
333
+ // passphrase source options must not become curl configuration by spelling.
334
+ const words = [
335
+ ...assignments.matchAll(new RegExp(`${SHELL_WORD_SOURCE}|[;&|()\\n]`, 'g')),
336
+ ];
337
+ if (!inputs.curlConfig && !inputs.passSources.size && !inputs.env.size)
338
+ return assignments;
339
+ const fields = new RegExp(`(^|[\\s'\"])(-{0,2}(?:(?:\\w+_)?proxy|${CURL_AUTH_OPTIONS})|-[uUbEx])([ \\t]*(?:=|:)[ \\t]*|[ \\t]+)(${SHELL_WORD_SOURCE})`, 'gi');
340
+ const edits = [];
341
+ let producerStart = null;
342
+ let producerPrintf = false;
343
+ const finishProducer = (end) => {
344
+ if (producerStart === null)
345
+ return;
346
+ const body = assignments.slice(producerStart, end);
347
+ const prefix = assignments.slice(0, producerStart);
348
+ const producesPassphrase = (assignments[end] === '|' &&
349
+ (inputs.passSources.has('stdin') ||
350
+ [...inputs.passSources].some((source) => source.startsWith('fd:')))) ||
351
+ ([...inputs.passSources].some((source) => /^(?:fd:|file:)/.test(source)) &&
352
+ prefix.includes('<(')) ||
353
+ [...inputs.passSources].some((source) => source.startsWith('file:') &&
354
+ body.includes('>') &&
355
+ body.includes(source.slice(5))) ||
356
+ [...inputs.env].some((key) => prefix.includes(`${key}=`) && prefix.includes('('));
357
+ if (producesPassphrase) {
358
+ edits.push({ start: producerStart, end, text: ` ${REDACTED} ` });
359
+ producerStart = null;
360
+ return;
361
+ }
362
+ if (!inputs.curlConfig) {
363
+ producerStart = null;
364
+ return;
365
+ }
366
+ const decodedBody = [...body.matchAll(new RegExp(SHELL_WORD_SOURCE, 'g'))]
367
+ .map((word) => decodeShellWord(word[0]))
368
+ .join(' ');
369
+ if (hasPrivateConfigVariables(decodedBody)) {
370
+ edits.push({ start: producerStart, end, text: ` ${REDACTED} ` });
371
+ producerStart = null;
372
+ return;
373
+ }
374
+ const redactField = (match, prefix, key, separator, value) => {
375
+ if (/^-[a-z]$/i.test(key) && !['-u', '-U', '-b', '-E', '-x'].includes(key))
376
+ return match;
377
+ const credential = decodeShellWord(value);
378
+ const field = key.replace(/^-+/, '');
379
+ if (producerPrintf && credential.includes('%'))
380
+ return `${prefix}${key}${separator}${REDACTED}`;
381
+ if (/^(?:proxy-)?(?:user|cert)$/i.test(field)) {
382
+ if (!credential.includes(':'))
383
+ return match;
384
+ return `${prefix}${key}${separator}${credential.replace(/^([^:]*:)[\s\S]*$/, `$1${REDACTED}`)}`;
385
+ }
386
+ if (/^cookie$/i.test(field) && !credential.includes('='))
387
+ return match;
388
+ if (/^(?:(?:\w+_)?proxy|x)$/i.test(field) || CURL_URL_FIELD.test(field))
389
+ return PROXY_USERINFO.test(credential)
390
+ ? `${prefix}${key}${separator}${credential.replace(PROXY_USERINFO, `$1${REDACTED}@`)}`
391
+ : match;
392
+ return `${prefix}${key}${separator}${REDACTED}`;
393
+ };
394
+ // A producer's literals obey shell quoting before becoming config text.
395
+ // Decode concatenated/escaped field names before matching them, while
396
+ // retaining untouched words and also scanning unquoted multiword fields.
397
+ let text = body.replace(new RegExp(SHELL_WORD_SOURCE, 'g'), (word) => {
398
+ const decoded = decodeShellWord(word);
399
+ const redacted = decoded.replace(fields, redactField);
400
+ return redacted === decoded ? word : JSON.stringify(redacted);
401
+ });
402
+ text = text.replace(fields, redactField);
403
+ // A printf template may take the credential from later arguments. Once
404
+ // its format is private, drop the argument list rather than expose the
405
+ // values substituted into it or attempt to interpret printf formatting.
406
+ if (text !== body && producerPrintf && body.includes('%'))
407
+ text = ` ${REDACTED} `;
408
+ if (text !== body)
409
+ edits.push({ start: producerStart, end, text });
410
+ producerStart = null;
411
+ };
412
+ for (const word of words) {
413
+ if (/^[;&|()\n]$/.test(word[0]))
414
+ finishProducer(word.index);
415
+ else if (producerStart === null &&
416
+ ['echo', 'printf'].includes(decodeShellWord(word[0]).split(/[\\/]/).at(-1) ?? '')) {
417
+ producerStart = word.index + word[0].length;
418
+ producerPrintf = decodeShellWord(word[0]).split(/[\\/]/).at(-1) === 'printf';
419
+ }
420
+ }
421
+ finishProducer(assignments.length);
422
+ let result = assignments;
423
+ for (const edit of edits.reverse())
424
+ result = result.slice(0, edit.start) + edit.text + result.slice(edit.end);
425
+ return result;
426
+ }
427
+ /** Redact known tool credential flags without matching unrelated short flags. */
428
+ function redactToolArguments(command, inputs) {
161
429
  const words = [...command.matchAll(new RegExp(`${SHELL_WORD_SOURCE}|[;&|()\n]`, 'g'))];
162
430
  const edits = [];
163
431
  let client = null;
164
432
  let options = true;
433
+ let opensslPasswd = false;
434
+ let opensslSubcommandRead = false;
435
+ const variables = new Map();
436
+ const expansions = [];
437
+ const resolveVariable = (contents, depth = 0) => {
438
+ if (depth > 20)
439
+ return 'private:@';
440
+ return contents.replace(CURL_EXPANSION, (_, name) => resolveVariable(variables.get(name)?.at(-1)?.contents ?? '', depth + 1));
441
+ };
442
+ const finishCurl = () => {
443
+ const privateNames = new Set();
444
+ for (const expansion of expansions) {
445
+ if (CURL_URL_FIELD.test(expansion.field) &&
446
+ !PROXY_USERINFO.test(expansion.resolved) &&
447
+ !new RegExp(`[?&#]${COMMAND_SENSITIVE_KEY_SOURCE}=`, 'i').test(expansion.resolved))
448
+ continue;
449
+ for (const match of expansion.contents.matchAll(CURL_EXPANSION))
450
+ privateNames.add(match[1]);
451
+ }
452
+ // Expanded variable definitions can themselves refer to other definitions.
453
+ // Walk each name once, including all assignments, without evaluating shell code.
454
+ for (const name of privateNames) {
455
+ for (const variable of variables.get(name) ?? []) {
456
+ if (variable.env)
457
+ inputs?.env.add(name);
458
+ if (variable.contents === undefined)
459
+ continue;
460
+ for (const match of variable.contents.matchAll(CURL_EXPANSION))
461
+ privateNames.add(match[1]);
462
+ edits.push({
463
+ start: variable.start,
464
+ end: variable.end,
465
+ text: `${variable.prefix}${REDACTED}`,
466
+ });
467
+ }
468
+ }
469
+ variables.clear();
470
+ expansions.length = 0;
471
+ };
165
472
  for (let index = 0; index < words.length; index += 1) {
166
473
  const word = words[index];
167
474
  if (!word)
168
475
  continue;
169
476
  const raw = word[0];
170
477
  if (/^[;&|()\n]$/.test(raw)) {
478
+ finishCurl();
171
479
  client = null;
480
+ opensslPasswd = false;
481
+ opensslSubcommandRead = false;
172
482
  options = true;
173
483
  continue;
174
484
  }
175
485
  const decoded = decodeShellWord(raw);
486
+ if (opensslPasswd) {
487
+ // passwd hashes each positional plaintext password. Its source and salt
488
+ // flags consume nonsecret references, which must not be mistaken for it.
489
+ const sourceFlag = /^-{1,2}(?:in|salt|rand|writerand|provider|provider-path|provparam|propquery)(?:=(.*))?$/.exec(decoded);
490
+ if (options && sourceFlag) {
491
+ if (inputs && /^-{1,2}in(?:=|$)/.test(decoded)) {
492
+ const source = sourceFlag[1] ?? words[index + 1]?.[0];
493
+ if (source)
494
+ inputs.passSources.add(`file:${decodeShellWord(source)}`);
495
+ }
496
+ if (sourceFlag[1] === undefined)
497
+ index += 1;
498
+ continue;
499
+ }
500
+ if (options && /^-{1,2}stdin$/.test(decoded))
501
+ inputs?.passSources.add('stdin');
502
+ if (!options || !decoded.startsWith('-')) {
503
+ edits.push({ start: word.index, end: word.index + raw.length, text: REDACTED });
504
+ continue;
505
+ }
506
+ }
507
+ if (client === 'sshpass') {
508
+ // sshpass dispatches a child after its own credential/source options.
509
+ // Consume source arguments so a filename named curl is not executable.
510
+ const source = /^-[fdP](.*)$/.exec(decoded);
511
+ if (options && source) {
512
+ if (!source[1])
513
+ index += 1;
514
+ continue;
515
+ }
516
+ if (options && /^-[evhV]+$/.test(decoded))
517
+ continue;
518
+ if (!options || !decoded.startsWith('-'))
519
+ client = null;
520
+ }
521
+ if (!CREDENTIAL_TOOLS.includes(client ?? '')) {
522
+ if (/^\w+\+?=/.test(decoded))
523
+ continue;
524
+ const wrapperValue = options
525
+ ? WRAPPER_VALUE_FLAGS[client ?? '']?.exec(decoded)
526
+ : null;
527
+ if (wrapperValue) {
528
+ if (wrapperValue[1] === undefined && wrapperValue[2] === undefined)
529
+ index += 1;
530
+ continue;
531
+ }
532
+ }
533
+ const executable = decoded.split(/[\\/]/).at(-1) ?? decoded;
534
+ if (WRAPPER_VALUE_FLAGS[executable] && !CREDENTIAL_TOOLS.includes(client ?? '')) {
535
+ client = executable;
536
+ options = true;
537
+ continue;
538
+ }
539
+ // Dispatching tools may take their own options and positional arguments.
540
+ // Once a credential-consuming tool appears, its subsequent flags still carry
541
+ // credentials; a wrapper's -u or timeout must not hide that client.
542
+ if (CREDENTIAL_TOOLS.includes(executable) &&
543
+ !CREDENTIAL_TOOLS.includes(client ?? '')) {
544
+ client = executable;
545
+ opensslPasswd = false;
546
+ opensslSubcommandRead = false;
547
+ options = true;
548
+ continue;
549
+ }
176
550
  if (client === null) {
177
551
  if (/^[\w]+=.*/.test(decoded) ||
178
552
  ['env', 'sudo', 'command', 'exec', 'nohup'].includes(decoded))
@@ -180,42 +554,292 @@ function redactHttpClientArguments(command) {
180
554
  client = decoded.split(/[\\/]/).at(-1) ?? decoded;
181
555
  continue;
182
556
  }
557
+ if (client === 'openssl' && !opensslSubcommandRead && !decoded.startsWith('-')) {
558
+ opensslSubcommandRead = true;
559
+ opensslPasswd = decoded === 'passwd';
560
+ continue;
561
+ }
183
562
  if (decoded === '--') {
184
563
  options = false;
185
564
  continue;
186
565
  }
187
- if (!options || !['curl', 'http', 'https', 'httpie'].includes(client))
566
+ if (client === 'curl' && options) {
567
+ // Public source/output arguments cannot become config flags by spelling.
568
+ const publicSource = /^(-[oAw])(.+)?$|^(--(?:output|output-dir|write-out|user-agent|cacert|capath))(?:=(.*))?$/.exec(decoded);
569
+ if (publicSource) {
570
+ if (publicSource[2] === undefined && publicSource[4] === undefined)
571
+ index += 1;
572
+ continue;
573
+ }
574
+ if (CURL_CONFIG_FLAG.test(decoded) && inputs)
575
+ inputs.curlConfig = true;
576
+ const variableFlag = /^--(?:expand-)?variable(?:=(.*))?$/.exec(decoded);
577
+ const expandFlag = CURL_EXPAND_FLAG.exec(decoded);
578
+ if (variableFlag || expandFlag) {
579
+ const attached = variableFlag ? variableFlag[1] : expandFlag[2];
580
+ const value = attached === undefined ? words[index + 1] : word;
581
+ if (!value || /^[;&|()\n]$/.test(value[0]))
582
+ continue;
583
+ const contents = attached ?? decodeShellWord(value[0]);
584
+ if (variableFlag) {
585
+ const definition = /^(%?)([A-Za-z0-9_]+)(?:\[[^\]]*\])?(?:=([\s\S]*)|@[^\s]*|$)/.exec(contents);
586
+ if (definition) {
587
+ const name = definition[2];
588
+ const entries = variables.get(name) ?? [];
589
+ entries.push({
590
+ start: value.index,
591
+ end: value.index + value[0].length,
592
+ prefix: (attached === undefined ? '' : `${decoded.split('=')[0]}=`) +
593
+ contents.slice(0, contents.indexOf('=') + 1),
594
+ contents: definition[3],
595
+ env: definition[1] === '%',
596
+ });
597
+ variables.set(name, entries);
598
+ }
599
+ }
600
+ else
601
+ expansions.push({
602
+ field: expandFlag[1],
603
+ contents,
604
+ resolved: resolveVariable(contents),
605
+ });
606
+ if (attached === undefined)
607
+ index += 1;
608
+ continue;
609
+ }
610
+ }
611
+ if (client === 'openssl' && options && /^-{1,2}kfile(?:=|$)/.test(decoded)) {
612
+ const attached = decoded.includes('=')
613
+ ? decoded.slice(decoded.indexOf('=') + 1)
614
+ : undefined;
615
+ const source = attached ?? words[index + 1]?.[0];
616
+ if (source)
617
+ inputs?.passSources.add(`file:${decodeShellWord(source)}`);
618
+ if (attached === undefined)
619
+ index += 1;
620
+ continue;
621
+ }
622
+ // Curl also accepts URLs without a scheme and uses their userinfo as
623
+ // credentials. Restrict this shape to HTTP-client arguments so image
624
+ // digests and SSH paths in other commands retain their meaning.
625
+ if (['curl', 'http', 'https', 'httpie'].includes(client) &&
626
+ (!options || !decoded.startsWith('-')) &&
627
+ PROXY_USERINFO.test(decoded)) {
628
+ edits.push({
629
+ start: word.index,
630
+ end: word.index + raw.length,
631
+ text: decoded.replace(PROXY_USERINFO, `$1${REDACTED}@`),
632
+ });
633
+ continue;
634
+ }
635
+ if (!options)
636
+ continue;
637
+ // Decode option spellings before classifying them: shell quotes and
638
+ // escapes change syntax, never the credential the invoked tool receives.
639
+ const sensitiveFlag = /^--([^=]+)(?:=(.*))?$/.exec(decoded);
640
+ if (sensitiveFlag && SENSITIVE_KEY.test(sensitiveFlag[1])) {
641
+ const attached = sensitiveFlag[2];
642
+ const value = attached === undefined ? words[index + 1] : word;
643
+ if (value && !/^[;&|()\n]$/.test(value[0])) {
644
+ edits.push({
645
+ start: value.index,
646
+ end: value.index + value[0].length,
647
+ text: attached === undefined ? REDACTED : `--${sensitiveFlag[1]}=${REDACTED}`,
648
+ });
649
+ if (attached === undefined)
650
+ index += 1;
651
+ }
652
+ continue;
653
+ }
654
+ // OpenSSL passphrase sources name their transport: pass: embeds the
655
+ // password; env/file/fd/stdin retain only a source reference. The enc
656
+ // -k/-K options carry a password/raw key directly, as does sshpass -p.
657
+ const directPasswordFlag = client === 'openssl'
658
+ ? /^(-{1,2}(?:pass(?:in|out)?|password|srppass|pwri_password|secretkey|hmac|[kK]))(?:=(.*))?$/.exec(decoded)
659
+ : client === 'sshpass'
660
+ ? /^(-[46v]*p)(.*)$/.exec(decoded)
661
+ : null;
662
+ if (directPasswordFlag) {
663
+ const name = directPasswordFlag[1];
664
+ const attached = client === 'sshpass' ? directPasswordFlag[2] || undefined : directPasswordFlag[2];
665
+ const value = attached === undefined ? words[index + 1] : word;
666
+ if (!value || /^[;&|()\n]$/.test(value[0]))
667
+ continue;
668
+ const password = attached ?? decodeShellWord(value[0]);
669
+ if (client === 'openssl' && inputs) {
670
+ if (/^env:[^=\0]+$/.test(password))
671
+ inputs.env.add(password.slice(4));
672
+ else if (/^(?:stdin|(?:file|fd):)/.test(password))
673
+ inputs.passSources.add(password);
674
+ }
675
+ if (client !== 'openssl' ||
676
+ /^(?:k|srppass|pwri_password|secretkey|hmac)$/i.test(name.replace(/^-+/, '')) ||
677
+ password.startsWith('pass:')) {
678
+ edits.push({
679
+ start: value.index,
680
+ end: value.index + value[0].length,
681
+ text: attached === undefined
682
+ ? REDACTED
683
+ : `${name}${client === 'openssl' ? '=' : ''}${REDACTED}`,
684
+ });
685
+ }
686
+ if (attached === undefined)
687
+ index += 1;
688
+ continue;
689
+ }
690
+ if (!['curl', 'http', 'https', 'httpie'].includes(client))
188
691
  continue;
692
+ const payloadFlag = client === 'curl' ? CURL_PAYLOAD_FLAG.exec(decoded) : null;
693
+ if (payloadFlag) {
694
+ const name = payloadFlag[1] ?? payloadFlag[3];
695
+ const attached = payloadFlag[2] ?? payloadFlag[4];
696
+ const value = attached === undefined ? words[index + 1] : word;
697
+ if (!value || /^[;&|()\n]$/.test(value[0]))
698
+ continue;
699
+ const payload = attached ?? decodeShellWord(value[0]);
700
+ const header = /^([^:]+):/.exec(payload);
701
+ const isHeader = ['--header', '--proxy-header'].includes(name) ||
702
+ (!name.startsWith('--') && name.endsWith('H'));
703
+ // JSON is handled structurally above. Form fields must also inspect
704
+ // percent-decoded names: passwords do not become public by encoding.
705
+ const formHasCredential = [...new URLSearchParams(payload).keys()].some((key) => SENSITIVE_KEY.test(key));
706
+ if (isHeader ? header && SENSITIVE_KEY.test(header[1].trim()) : formHasCredential) {
707
+ edits.push({
708
+ start: value.index,
709
+ end: value.index + value[0].length,
710
+ text: attached === undefined
711
+ ? REDACTED
712
+ : `${name}${name.startsWith('--') ? '=' : ''}${REDACTED}`,
713
+ });
714
+ }
715
+ if (attached === undefined)
716
+ index += 1;
717
+ continue;
718
+ }
189
719
  const flag = client === 'curl'
190
- ? /^(-[ub])(.+)?$|^(--(?:user|proxy-user|cookie))(?:=(.*))?$/.exec(decoded)
720
+ ? CURL_AUTH_FLAG.exec(decoded)
191
721
  : /^(-a)(.+)?$|^(--auth)(?:=(.*))?$/.exec(decoded);
192
722
  if (!flag)
193
723
  continue;
194
724
  const name = flag[1] ?? flag[3];
195
725
  const attached = flag[2] ?? flag[4];
726
+ const value = attached === undefined ? words[index + 1] : word;
727
+ if (!value || /^[;&|()\n]$/.test(value[0]))
728
+ continue;
729
+ let replacement = REDACTED;
730
+ if (CURL_URL_FIELD.test(name.replace(/^-+/, '')) ||
731
+ (!name.startsWith('--') && name.endsWith('x'))) {
732
+ const proxy = attached ?? decodeShellWord(value[0]);
733
+ if (!PROXY_USERINFO.test(proxy)) {
734
+ if (attached === undefined)
735
+ index += 1;
736
+ continue;
737
+ }
738
+ replacement = proxy.replace(PROXY_USERINFO, `$1${REDACTED}@`);
739
+ }
196
740
  if (attached !== undefined) {
197
741
  edits.push({
198
742
  start: word.index,
199
743
  end: word.index + raw.length,
200
- text: `${name}${name?.startsWith('--') ? '=' : ''}${REDACTED}`,
744
+ text: `${name}${name?.startsWith('--') ? '=' : ''}${replacement}`,
201
745
  });
202
746
  }
203
747
  else {
204
- const value = words[index + 1];
205
- if (!value || /^[;&|()\n]$/.test(value[0]))
206
- continue;
207
748
  edits.push({
208
749
  start: value.index,
209
750
  end: value.index + value[0].length,
210
- text: REDACTED,
751
+ text: replacement,
211
752
  });
212
753
  index += 1;
213
754
  }
214
755
  }
756
+ finishCurl();
757
+ edits.sort((a, b) => a.start - b.start);
215
758
  for (const edit of edits.reverse())
216
759
  command = command.slice(0, edit.start) + edit.text + command.slice(edit.end);
217
760
  return command;
218
761
  }
762
+ /** Find config consumers across shell wrappers before scanning their producers. */
763
+ function getToolInputs(command, depth = 0) {
764
+ const inputs = {
765
+ curlConfig: depth > 8,
766
+ env: new Set(),
767
+ passSources: new Set(),
768
+ };
769
+ if (depth > 8)
770
+ return inputs;
771
+ // Use the same invocation parser as redaction; an SSH -K is not curl config.
772
+ redactToolArguments(command, inputs);
773
+ const words = [...command.matchAll(new RegExp(SHELL_WORD_SOURCE, 'g'))];
774
+ for (const word of words) {
775
+ const decoded = decodeShellWord(word[0]);
776
+ if (decoded === word[0] || !/[\s;&|()]/.test(decoded))
777
+ continue;
778
+ const nested = getToolInputs(decoded, depth + 1);
779
+ inputs.curlConfig ||= nested.curlConfig;
780
+ for (const key of nested.env)
781
+ inputs.env.add(key);
782
+ for (const source of nested.passSources)
783
+ inputs.passSources.add(source);
784
+ }
785
+ if (inputs.curlConfig) {
786
+ for (const word of words) {
787
+ const body = decodeShellWord(word[0]);
788
+ if (!hasPrivateConfigVariables(body))
789
+ continue;
790
+ for (const match of body.matchAll(/\b(?:expand-)?variable\s*[=: \t]+["']?%+([A-Za-z0-9_]+)/g))
791
+ inputs.env.add(match[1]);
792
+ }
793
+ }
794
+ return inputs;
795
+ }
796
+ /** Apply the same boundary to scripts carried inside quoted shell words. */
797
+ function redactShellSyntax(command, depth = 0, inheritedInputs) {
798
+ // Nested shell/eval wrappers cannot create unbounded recursive scanning.
799
+ if (depth > 8)
800
+ return REDACTED;
801
+ command = redactCommandSubstitutions(command);
802
+ // Nested scripts may be dispatched by any tool. Scan decoded compound
803
+ // words instead of maintaining another list of interpreter/wrapper flags.
804
+ // JSON and sensitive assignments already have structural/whole-word passes;
805
+ // treating their values as commands would lose evidence or word boundaries.
806
+ const inputs = getToolInputs(command);
807
+ inputs.curlConfig ||= inheritedInputs?.curlConfig ?? false;
808
+ for (const key of inheritedInputs?.env ?? [])
809
+ inputs.env.add(key);
810
+ for (const source of inheritedInputs?.passSources ?? [])
811
+ inputs.passSources.add(source);
812
+ const sensitiveAssignment = new RegExp(`^(${COMMAND_SENSITIVE_KEY_SOURCE})(\\+?=)`, 'i');
813
+ // Resolve credential references before nested word redaction can erase the
814
+ // expansion marker while leaving its original variable definition behind.
815
+ let out = redactToolArguments(redactJsonArguments(command), inputs).replace(new RegExp(SHELL_WORD_SOURCE, 'g'), (word) => {
816
+ const decoded = decodeShellWord(word);
817
+ // A compound word beginning with a credential assignment may be either
818
+ // an env value or a whole script. Its entire value is private in both
819
+ // cases; dropping it avoids guessing which later spans belong to it.
820
+ const assignment = sensitiveAssignment.exec(decoded);
821
+ if (assignment)
822
+ return `${assignment[1]}${assignment[2]}${REDACTED}`;
823
+ if (decoded === word || !/[\s;&|()]/.test(decoded))
824
+ return word;
825
+ if (/^\s*[[{]/.test(decoded)) {
826
+ try {
827
+ JSON.parse(decoded);
828
+ return word;
829
+ }
830
+ catch {
831
+ // A shell group begins with '{' too; only valid JSON belongs to the
832
+ // structural JSON pass instead of the nested-command pass.
833
+ }
834
+ }
835
+ const redacted = redactShellSyntax(decoded, depth + 1, inputs);
836
+ return redacted === decoded ? word : JSON.stringify(redacted);
837
+ });
838
+ out = redactCredentialAssignments(out, inputs);
839
+ for (const [pattern, replacement] of COMMAND_REDACTIONS)
840
+ out = out.replace(pattern, replacement);
841
+ return out;
842
+ }
219
843
  /**
220
844
  * Redact common secret shapes from a shell command and cap its length so
221
845
  * command text never carries credentials or bulk file content off-box.
@@ -225,10 +849,7 @@ function redactHttpClientArguments(command) {
225
849
  export function sanitizeCommand(command) {
226
850
  // Shell continuations disappear before tokenization; otherwise a flag's
227
851
  // value can be mistaken for the continuation itself and escape redaction.
228
- let out = redactHttpClientArguments(redactJsonArguments(command.replace(/\\\r?\n/g, '')));
229
- for (const [pattern, replacement] of COMMAND_REDACTIONS) {
230
- out = out.replace(pattern, replacement);
231
- }
852
+ let out = redactShellSyntax(command.replace(/\\\r?\n/g, ''));
232
853
  if (out.length > MAX_COMMAND_CHARS) {
233
854
  out = `${out.slice(0, MAX_COMMAND_CHARS)} [... truncated, ${out.length - MAX_COMMAND_CHARS} characters omitted]`;
234
855
  }