@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.
package/dist/sanitize-command.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
|
464
|
-
* credential
|
|
465
|
-
*
|
|
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
|
-
|
|
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
|
|
516
|
-
*
|
|
517
|
-
*
|
|
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
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
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.
|
|
544
|
-
*
|
|
545
|
-
*
|
|
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(
|
|
548
|
-
|
|
549
|
-
|
|
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
|
-
|
|
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 (
|
|
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
|
-
/**
|
|
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
|
-
|
|
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
|
-
...
|
|
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
|
|
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
|
|
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
|
|
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
|
/**
|