@mossbear/eval-core 0.1.0-alpha.105 → 0.1.0-alpha.107

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.
@@ -269,6 +269,32 @@ function withInheritedInputs(inputs, inherited) {
269
269
  * redacted as a command in its own right. An escaped backtick is literal text;
270
270
  * `invokedTool` still reads the tool its word names.
271
271
  */
272
+ /** Whether an unescaped double quote closes the one open at `from`. */
273
+ function closesQuote(command, from) {
274
+ for (let index = from; index < command.length; index += 1) {
275
+ if (command[index] === '\\')
276
+ index += 1;
277
+ else if (command[index] === '"')
278
+ return true;
279
+ }
280
+ return false;
281
+ }
282
+ const VALUE_AFTER_BACKTICK = new RegExp(`([ \\t]*)(${SHELL_WORD_SOURCE})`, 'y');
283
+ /**
284
+ * Whether `body` ends in a credential flag, or a word its chain takes as a
285
+ * flag's value, that `redacted` no longer ends in: a rewrite took the flag
286
+ * whose value comes after the body.
287
+ */
288
+ function endsChainLost(body, redacted) {
289
+ const last = (text) => text.match(SHELL_WORDS)?.at(-1);
290
+ const before = last(body);
291
+ const after = last(redacted);
292
+ return (redacted !== body &&
293
+ before !== undefined &&
294
+ !/\s$/.test(body) &&
295
+ continuesCredentialChain(before) &&
296
+ !(after !== undefined && continuesCredentialChain(after)));
297
+ }
272
298
  function redactCommandSubstitutions(command, depth = 0, inheritedInputs, valuesFirst = true) {
273
299
  if (depth > 8)
274
300
  return REDACTED;
@@ -336,6 +362,8 @@ function redactCommandSubstitutions(command, depth = 0, inheritedInputs, valuesF
336
362
  let quote = null;
337
363
  let out = '';
338
364
  let substitutions = 0;
365
+ // Where in `out` the open double quote began.
366
+ let quoteOpenedAt = 0;
339
367
  for (let index = 0; index < command.length; index += 1) {
340
368
  const char = command[index];
341
369
  if (char === '\\' && quote !== "'") {
@@ -361,10 +389,30 @@ function redactCommandSubstitutions(command, depth = 0, inheritedInputs, valuesF
361
389
  return `${out}\`${REDACTED}`;
362
390
  const body = unescapeBacktickBody(command.slice(bodyStart, index), quote === '"');
363
391
  const redacted = redactShellSyntax(body, depth + 1, surroundingInputs(), valuesFirst);
392
+ // Rewriting a body inside a double quote that never closes changes
393
+ // how the rest of that quote splits into words, which can move a
394
+ // credential argument's extent: fail closed, as for an unterminated
395
+ // backtick.
396
+ if (redacted !== body && quote === '"' && !closesQuote(command, index + 1))
397
+ return `${out.slice(0, quoteOpenedAt)}${REDACTED}`;
364
398
  out +=
365
399
  redacted === body
366
400
  ? original
367
401
  : `\`${escapeBacktickBody(redacted, quote === '"')}${unterminated ? '' : '`'}`;
402
+ // A body that ends in a credential flag hands its value to the word
403
+ // after the backtick (`\x60… --token\x60value`). Where the rewrite took
404
+ // that flag, as a chain value it reads, no later pass sees it, so
405
+ // that word is redacted here.
406
+ if (!unterminated && quote === null && endsChainLost(body, redacted)) {
407
+ VALUE_AFTER_BACKTICK.lastIndex = index + 1;
408
+ const value = VALUE_AFTER_BACKTICK.exec(command);
409
+ if (value &&
410
+ !/[\x60]/.test(value[2]) &&
411
+ command[VALUE_AFTER_BACKTICK.lastIndex] !== '(') {
412
+ out += `${value[1]}${REDACTED}`;
413
+ index = VALUE_AFTER_BACKTICK.lastIndex - 1;
414
+ }
415
+ }
368
416
  }
369
417
  else if (original.includes('`')) {
370
418
  // `(` already exposes this body to the outer parse; only backticks
@@ -378,8 +426,11 @@ function redactCommandSubstitutions(command, depth = 0, inheritedInputs, valuesF
378
426
  }
379
427
  if (char === "'" && quote !== '"')
380
428
  quote = quote === "'" ? null : "'";
381
- else if (char === '"' && quote !== "'")
429
+ else if (char === '"' && quote !== "'") {
430
+ if (quote === null)
431
+ quoteOpenedAt = out.length;
382
432
  quote = quote === '"' ? null : '"';
433
+ }
383
434
  out += char;
384
435
  }
385
436
  return out;
@@ -413,7 +464,7 @@ function redactJsonValue(value, depth = 0) {
413
464
  ]));
414
465
  }
415
466
  function redactJsonArguments(command) {
416
- return command.replace(new RegExp(SHELL_WORD_SOURCE, 'g'), (argument) => {
467
+ const redactArgument = (argument) => {
417
468
  // Shell double quotes only consume escapes for these characters. JSON's
418
469
  // string decoder rejects valid shell escapes such as \$, so decode the
419
470
  // shell layer before parsing the request body.
@@ -434,7 +485,23 @@ function redactJsonArguments(command) {
434
485
  return argument;
435
486
  return argument.startsWith("'") ? `'${REDACTED}'` : `"${REDACTED}"`;
436
487
  }
437
- });
488
+ };
489
+ // Dropping a word that a substitution closed onto a credential flag
490
+ // (`[a]` then a backtick and `--token`) hides the flag from the
491
+ // space-separated rule, so its value is followed here, as for every other
492
+ // edit that rewrites a word.
493
+ const next = textChainCursor(command);
494
+ const chained = new Set();
495
+ const ranges = [];
496
+ for (const match of command.matchAll(new RegExp(SHELL_WORD_SOURCE, 'g'))) {
497
+ const replaced = redactArgument(match[0]);
498
+ if (replaced === match[0])
499
+ continue;
500
+ const end = match.index + match[0].length;
501
+ ranges.push({ start: match.index, end, text: replaced });
502
+ ranges.push(...followCredentialChain(match[0], end, next, chained, command));
503
+ }
504
+ return applyRanges(command, ranges);
438
505
  }
439
506
  /**
440
507
  * Credential values recognised by the name beside them (URL userinfo,
@@ -443,13 +510,122 @@ function redactJsonArguments(command) {
443
510
  const VALUE_REDACTIONS = [
444
511
  [/\b([a-z][a-z0-9+.-]*:\/\/[^/\s:@]+:)[^@\s]+@/gi, `$1${REDACTED}@`],
445
512
  [/\b([a-z][a-z0-9+.-]*:\/\/)[^/\s:@]+@/gi, `$1${REDACTED}@`],
446
- [/\b(authorization\s*:\s*)(?:(?:bearer|basic|token)\s+)?\S+/gi, `$1${REDACTED}`],
513
+ // The value never runs on into the next header's name (`…v"|"Authorization:`).
514
+ [
515
+ /\b(authorization\s*:\s*)(?:(?:bearer|basic|token)\s+)?(?:(?!\b(?:authorization|set-cookie|cookie)\s*:)\S)+/gi,
516
+ `$1${REDACTED}`,
517
+ ],
447
518
  [new RegExp(COOKIE_HEADER_SOURCE, 'gi'), `$1${REDACTED}`],
448
519
  [
449
520
  new RegExp(`([?&#]${COMMAND_SENSITIVE_KEY_SOURCE}=)[^&\\s"'<>]+`, 'gi'),
450
521
  `$1${REDACTED}`,
451
522
  ],
452
523
  ];
524
+ // One piece of the text's quote structure, or one character that is not.
525
+ const QUOTE_STRUCTURE = /\\['"\x60]|\\$|\$'|['"\x60]|\\[\s\S]|[\s\S]/g;
526
+ /**
527
+ * `match.replace(single, replacement)` for a match of a `VALUE_REDACTIONS`
528
+ * pattern, keeping the quote structure of the text it removes. A value can
529
+ * take a quote that closes the word it matched in (`\S+` takes `v'` from
530
+ * `'Authorization: Bearer v'`), and dropping that quote pairs every later
531
+ * quote differently, which moves the words the rules after this one read.
532
+ * So each quote and backtick in the removed text stays, after the marker, as
533
+ * do the escapes that decide whether one is literal (`\'`, a backslash that
534
+ * escapes the character after the match, and the `$` of `$'`). Nothing else
535
+ * of the value survives.
536
+ */
537
+ function redactValueMatch(match, single, replacement) {
538
+ const redacted = match.replace(single, replacement);
539
+ let start = 0;
540
+ while (start < match.length && match[start] === redacted[start])
541
+ start += 1;
542
+ let end = 0;
543
+ while (end < Math.min(match.length, redacted.length) - start &&
544
+ match[match.length - 1 - end] === redacted[redacted.length - 1 - end])
545
+ end += 1;
546
+ const structure = match
547
+ .slice(start, match.length - end)
548
+ .replace(QUOTE_STRUCTURE, (piece) => /^(?:['"\x60]|\\['"\x60]?|\$')$/.test(piece) ? piece : '');
549
+ return (redacted.slice(0, redacted.length - end) +
550
+ structure +
551
+ redacted.slice(redacted.length - end));
552
+ }
553
+ /**
554
+ * A reader of the quote open at a position of `text`, pairing quotes from the
555
+ * start of `text` as the shell does. Positions must not decrease.
556
+ */
557
+ function quoteReader(text) {
558
+ let position = 0;
559
+ let quote = null;
560
+ return (index) => {
561
+ for (; position < index; position += 1) {
562
+ const char = text[position];
563
+ if (quote === "'") {
564
+ if (char === "'")
565
+ quote = null;
566
+ }
567
+ else if (char === '\\')
568
+ position += 1;
569
+ else if (char === '"')
570
+ quote = quote === '"' ? null : '"';
571
+ else if (char === "'" && quote === null)
572
+ quote = "'";
573
+ }
574
+ return quote;
575
+ };
576
+ }
577
+ /** Where in `text` the open `quote` closes, or -1. */
578
+ function closingQuote(text, quote) {
579
+ for (let index = 0; index < text.length; index += 1) {
580
+ if (quote === '"' && text[index] === '\\')
581
+ index += 1;
582
+ else if (text[index] === quote)
583
+ return index;
584
+ }
585
+ return -1;
586
+ }
587
+ /**
588
+ * The edits one `VALUE_REDACTIONS` pattern makes to `text`. A value stops at
589
+ * a quote that closes the word it matched in, as the shell pairs quotes, when
590
+ * the word ends there: what follows is the next header or command, or the
591
+ * command around a substitution's body (`'…: v';'Cookie: …'`, `"…: v"\x60`),
592
+ * never the value, and a value pattern that ran on into it would take the
593
+ * header or flag there with it. Text glued after the quote is the same word,
594
+ * so the value runs on through it. A match the cut leaves the pattern no
595
+ * longer matching is kept whole.
596
+ */
597
+ // The URL patterns read a run of scheme characters from every word boundary
598
+ // in it, which is quadratic on a long hyphenated word, and can only match
599
+ // where the text spells `://`.
600
+ const NEEDS_URL_SCHEME = VALUE_REDACTIONS.map(([pattern]) => pattern.source.includes(String.raw `:\/\/`));
601
+ function valueRedactionRanges(text, index) {
602
+ const [pattern, replacement] = VALUE_REDACTIONS[index];
603
+ const single = VALUE_PATTERNS[index];
604
+ if (NEEDS_URL_SCHEME[index] && !text.includes('://'))
605
+ return [];
606
+ const quoteAt = quoteReader(text);
607
+ const ranges = [];
608
+ pattern.lastIndex = 0;
609
+ for (let match = pattern.exec(text); match; match = pattern.exec(text)) {
610
+ let matched = match[0];
611
+ const quote = quoteAt(match.index);
612
+ const closing = quote === null ? -1 : closingQuote(matched, quote);
613
+ if (closing > 0 &&
614
+ /^(?:[\s;&|()<>\x60]|$)/.test(text.slice(match.index + closing + 1)) &&
615
+ single.test(matched.slice(0, closing))) {
616
+ matched = matched.slice(0, closing);
617
+ pattern.lastIndex = match.index + closing;
618
+ }
619
+ ranges.push({
620
+ start: match.index,
621
+ end: match.index + matched.length,
622
+ text: redactValueMatch(matched, single, replacement),
623
+ });
624
+ }
625
+ return ranges;
626
+ }
627
+ /** Apply each `VALUE_REDACTIONS` pattern to `text` through `valueRedactionRanges`. */
628
+ const VALUE_REDACTION_PASSES = VALUE_REDACTIONS.map((_, index) => (text) => applyRanges(text, valueRedactionRanges(text, index)));
453
629
  /**
454
630
  * Space-separated credential flags have no assignment operator or token
455
631
  * prefix. Keep the flag for evidence, but never its value. The value is the
@@ -460,20 +636,22 @@ const VALUE_REDACTIONS = [
460
636
  * That reach means the value can itself be a credential flag
461
637
  * (`--password --token <value>`). Taking it as the first flag's value must not
462
638
  * spare its own value, so the word after a swallowed credential flag is
463
- * redacted too, for as long as the chain runs. A chained word that spells a
464
- * credential key is left for the passes after this one to read (see
465
- * `isChainedValue`), and one with an attached value ends the chain. Every
639
+ * redacted too, for as long as the chain runs. A chained word that is a bare
640
+ * credential flag, or ends in a key or header whose value is the next word, is
641
+ * left for the passes after this one to read (see `handsValueOn`), and one
642
+ * with an attached value ends the chain. Every
466
643
  * match the rule makes on its own is still redacted: the chain only adds.
467
644
  */
468
- const CREDENTIAL_FLAG = new RegExp(`(^|[\\s;&|(\\x60])(--${COMMAND_SENSITIVE_KEY_SOURCE}\\s+)(${SHELL_WORD_SOURCE})`, 'gi');
469
- const NEXT_SHELL_WORD = new RegExp(`(\\s+)(${SHELL_WORD_SOURCE})`, 'y');
645
+ const CREDENTIAL_FLAG = new RegExp(`(^|[\\s;&|(\\x60])(--${COMMAND_SENSITIVE_KEY_SOURCE}(?:[\\s)\\x60]|\\$\\()+)(${SHELL_WORD_SOURCE})`, 'gi');
646
+ // The next word, across a backtick, `)` or `$(` that cuts a flag from its value.
647
+ const NEXT_SHELL_WORD = new RegExp(`((?:[\\s)\\x60]|\\$\\()+)(${SHELL_WORD_SOURCE})`, 'y');
648
+ const SHELL_WORDS = new RegExp(SHELL_WORD_SOURCE, 'g');
470
649
  // The words a credential key is spelled with. Keys are tested by scanning a
471
650
  // word's `[\w.-]` runs for one rather than by a regex with `[\w.-]*` on both
472
651
  // sides of the key, which backtracks quadratically on a long word.
473
652
  const CREDENTIAL_KEY_WORD = new RegExp(`${SENSITIVE_WORD_SOURCE}|SSHPASS|REDISCLI_AUTH|VALKEYCLI_AUTH|MYSQL_PWD`, 'i');
474
653
  const ASSIGNMENT_OPERATOR = /\s*[=:]/y;
475
654
  const LINE_CONTINUATION = /^\\\r?\n$/;
476
- const KEY_CHARACTER = /[\w.-]/;
477
655
  /** `SENSITIVE_KEY`'s test, without its backtracking on a long name. */
478
656
  function isCredentialKeyName(name) {
479
657
  return ((/^[\w.-]+$/.test(name) && SENSITIVE_CONTENT.test(name)) ||
@@ -511,50 +689,129 @@ function spellsCredentialKey(text) {
511
689
  }
512
690
  return false;
513
691
  }
692
+ // One piece of a shell word: a quoted string, an escape, a run of plain word
693
+ // characters, or one other character. A flag inside a run follows a word
694
+ // character, which never starts a word, so only a run's start can open one.
695
+ const WORD_PIECES = /\$'(?:\\[\s\S]|[^'\\])*'|'[^']*'|"(?:\\.|[^"\\])*"|\\[\s\S]|[\w.-]+|[\s\S]/g;
696
+ // A piece a flag glued after still starts a word at: a quoted string, or a
697
+ // blank, operator, backtick, quote or backslash, escaped or not.
698
+ const FLAG_BOUNDARY_PIECE = /^(?:\$?'[\s\S]*'|"[\s\S]*"|\\?[\s;&|(\x60'"\\])$/;
514
699
  /**
515
- * Whether `word` ends in a credential flag the space-separated rule reads on
516
- * its own: it reads boundaries in the raw text, so `\<tab>--token` (an escaped
517
- * tab) ends in one.
700
+ * Whether `word` ends in a credential flag: a run of its pieces that decodes
701
+ * to `--` and a credential key, at the start of the word, opening with a
702
+ * quote, or after a piece that starts one there. That reads `\<tab>--token` (an escaped tab) and
703
+ * `"a"--token` (glued after a quoted string, which the shell joins into one
704
+ * word) as ending in a flag, with quotes inside the flag (`--t''oken`) read
705
+ * through. A substitution a pass replaced with the redaction marker still
706
+ * ends where its backtick did, so a flag after the marker starts a word as
707
+ * it did.
518
708
  */
709
+ // Longer than any credential key `CREDENTIAL_KEY_WORD` matches.
710
+ const KEY_WINDOW = 32;
519
711
  function endsInCredentialFlag(word) {
712
+ const pieces = word.match(WORD_PIECES) ?? [];
713
+ // The decoded suffix is read one piece at a time, front first, without
714
+ // being rebuilt: a long word would otherwise cost time quadratic in its
715
+ // length on the hook path. `head` keeps the suffix's first characters,
716
+ // enough to hold any credential key a newly read piece could start.
717
+ let head = '';
718
+ let hasKey = false;
520
719
  let start = word.length;
521
- while (start > 0 && KEY_CHARACTER.test(word[start - 1]))
522
- start -= 1;
523
- if (!word.startsWith('--', start))
524
- return false;
525
- if (start > 0 && !/[\s;&|(\x60]/.test(word[start - 1]))
526
- return false;
527
- return CREDENTIAL_KEY_WORD.test(word.slice(start + 2));
528
- }
529
- /**
530
- * Whether to redact a word the chain reached. The other rules read credential
531
- * keys in the raw text and, for nested commands, in decoded words, so a word
532
- * that spells one either way (a bare credential flag among them) is left whole
533
- * for them to read, exactly as it reads without the chain.
534
- */
535
- function isChainedValue(word) {
536
- return ![word, decodeShellWord(word)].some((text) => spellsCredentialKey(text) || startsValueHeader(text));
720
+ for (let index = pieces.length - 1; index >= 0; index -= 1) {
721
+ const piece = pieces[index];
722
+ start -= piece.length;
723
+ const decoded = decodeShellWord(piece);
724
+ if (!/^[\w.-]*$/.test(decoded))
725
+ return false;
726
+ const joined = decoded + head;
727
+ hasKey ||= CREDENTIAL_KEY_WORD.test(joined);
728
+ head = joined.slice(0, KEY_WINDOW);
729
+ // A key starts with a letter, so a suffix that opens with `--` holds a
730
+ // key after the dashes exactly when it holds one at all.
731
+ if (!head.startsWith('--') || !hasKey)
732
+ continue;
733
+ if (index === 0 ||
734
+ /^(?:\$?'|")/.test(piece) ||
735
+ FLAG_BOUNDARY_PIECE.test(pieces[index - 1]) ||
736
+ word.endsWith(REDACTED, start))
737
+ return true;
738
+ }
739
+ return false;
537
740
  }
538
741
  // `VALUE_REDACTIONS` without the global flag, for testing a single word.
539
742
  const VALUE_PATTERNS = VALUE_REDACTIONS.map(([pattern]) => new RegExp(pattern.source, pattern.flags.replace('g', '')));
540
743
  /**
541
744
  * Whether `text` begins a match of a value pattern that runs on into the next
542
745
  * word, as a header name does (`Cookie: name=value`). Redacting that word
543
- * would hide the value from the pattern, so the chain leaves it whole. The
544
- * test is read off `VALUE_REDACTIONS` itself, so a pattern added later is
545
- * covered without a list of header names here.
746
+ * would hide the value from the pattern, so the chain leaves it whole. Where
747
+ * no word follows (`next` is undefined: an operator or the end), no pattern
748
+ * can read on, so the word is not left. The test is read off
749
+ * `VALUE_REDACTIONS` itself, so a pattern added later is covered without a
750
+ * list of header names here.
546
751
  */
547
- function startsValueHeader(text) {
548
- return VALUE_PATTERNS.some((pattern) => [' name=value', ' value'].some((next) => {
549
- const match = pattern.exec(text + next);
752
+ function startsValueHeader(word, next) {
753
+ if (next === undefined)
754
+ return false;
755
+ // Only the word's tail and the next word's head are read: what a pattern
756
+ // takes before the value is a header name, its separator and a scheme, and
757
+ // crossing into the next word takes a few characters of it. Testing every
758
+ // pattern against a whole long word costs time quadratic in its length for
759
+ // each word of a chain.
760
+ const text = word.slice(-VALUE_HEADER_TAIL);
761
+ return VALUE_PATTERNS.some((pattern) => [
762
+ ' name=value',
763
+ ' value',
764
+ ...(next ? [` ${next.slice(0, VALUE_HEADER_TAIL)}`] : []),
765
+ ].some((following) => {
766
+ const match = pattern.exec(text + following);
550
767
  return (match !== null &&
551
768
  match.index < text.length &&
552
769
  match.index + match[0].length > text.length);
553
770
  }));
554
771
  }
772
+ const VALUE_HEADER_TAIL = 256;
773
+ /**
774
+ * Whether `word` ends in a credential key or header whose value is the word
775
+ * after it (`a'Cookie:` then `s=v`, `xAuthorization:` then `v`), so
776
+ * redacting `word` would hide the value from the rule that reads the pair.
777
+ */
778
+ function handsValueOn(word, after) {
779
+ return [
780
+ [word, after],
781
+ [decodeShellWord(word), after === undefined ? undefined : decodeShellWord(after)],
782
+ ].some(([text, next]) => {
783
+ if (startsValueHeader(text, next))
784
+ return true;
785
+ const key = trailingKeyAssignment(text);
786
+ return key !== undefined && next !== undefined && spellsCredentialKey(key);
787
+ });
788
+ }
789
+ /**
790
+ * The `key=` or `key:` that `text` ends in, read back from its end: an
791
+ * unanchored `/[\w.-]+\s*[=:]\s*$/` re-reads a long run of word characters
792
+ * from every position in it, which is quadratic in the run's length.
793
+ */
794
+ function trailingKeyAssignment(text) {
795
+ let end = text.length;
796
+ while (end > 0 && /\s/.test(text[end - 1]))
797
+ end -= 1;
798
+ if (end === 0 || !/[=:]/.test(text[end - 1]))
799
+ return undefined;
800
+ let start = end - 1;
801
+ while (start > 0 && /\s/.test(text[start - 1]))
802
+ start -= 1;
803
+ const nameEnd = start;
804
+ while (start > 0 && /[\w.-]/.test(text[start - 1]))
805
+ start -= 1;
806
+ return start === nameEnd ? undefined : text.slice(start);
807
+ }
555
808
  /** Whether the word after `word` is a credential flag's value. */
556
809
  function continuesCredentialChain(word) {
557
- return isBareCredentialFlag(word) || endsInCredentialFlag(word);
810
+ // Decoded too: quotes hide a flag glued after a substitution or the
811
+ // redaction marker that replaced one (`[redacted]"--passwd"`).
812
+ return (isBareCredentialFlag(word) ||
813
+ endsInCredentialFlag(word) ||
814
+ endsInCredentialFlag(decodeShellWord(word)));
558
815
  }
559
816
  /**
560
817
  * The words to redact after `value`, a word ending at `end` that a rule has
@@ -565,7 +822,7 @@ function continuesCredentialChain(word) {
565
822
  * the word here, whatever flag or tool took it. `seen` holds the ends a chain
566
823
  * has already left, so a run of flags stays linear.
567
824
  */
568
- function followCredentialChain(value, end, next, seen) {
825
+ function followCredentialChain(value, end, next, seen, text) {
569
826
  const ranges = [];
570
827
  for (let word = value; continuesCredentialChain(word);) {
571
828
  if (seen.has(end))
@@ -576,14 +833,29 @@ function followCredentialChain(value, end, next, seen) {
576
833
  break;
577
834
  if (isAttachedCredentialFlag(following.word))
578
835
  break;
579
- if (isChainedValue(following.word))
836
+ if (!isBareCredentialFlag(following.word) &&
837
+ !handsValueOn(following.word, wordAfter(text, following.end, next)))
580
838
  ranges.push({ start: following.start, end: following.end, text: REDACTED });
581
839
  word = following.word;
582
840
  end = following.end;
583
841
  }
584
842
  return ranges;
585
843
  }
586
- /** Steps through `text` by shell word: a chain ends at an operator. */
844
+ /**
845
+ * The word after position `end` for `startsValueHeader`: undefined where an
846
+ * operator follows, so no pattern can read on, and empty at the end of the
847
+ * text, which may be a nested word the enclosing command continues after.
848
+ */
849
+ function wordAfter(text, end, next) {
850
+ const following = next(end);
851
+ if (following)
852
+ return following.word;
853
+ return text.slice(end).trim() === '' ? '' : undefined;
854
+ }
855
+ /**
856
+ * Steps through `text` by shell word: a chain ends at an operator, but not at
857
+ * a backtick, `)` or `$(`, which cut a flag from its value without ending it.
858
+ */
587
859
  function textChainCursor(text) {
588
860
  return (end) => {
589
861
  NEXT_SHELL_WORD.lastIndex = end;
@@ -635,7 +907,7 @@ function replaceValues(text, pattern, replace, after = '') {
635
907
  if (replaced === match[0])
636
908
  continue;
637
909
  ranges.push({ start: match.index, end, text: replaced });
638
- for (const range of followCredentialChain(match.at(-1) ?? '', end, next, seen)) {
910
+ for (const range of followCredentialChain(match.at(-1) ?? '', end, next, seen, text + after)) {
639
911
  if (range.start < text.length)
640
912
  ranges.push(range);
641
913
  else
@@ -648,7 +920,27 @@ function replaceValues(text, pattern, replace, after = '') {
648
920
  }
649
921
  return { text: applyRanges(text, ranges), after: beyond };
650
922
  }
923
+ /**
924
+ * `credentialFlagValueRanges` of `command` read without the quote at `quote`,
925
+ * from `from`, the start of the word before it, as ranges of `command`.
926
+ */
927
+ function withoutUnpairedQuote(command, from, quote, depth) {
928
+ if (depth >= 8)
929
+ return [];
930
+ const at = (position, isEnd) => from +
931
+ position +
932
+ (position > quote - from || (!isEnd && position === quote - from) ? 1 : 0);
933
+ return credentialFlagValueRanges(command.slice(from, quote) + command.slice(quote + 1), depth + 1).map((range) => ({
934
+ start: at(range.start, false),
935
+ end: at(range.end, true),
936
+ text: range.text,
937
+ }));
938
+ }
651
939
  function redactCredentialFlagValues(command) {
940
+ return applyRanges(command, credentialFlagValueRanges(command));
941
+ }
942
+ /** The ranges `redactCredentialFlagValues` redacts in `command`. */
943
+ function credentialFlagValueRanges(command, depth = 0) {
652
944
  const next = textChainCursor(command);
653
945
  const ranges = [];
654
946
  // A chain runs the same from any word it reaches, so one that reaches a word
@@ -656,10 +948,64 @@ function redactCredentialFlagValues(command) {
656
948
  const chained = new Set();
657
949
  for (const match of command.matchAll(CREDENTIAL_FLAG)) {
658
950
  const end = match.index + match[1].length + match[2].length + match[3].length;
951
+ // A value cut from its flag by a backtick or `)`, or read past an
952
+ // unterminated quote (where the text may be a nested word whose header
953
+ // runs on into the next word of the enclosing command), is less surely
954
+ // the flag's: one that ends in a key or header whose value is the next
955
+ // word is left whole for the rule that reads that pair.
956
+ if ((depth > 0 || /[)\x60]|\$\(/.test(match[2])) &&
957
+ handsValueOn(match[3], wordAfter(command, end, next)))
958
+ continue;
659
959
  ranges.push({ start: end - match[3].length, end, text: REDACTED });
660
- ranges.push(...followCredentialChain(match[3], end, next, chained));
960
+ ranges.push(...followCredentialChain(match[3], end, next, chained, command));
661
961
  }
662
- return applyRanges(command, ranges);
962
+ // A quote or escape in a flag (`'--token'`, `\--token`) hides it from the
963
+ // pattern above, though the shell passes the tool the same flag. Read each
964
+ // word as the shell decodes it, so the flag takes its value wherever it
965
+ // stands, including where a command starts, which the per-tool walker
966
+ // reads as the program's name.
967
+ let wordEnd = 0;
968
+ let wordStart = 0;
969
+ for (const word of command.matchAll(SHELL_WORDS)) {
970
+ // A quote between words opens a string that never closes (a word would
971
+ // have taken a closed one). The shell fails on it, so which quotes pair
972
+ // is a guess: the unpaired one may be the second half of a flag's own
973
+ // `''`, or close what the word before it opened. What was typed there is
974
+ // still what a person meant, so the text is read again without that
975
+ // quote, from the word before it, and a credential flag keeps its value.
976
+ const gap = command.slice(wordEnd, word.index);
977
+ const open = gap.search(/['"]/);
978
+ if (open !== -1) {
979
+ ranges.push(...withoutUnpairedQuote(command, wordStart, wordEnd + open, depth));
980
+ break;
981
+ }
982
+ wordStart = word.index;
983
+ wordEnd = word.index + word[0].length;
984
+ if (word.index > 0 && !/[\s;&|()]/.test(command[word.index - 1]))
985
+ continue;
986
+ // A substitution a pass replaced with the redaction marker ends where its
987
+ // backtick did, so a flag glued after either starts a word, and a
988
+ // backtick or `)` after a flag cuts it from its value without ending
989
+ // it. A flag glued after a quoted string or an escaped blank
990
+ // (`"a "--token`, `a\ --token`) is the end of a word the chain reads as
991
+ // a credential flag, so it is read as one here too.
992
+ const flag = word[0].replace(/^(?:\x60|\[redacted\])+/, '').replace(/\x60+$/, '');
993
+ if (decodeShellWord(flag) === flag ||
994
+ !continuesCredentialChain(word[0].replace(/\x60+$/, '')))
995
+ continue;
996
+ const end = word.index + word[0].length;
997
+ const value = next(end);
998
+ // A value that spells a credential key or starts a header is left whole
999
+ // for the rules that read one, as the chain leaves it; so is one that
1000
+ // opens a backtick substitution, which the substitution passes read.
1001
+ if (!value ||
1002
+ value.word.includes('\x60') ||
1003
+ handsValueOn(value.word, wordAfter(command, value.end, next)))
1004
+ continue;
1005
+ ranges.push({ start: value.start, end: value.end, text: REDACTED });
1006
+ ranges.push(...followCredentialChain(value.word, value.end, next, chained, command));
1007
+ }
1008
+ return ranges;
663
1009
  }
664
1010
  function applyCommandRedactions(command) {
665
1011
  let out = command;
@@ -689,7 +1035,7 @@ const COMMAND_REDACTIONS = [
689
1035
  `$1$2$3 ${REDACTED}$4`,
690
1036
  ],
691
1037
  [new RegExp(PRIVATE_KEY_BLOCK_SOURCE, 'g'), REDACTED],
692
- ...VALUE_REDACTIONS,
1038
+ ...VALUE_REDACTION_PASSES,
693
1039
  redactCredentialFlagValues,
694
1040
  // JSON bodies embedded in shell commands are still command text. Quoted
695
1041
  // keys evade the shell-assignment scanner below, including escaped values.
@@ -1281,7 +1627,7 @@ function redactToolArguments(command, inputs) {
1281
1627
  const word = at === undefined ? undefined : words[at];
1282
1628
  if (!word || edit.end !== word.index + word[0].length)
1283
1629
  return [];
1284
- return followCredentialChain(word[0], edit.end, nextWord, chainedWords);
1630
+ return followCredentialChain(word[0], edit.end, nextWord, chainedWords, command);
1285
1631
  });
1286
1632
  // A chain edit either matches an edit's word or misses it; on a match the
1287
1633
  // whole-word redaction replaces it, so the chain only ever adds.
@@ -1293,6 +1639,13 @@ function redactToolArguments(command, inputs) {
1293
1639
  else
1294
1640
  edits[same] = chainEdit;
1295
1641
  }
1642
+ // Erasing a header name also erases the context needed to redact its
1643
+ // following value. Rewriting across shell words can change quote pairing,
1644
+ // so this ambiguous credential form fails closed rather than risking a leak.
1645
+ for (const index of VALUE_REDACTIONS.keys()) {
1646
+ if (valueRedactionRanges(command, index).some((range) => edits.some((edit) => range.start >= edit.start && range.start < edit.end && range.end > edit.end)))
1647
+ return REDACTED;
1648
+ }
1296
1649
  edits.sort((a, b) => a.start - b.start);
1297
1650
  for (const edit of edits.reverse())
1298
1651
  command = command.slice(0, edit.start) + edit.text + command.slice(edit.end);
@@ -1388,7 +1741,7 @@ function valueRedactionTargets(command) {
1388
1741
  for (const match of command.match(pattern) ?? []) {
1389
1742
  if (/[`\uE000\uE001]/.test(match))
1390
1743
  continue;
1391
- const value = changedPart(match, match.replace(single, replacement));
1744
+ const value = changedPart(match, redactValueMatch(match, single, replacement));
1392
1745
  if (value !== '' && !values.includes(value))
1393
1746
  values.push(value);
1394
1747
  }
@@ -1408,7 +1761,7 @@ function redactValuesFirst(command, early) {
1408
1761
  for (const [pattern, replacement] of VALUE_REDACTIONS) {
1409
1762
  const single = new RegExp(pattern.source, pattern.flags.replace('g', ''));
1410
1763
  whole = whole.replace(pattern, (match) => {
1411
- const redacted = match.replace(single, replacement);
1764
+ const redacted = redactValueMatch(match, single, replacement);
1412
1765
  if (!/[`\uE000\uE001]/.test(match) && early.has(changedPart(match, redacted)))
1413
1766
  return redacted;
1414
1767
  held.push(match);
@@ -1420,17 +1773,31 @@ function redactValuesFirst(command, early) {
1420
1773
  whole = whole.replace(/\uE000(\d+)\uE001/g, (_, index) => held[Number(index)]);
1421
1774
  return whole;
1422
1775
  }
1776
+ /**
1777
+ * Redact each `VALUE_REDACTIONS` match in `text` that starts in a word
1778
+ * `rewriteWord` changes and runs on past it, as a header whose value is the
1779
+ * next word does (`"…Set-Cookie:" s=v`). Re-spelling that word moves the
1780
+ * header's name away from its value, so the value patterns, which run after
1781
+ * the rewrite, would no longer see the pair. Every other match is left to
1782
+ * them and to the rewrite, which reads the rest of its word.
1783
+ */
1784
+ function redactHeadersAcrossRewrites(text, rewriteWord) {
1785
+ const rewritten = [...text.matchAll(SHELL_WORDS)]
1786
+ .filter((word) => rewriteWord(word[0]) !== word[0])
1787
+ .map((word) => [word.index, word.index + word[0].length]);
1788
+ if (rewritten.length === 0)
1789
+ return text;
1790
+ return applyRanges(text, [...VALUE_REDACTIONS.keys()].flatMap((index) => valueRedactionRanges(text, index).filter((range) => rewritten.some(([from, to]) => range.start >= from && range.start < to && range.end > to))));
1791
+ }
1423
1792
  function redactAfterValues(command, depth, writtenInputs, valuesFirst = true) {
1424
1793
  command = redactCommandSubstitutions(command, depth, writtenInputs, valuesFirst);
1794
+ const inputs = withInheritedInputs(backtickAwareInputs(command), writtenInputs);
1795
+ const sensitiveAssignment = new RegExp(`^(${COMMAND_SENSITIVE_KEY_SOURCE})(\\+?=)`, 'i');
1425
1796
  // Nested scripts may be dispatched by any tool. Scan decoded compound
1426
1797
  // words instead of maintaining another list of interpreter/wrapper flags.
1427
1798
  // JSON and sensitive assignments already have structural/whole-word passes;
1428
1799
  // treating their values as commands would lose evidence or word boundaries.
1429
- const inputs = withInheritedInputs(backtickAwareInputs(command), writtenInputs);
1430
- const sensitiveAssignment = new RegExp(`^(${COMMAND_SENSITIVE_KEY_SOURCE})(\\+?=)`, 'i');
1431
- // Resolve credential references before nested word redaction can erase the
1432
- // expansion marker while leaving its original variable definition behind.
1433
- let out = redactToolArguments(redactJsonArguments(command), inputs).replace(new RegExp(SHELL_WORD_SOURCE, 'g'), (word) => {
1800
+ const rewriteWord = (word) => {
1434
1801
  const decoded = decodeShellWord(word);
1435
1802
  // A compound word beginning with a credential assignment may be either
1436
1803
  // an env value or a whole script. Its entire value is private in both
@@ -1452,7 +1819,33 @@ function redactAfterValues(command, depth, writtenInputs, valuesFirst = true) {
1452
1819
  }
1453
1820
  const redacted = redactShellSyntax(decoded, depth + 1, inputs, valuesFirst);
1454
1821
  return redacted === decoded ? word : JSON.stringify(redacted);
1455
- });
1822
+ };
1823
+ const rewrites = new Map();
1824
+ const rewriteOnce = (word) => {
1825
+ let rewritten = rewrites.get(word);
1826
+ if (rewritten === undefined)
1827
+ rewrites.set(word, (rewritten = rewriteWord(word)));
1828
+ return rewritten;
1829
+ };
1830
+ // Resolve credential references before nested word redaction can erase the
1831
+ // expansion marker while leaving its original variable definition behind.
1832
+ const text = redactHeadersAcrossRewrites(redactToolArguments(redactJsonArguments(command), inputs), rewriteOnce);
1833
+ // A rewrite can take the credential flag a word ends in (the header value
1834
+ // in `'…: v'--token` runs on through it once decoded), so its value is
1835
+ // followed, as for every other edit that rewrites a word.
1836
+ const next = textChainCursor(text);
1837
+ const chained = new Set();
1838
+ const ranges = [];
1839
+ for (const word of text.matchAll(SHELL_WORDS)) {
1840
+ const rewritten = rewriteOnce(word[0]);
1841
+ if (rewritten === word[0])
1842
+ continue;
1843
+ const end = word.index + word[0].length;
1844
+ ranges.push({ start: word.index, end, text: rewritten });
1845
+ if (!continuesCredentialChain(rewritten))
1846
+ ranges.push(...followCredentialChain(word[0], end, next, chained, text));
1847
+ }
1848
+ const out = applyRanges(text, ranges);
1456
1849
  return applyCommandRedactions(redactCredentialAssignments(out, inputs));
1457
1850
  }
1458
1851
  /**