@mossbear/eval-core 0.1.0-alpha.104 → 0.1.0-alpha.106
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
|
@@ -54,9 +54,13 @@ const REDIS_URI = /^(?:rediss?|valkeys?):\/\//i;
|
|
|
54
54
|
/**
|
|
55
55
|
* The tool a word invokes: its basename, lowercased, without a Windows
|
|
56
56
|
* executable suffix. Windows resolves executable names case-insensitively.
|
|
57
|
+
* Backticks are dropped, so a word a substitution begins or ends (`` `curl ``)
|
|
58
|
+
* still names its tool when quoting hides the substitution from the parser.
|
|
57
59
|
*/
|
|
58
60
|
function invokedTool(word) {
|
|
59
|
-
return (word.split(/[\\/]/).at(-1) ?? word)
|
|
61
|
+
return (word.replaceAll('`', '').split(/[\\/]/).at(-1) ?? word)
|
|
62
|
+
.replace(/\.exe$/i, '')
|
|
63
|
+
.toLowerCase();
|
|
60
64
|
}
|
|
61
65
|
const CREDENTIAL_TOOLS = [
|
|
62
66
|
'curl',
|
|
@@ -87,6 +91,68 @@ const WRAPPER_VALUE_FLAGS = {
|
|
|
87
91
|
time: /^-[of](.+)?$|^--(?:output|format)(?:=(.*))?$/,
|
|
88
92
|
xargs: /^-[adEILnPs](.+)?$|^--(?:arg-file|delimiter|eof|replace|max-lines|max-args|max-procs|max-chars)(?:=(.*))?$/,
|
|
89
93
|
};
|
|
94
|
+
// Wrapper options known to take no value. Any other option, unless its value is
|
|
95
|
+
// attached with `=`, may have consumed the next word.
|
|
96
|
+
const WRAPPER_SWITCHES = {
|
|
97
|
+
sudo: /^-[AbEHIKklnPSsVvie]+$|^--(?:preserve-env|login|shell|set-home|non-interactive|background)$/,
|
|
98
|
+
doas: /^-[nsL]+$/,
|
|
99
|
+
env: /^-[i0v]+$|^-$|^--(?:ignore-environment|null|debug)$/,
|
|
100
|
+
time: /^-[pvqa]+$|^--(?:portability|verbose|quiet|append)$/,
|
|
101
|
+
xargs: /^-[0rtpxo]+$|^--(?:null|no-run-if-empty|verbose|interactive|exit|open-tty)$/,
|
|
102
|
+
};
|
|
103
|
+
// Programs that never run an argument as a command, so a credential tool's name
|
|
104
|
+
// among their arguments is a file (`cat ./bin/curl`), not an invocation. Any
|
|
105
|
+
// program not listed may dispatch (`timeout`, `watch`, `find -exec`, `ssh`), and
|
|
106
|
+
// keeps treating every word as a possible invocation, so an unlisted program
|
|
107
|
+
// errs toward over-redaction.
|
|
108
|
+
const NON_DISPATCHING_TOOLS = new Set([
|
|
109
|
+
'basename',
|
|
110
|
+
'cat',
|
|
111
|
+
'chgrp',
|
|
112
|
+
'chmod',
|
|
113
|
+
'chown',
|
|
114
|
+
'cmp',
|
|
115
|
+
'cp',
|
|
116
|
+
'diff',
|
|
117
|
+
'dirname',
|
|
118
|
+
'du',
|
|
119
|
+
'file',
|
|
120
|
+
'grep',
|
|
121
|
+
'head',
|
|
122
|
+
'ln',
|
|
123
|
+
'ls',
|
|
124
|
+
'mkdir',
|
|
125
|
+
'mv',
|
|
126
|
+
'readlink',
|
|
127
|
+
'realpath',
|
|
128
|
+
'rm',
|
|
129
|
+
'stat',
|
|
130
|
+
'tail',
|
|
131
|
+
'touch',
|
|
132
|
+
'wc',
|
|
133
|
+
'which',
|
|
134
|
+
]);
|
|
135
|
+
/**
|
|
136
|
+
* Whether the command word starting at `index` of `text` runs a listed program:
|
|
137
|
+
* bare, or from a system bin directory. A same-named local script
|
|
138
|
+
* (`./tools/diff`) may run anything, and a redirection operand (`>file curl`)
|
|
139
|
+
* names a file, not the command.
|
|
140
|
+
*/
|
|
141
|
+
function isNonDispatching(text, index, word) {
|
|
142
|
+
let before = index - 1;
|
|
143
|
+
while (text[before] === ' ' || text[before] === '\t')
|
|
144
|
+
before -= 1;
|
|
145
|
+
// `>&file` and `>|file` redirect too.
|
|
146
|
+
if (text[before] === '&' || text[before] === '|')
|
|
147
|
+
before -= 1;
|
|
148
|
+
if (text[before] === '<' || text[before] === '>')
|
|
149
|
+
return false;
|
|
150
|
+
// A backtick in the command word may start a substitution that runs anything.
|
|
151
|
+
if (word.includes('`'))
|
|
152
|
+
return false;
|
|
153
|
+
return (NON_DISPATCHING_TOOLS.has(invokedTool(word)) &&
|
|
154
|
+
/^(?:(?:\/usr)?\/bin\/)?[^\\/]+$/.test(word));
|
|
155
|
+
}
|
|
90
156
|
const CURL_AUTH_OPTIONS = `user|proxy-user|${CURL_URL_OPTIONS}|cookie|oauth2-bearer|cert|proxy-cert|pass|proxy-pass`;
|
|
91
157
|
const CURL_AUTH_FLAG = new RegExp(`^(-${CURL_SHORT_SWITCHES}[uUbEx])(.+)?$|^(--(?:${CURL_AUTH_OPTIONS}))(?:=(.*))?$`);
|
|
92
158
|
const CURL_EXPANSION = /\{\{([A-Za-z0-9_]+)(?::[^{}]*)?\}\}/g;
|
|
@@ -128,22 +194,120 @@ function decodeShellWord(word) {
|
|
|
128
194
|
return escaped ?? plain ?? '';
|
|
129
195
|
});
|
|
130
196
|
}
|
|
131
|
-
/**
|
|
132
|
-
|
|
197
|
+
/**
|
|
198
|
+
* Undo the escapes a backtick substitution's body carries for `\\` and `\``,
|
|
199
|
+
* and for `\"` when the substitution sits inside double quotes.
|
|
200
|
+
* `\$` is left escaped: the word parser decodes it either way.
|
|
201
|
+
*/
|
|
202
|
+
function unescapeBacktickBody(body, doubleQuoted = false) {
|
|
203
|
+
return body.replace(doubleQuoted ? /\\([\\`"])/g : /\\([\\`])/g, '$1');
|
|
204
|
+
}
|
|
205
|
+
/** Spell a body back inside backticks, reversing `unescapeBacktickBody`. */
|
|
206
|
+
function escapeBacktickBody(body, doubleQuoted = false) {
|
|
207
|
+
return body.replace(doubleQuoted ? /\\(?=[\\`"]|$)|[`"]/g : /\\(?=[\\`]|$)|`/g, '\\$&');
|
|
208
|
+
}
|
|
209
|
+
/**
|
|
210
|
+
* The command with each backtick substitution spelled `$(…)`. `(` separates
|
|
211
|
+
* words for the tool parsers and a backtick does not, so tool-input discovery
|
|
212
|
+
* reads this spelling to see the commands inside backticks.
|
|
213
|
+
*/
|
|
214
|
+
function backticksAsSubshells(command, depth = 0) {
|
|
215
|
+
if (depth > 8 || !command.includes('`'))
|
|
216
|
+
return command;
|
|
217
|
+
let quote = null;
|
|
218
|
+
let out = '';
|
|
219
|
+
for (let index = 0; index < command.length; index += 1) {
|
|
220
|
+
const char = command[index];
|
|
221
|
+
if (char === '\\' && quote !== "'") {
|
|
222
|
+
out += command.slice(index, index + 2);
|
|
223
|
+
index += 1;
|
|
224
|
+
continue;
|
|
225
|
+
}
|
|
226
|
+
if (char === '`' && quote !== "'") {
|
|
227
|
+
let end = index + 1;
|
|
228
|
+
while (end < command.length && command[end] !== '`')
|
|
229
|
+
end += command[end] === '\\' ? 2 : 1;
|
|
230
|
+
const body = unescapeBacktickBody(command.slice(index + 1, end));
|
|
231
|
+
out += `$(${backticksAsSubshells(body, depth + 1)})`;
|
|
232
|
+
index = end;
|
|
233
|
+
continue;
|
|
234
|
+
}
|
|
235
|
+
if (char === "'" && quote !== '"')
|
|
236
|
+
quote = quote === "'" ? null : "'";
|
|
237
|
+
else if (char === '"' && quote !== "'")
|
|
238
|
+
quote = quote === '"' ? null : '"';
|
|
239
|
+
out += char;
|
|
240
|
+
}
|
|
241
|
+
return out;
|
|
242
|
+
}
|
|
243
|
+
/**
|
|
244
|
+
* Tool inputs read from the command as written and from its `$(…)` spelling.
|
|
245
|
+
* The respelling separates a backtick body from its first word but also
|
|
246
|
+
* splits a word that runs on past the substitution, so neither alone sees
|
|
247
|
+
* every reference.
|
|
248
|
+
*/
|
|
249
|
+
function backtickAwareInputs(command) {
|
|
250
|
+
const inputs = getToolInputs(command);
|
|
251
|
+
return command.includes('`')
|
|
252
|
+
? withInheritedInputs(getToolInputs(backticksAsSubshells(command)), inputs)
|
|
253
|
+
: inputs;
|
|
254
|
+
}
|
|
255
|
+
function withInheritedInputs(inputs, inherited) {
|
|
256
|
+
inputs.curlConfig ||= inherited?.curlConfig ?? false;
|
|
257
|
+
for (const name of inherited?.curlVariables ?? [])
|
|
258
|
+
inputs.curlVariables.add(name);
|
|
259
|
+
for (const key of inherited?.env ?? [])
|
|
260
|
+
inputs.env.add(key);
|
|
261
|
+
for (const source of inherited?.passSources ?? [])
|
|
262
|
+
inputs.passSources.add(source);
|
|
263
|
+
return inputs;
|
|
264
|
+
}
|
|
265
|
+
/**
|
|
266
|
+
* A program supplying a credential argument carries private produced content.
|
|
267
|
+
* Any other backtick substitution runs a command of its own, which a backtick
|
|
268
|
+
* does not separate from its first word the way `(` does, so its body is
|
|
269
|
+
* redacted as a command in its own right. An escaped backtick is literal text;
|
|
270
|
+
* `invokedTool` still reads the tool its word names.
|
|
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
|
+
function redactCommandSubstitutions(command, depth = 0, inheritedInputs, valuesFirst = true) {
|
|
283
|
+
if (depth > 8)
|
|
284
|
+
return REDACTED;
|
|
133
285
|
const marker = '__SUBSTITUTION_BOUNDARY__';
|
|
134
286
|
if (command.includes(marker))
|
|
135
287
|
return REDACTED;
|
|
288
|
+
let contextInputs;
|
|
289
|
+
// A body also feeds, or consumes, the commands around it.
|
|
290
|
+
const surroundingInputs = () => (contextInputs ??= withInheritedInputs(backtickAwareInputs(command), inheritedInputs));
|
|
136
291
|
const isCredentialInput = (start, end) => {
|
|
137
292
|
const probe = command.slice(0, start) + marker + command.slice(end + 1);
|
|
138
293
|
const inputs = getToolInputs(probe);
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
redacted = redacted.replace(pattern, replacement);
|
|
142
|
-
return !redacted.includes(marker);
|
|
294
|
+
const redacted = redactCredentialAssignments(redactToolArguments(redactJsonArguments(probe), inputs), inputs);
|
|
295
|
+
return !applyCommandRedactions(redacted).includes(marker);
|
|
143
296
|
};
|
|
144
297
|
const findEnd = (start, closing, depth) => {
|
|
145
298
|
if (depth > 8)
|
|
146
299
|
return command.length;
|
|
300
|
+
// A backtick body ends at the first unescaped backtick, as bash reads it:
|
|
301
|
+
// quotes inside it do not hide one, and `\` escapes even inside `'…'`.
|
|
302
|
+
if (closing === '`') {
|
|
303
|
+
for (let index = start; index < command.length; index += 1) {
|
|
304
|
+
if (command[index] === '\\')
|
|
305
|
+
index += 1;
|
|
306
|
+
else if (command[index] === '`')
|
|
307
|
+
return index;
|
|
308
|
+
}
|
|
309
|
+
return command.length;
|
|
310
|
+
}
|
|
147
311
|
let quote = null;
|
|
148
312
|
let groups = 0;
|
|
149
313
|
for (let index = start; index < command.length; index += 1) {
|
|
@@ -152,8 +316,6 @@ function redactCommandSubstitutions(command) {
|
|
|
152
316
|
index += 1;
|
|
153
317
|
continue;
|
|
154
318
|
}
|
|
155
|
-
if (closing === '`' && char === '`')
|
|
156
|
-
return index;
|
|
157
319
|
if (quote === "'") {
|
|
158
320
|
if (char === "'")
|
|
159
321
|
quote = null;
|
|
@@ -184,6 +346,8 @@ function redactCommandSubstitutions(command) {
|
|
|
184
346
|
let quote = null;
|
|
185
347
|
let out = '';
|
|
186
348
|
let substitutions = 0;
|
|
349
|
+
// Where in `out` the open double quote began.
|
|
350
|
+
let quoteOpenedAt = 0;
|
|
187
351
|
for (let index = 0; index < command.length; index += 1) {
|
|
188
352
|
const char = command[index];
|
|
189
353
|
if (char === '\\' && quote !== "'") {
|
|
@@ -195,14 +359,48 @@ function redactCommandSubstitutions(command) {
|
|
|
195
359
|
if (++substitutions > 64)
|
|
196
360
|
return out + REDACTED;
|
|
197
361
|
const start = index;
|
|
198
|
-
|
|
199
|
-
|
|
362
|
+
const bodyStart = index + (char === '`' ? 1 : 2);
|
|
363
|
+
index = findEnd(bodyStart, char === '`' ? '`' : ')', 0);
|
|
364
|
+
const original = command.slice(start, index + 1);
|
|
365
|
+
if (isCredentialInput(start, index))
|
|
366
|
+
out += REDACTED;
|
|
367
|
+
else if (char === '`') {
|
|
368
|
+
// In the command as written, an unterminated backtick leaves the
|
|
369
|
+
// body's extent unknown: fail closed. A nested pass sees words the
|
|
370
|
+
// parser split at a backtick (`"a b"\``), so it parses the rest instead.
|
|
371
|
+
const unterminated = command[index] !== '`';
|
|
372
|
+
if (unterminated && depth === 0)
|
|
373
|
+
return `${out}\`${REDACTED}`;
|
|
374
|
+
const body = unescapeBacktickBody(command.slice(bodyStart, index), quote === '"');
|
|
375
|
+
const redacted = redactShellSyntax(body, depth + 1, surroundingInputs(), valuesFirst);
|
|
376
|
+
// Rewriting a body inside a double quote that never closes changes
|
|
377
|
+
// how the rest of that quote splits into words, which can move a
|
|
378
|
+
// credential argument's extent: fail closed, as for an unterminated
|
|
379
|
+
// backtick.
|
|
380
|
+
if (redacted !== body && quote === '"' && !closesQuote(command, index + 1))
|
|
381
|
+
return `${out.slice(0, quoteOpenedAt)}${REDACTED}`;
|
|
382
|
+
out +=
|
|
383
|
+
redacted === body
|
|
384
|
+
? original
|
|
385
|
+
: `\`${escapeBacktickBody(redacted, quote === '"')}${unterminated ? '' : '`'}`;
|
|
386
|
+
}
|
|
387
|
+
else if (original.includes('`')) {
|
|
388
|
+
// `(` already exposes this body to the outer parse; only backticks
|
|
389
|
+
// nested inside it need a pass of their own.
|
|
390
|
+
const body = command.slice(bodyStart, index);
|
|
391
|
+
out += `$(${redactCommandSubstitutions(body, depth + 1, surroundingInputs(), valuesFirst)}${command.slice(index, index + 1)}`;
|
|
392
|
+
}
|
|
393
|
+
else
|
|
394
|
+
out += original;
|
|
200
395
|
continue;
|
|
201
396
|
}
|
|
202
397
|
if (char === "'" && quote !== '"')
|
|
203
398
|
quote = quote === "'" ? null : "'";
|
|
204
|
-
else if (char === '"' && quote !== "'")
|
|
399
|
+
else if (char === '"' && quote !== "'") {
|
|
400
|
+
if (quote === null)
|
|
401
|
+
quoteOpenedAt = out.length;
|
|
205
402
|
quote = quote === '"' ? null : '"';
|
|
403
|
+
}
|
|
206
404
|
out += char;
|
|
207
405
|
}
|
|
208
406
|
return out;
|
|
@@ -236,7 +434,7 @@ function redactJsonValue(value, depth = 0) {
|
|
|
236
434
|
]));
|
|
237
435
|
}
|
|
238
436
|
function redactJsonArguments(command) {
|
|
239
|
-
|
|
437
|
+
const redactArgument = (argument) => {
|
|
240
438
|
// Shell double quotes only consume escapes for these characters. JSON's
|
|
241
439
|
// string decoder rejects valid shell escapes such as \$, so decode the
|
|
242
440
|
// shell layer before parsing the request body.
|
|
@@ -257,7 +455,313 @@ function redactJsonArguments(command) {
|
|
|
257
455
|
return argument;
|
|
258
456
|
return argument.startsWith("'") ? `'${REDACTED}'` : `"${REDACTED}"`;
|
|
259
457
|
}
|
|
260
|
-
}
|
|
458
|
+
};
|
|
459
|
+
// Dropping a word that a substitution closed onto a credential flag
|
|
460
|
+
// (`[a]` then a backtick and `--token`) hides the flag from the
|
|
461
|
+
// space-separated rule, so its value is followed here, as for every other
|
|
462
|
+
// edit that rewrites a word.
|
|
463
|
+
const next = textChainCursor(command);
|
|
464
|
+
const chained = new Set();
|
|
465
|
+
const ranges = [];
|
|
466
|
+
for (const match of command.matchAll(new RegExp(SHELL_WORD_SOURCE, 'g'))) {
|
|
467
|
+
const replaced = redactArgument(match[0]);
|
|
468
|
+
if (replaced === match[0])
|
|
469
|
+
continue;
|
|
470
|
+
const end = match.index + match[0].length;
|
|
471
|
+
ranges.push({ start: match.index, end, text: replaced });
|
|
472
|
+
ranges.push(...followCredentialChain(match[0], end, next, chained, command));
|
|
473
|
+
}
|
|
474
|
+
return applyRanges(command, ranges);
|
|
475
|
+
}
|
|
476
|
+
/**
|
|
477
|
+
* Credential values recognised by the name beside them (URL userinfo,
|
|
478
|
+
* Authorization and Cookie headers, query keys) rather than by shell words.
|
|
479
|
+
*/
|
|
480
|
+
const VALUE_REDACTIONS = [
|
|
481
|
+
[/\b([a-z][a-z0-9+.-]*:\/\/[^/\s:@]+:)[^@\s]+@/gi, `$1${REDACTED}@`],
|
|
482
|
+
[/\b([a-z][a-z0-9+.-]*:\/\/)[^/\s:@]+@/gi, `$1${REDACTED}@`],
|
|
483
|
+
[/\b(authorization\s*:\s*)(?:(?:bearer|basic|token)\s+)?\S+/gi, `$1${REDACTED}`],
|
|
484
|
+
[new RegExp(COOKIE_HEADER_SOURCE, 'gi'), `$1${REDACTED}`],
|
|
485
|
+
[
|
|
486
|
+
new RegExp(`([?&#]${COMMAND_SENSITIVE_KEY_SOURCE}=)[^&\\s"'<>]+`, 'gi'),
|
|
487
|
+
`$1${REDACTED}`,
|
|
488
|
+
],
|
|
489
|
+
];
|
|
490
|
+
/**
|
|
491
|
+
* Space-separated credential flags have no assignment operator or token
|
|
492
|
+
* prefix. Keep the flag for evidence, but never its value. The value is the
|
|
493
|
+
* next shell word across any whitespace, newlines included: a word typed on
|
|
494
|
+
* the line after `--password` is very likely the password, even though bash
|
|
495
|
+
* would read it as a new command.
|
|
496
|
+
*
|
|
497
|
+
* That reach means the value can itself be a credential flag
|
|
498
|
+
* (`--password --token <value>`). Taking it as the first flag's value must not
|
|
499
|
+
* spare its own value, so the word after a swallowed credential flag is
|
|
500
|
+
* redacted too, for as long as the chain runs. A chained word that spells a
|
|
501
|
+
* credential key is left for the passes after this one to read (see
|
|
502
|
+
* `isChainedValue`), and one with an attached value ends the chain. Every
|
|
503
|
+
* match the rule makes on its own is still redacted: the chain only adds.
|
|
504
|
+
*/
|
|
505
|
+
const CREDENTIAL_FLAG = new RegExp(`(^|[\\s;&|(\\x60])(--${COMMAND_SENSITIVE_KEY_SOURCE}\\s+)(${SHELL_WORD_SOURCE})`, 'gi');
|
|
506
|
+
const NEXT_SHELL_WORD = new RegExp(`(\\s+)(${SHELL_WORD_SOURCE})`, 'y');
|
|
507
|
+
const SHELL_WORDS = new RegExp(SHELL_WORD_SOURCE, 'g');
|
|
508
|
+
// The words a credential key is spelled with. Keys are tested by scanning a
|
|
509
|
+
// word's `[\w.-]` runs for one rather than by a regex with `[\w.-]*` on both
|
|
510
|
+
// sides of the key, which backtracks quadratically on a long word.
|
|
511
|
+
const CREDENTIAL_KEY_WORD = new RegExp(`${SENSITIVE_WORD_SOURCE}|SSHPASS|REDISCLI_AUTH|VALKEYCLI_AUTH|MYSQL_PWD`, 'i');
|
|
512
|
+
const ASSIGNMENT_OPERATOR = /\s*[=:]/y;
|
|
513
|
+
const LINE_CONTINUATION = /^\\\r?\n$/;
|
|
514
|
+
const KEY_CHARACTER = /[\w.-]/;
|
|
515
|
+
/** `SENSITIVE_KEY`'s test, without its backtracking on a long name. */
|
|
516
|
+
function isCredentialKeyName(name) {
|
|
517
|
+
return ((/^[\w.-]+$/.test(name) && SENSITIVE_CONTENT.test(name)) ||
|
|
518
|
+
/^(?:SSHPASS|REDISCLI_AUTH|VALKEYCLI_AUTH|MYSQL_PWD)$/i.test(name));
|
|
519
|
+
}
|
|
520
|
+
function isBareCredentialFlag(word) {
|
|
521
|
+
const flag = /^--([^=]+)$/.exec(decodeShellWord(word));
|
|
522
|
+
return flag !== null && isCredentialKeyName(flag[1]);
|
|
523
|
+
}
|
|
524
|
+
/**
|
|
525
|
+
* `--token=<value>` carries its own value, which the attached-flag and
|
|
526
|
+
* assignment rules redact while keeping the flag's name readable to the
|
|
527
|
+
* passes after them.
|
|
528
|
+
*/
|
|
529
|
+
function isAttachedCredentialFlag(word) {
|
|
530
|
+
const flag = /^--([^=]+)=/.exec(decodeShellWord(word));
|
|
531
|
+
return flag !== null && isCredentialKeyName(flag[1]);
|
|
532
|
+
}
|
|
533
|
+
/**
|
|
534
|
+
* Whether `text` spells a credential key where the other rules read one: a
|
|
535
|
+
* `[\w.-]` run at the start, or after a shell boundary, quote or escape (where
|
|
536
|
+
* a decoded word starts), that is a `--` flag or is followed by `=` or `:`.
|
|
537
|
+
*/
|
|
538
|
+
function spellsCredentialKey(text) {
|
|
539
|
+
for (const run of text.matchAll(/[\w.-]+/g)) {
|
|
540
|
+
if (run.index > 0 && !/[\s;&|(\x60'"\\]/.test(text[run.index - 1]))
|
|
541
|
+
continue;
|
|
542
|
+
if (!CREDENTIAL_KEY_WORD.test(run[0]))
|
|
543
|
+
continue;
|
|
544
|
+
if (run[0].startsWith('--'))
|
|
545
|
+
return true;
|
|
546
|
+
ASSIGNMENT_OPERATOR.lastIndex = run.index + run[0].length;
|
|
547
|
+
if (ASSIGNMENT_OPERATOR.test(text))
|
|
548
|
+
return true;
|
|
549
|
+
}
|
|
550
|
+
return false;
|
|
551
|
+
}
|
|
552
|
+
/**
|
|
553
|
+
* Whether `word` ends in a credential flag the space-separated rule reads on
|
|
554
|
+
* its own: it reads boundaries in the raw text, so `\<tab>--token` (an escaped
|
|
555
|
+
* tab) ends in one.
|
|
556
|
+
*/
|
|
557
|
+
function endsInCredentialFlag(word) {
|
|
558
|
+
let start = word.length;
|
|
559
|
+
while (start > 0 && KEY_CHARACTER.test(word[start - 1]))
|
|
560
|
+
start -= 1;
|
|
561
|
+
if (!word.startsWith('--', start))
|
|
562
|
+
return false;
|
|
563
|
+
// A substitution a pass replaced with the redaction marker still ends where
|
|
564
|
+
// its backtick did, so a flag after the marker starts a word as it did.
|
|
565
|
+
if (start > 0 &&
|
|
566
|
+
!/[\s;&|(\x60]/.test(word[start - 1]) &&
|
|
567
|
+
!word.slice(0, start).endsWith(REDACTED))
|
|
568
|
+
return false;
|
|
569
|
+
return CREDENTIAL_KEY_WORD.test(word.slice(start + 2));
|
|
570
|
+
}
|
|
571
|
+
/**
|
|
572
|
+
* Whether to redact a word the chain reached. The other rules read credential
|
|
573
|
+
* keys in the raw text and, for nested commands, in decoded words, so a word
|
|
574
|
+
* that spells one either way (a bare credential flag among them) is left whole
|
|
575
|
+
* for them to read, exactly as it reads without the chain. So is a header that
|
|
576
|
+
* a value pattern reads on into `after`, the word that follows it.
|
|
577
|
+
*/
|
|
578
|
+
function isChainedValue(word, after) {
|
|
579
|
+
return ![
|
|
580
|
+
[word, after],
|
|
581
|
+
[decodeShellWord(word), after === undefined ? undefined : decodeShellWord(after)],
|
|
582
|
+
].some(([text, next]) => spellsCredentialKey(text) || startsValueHeader(text, next));
|
|
583
|
+
}
|
|
584
|
+
// `VALUE_REDACTIONS` without the global flag, for testing a single word.
|
|
585
|
+
const VALUE_PATTERNS = VALUE_REDACTIONS.map(([pattern]) => new RegExp(pattern.source, pattern.flags.replace('g', '')));
|
|
586
|
+
/**
|
|
587
|
+
* Whether `text` begins a match of a value pattern that runs on into the next
|
|
588
|
+
* word, as a header name does (`Cookie: name=value`). Redacting that word
|
|
589
|
+
* would hide the value from the pattern, so the chain leaves it whole. Where
|
|
590
|
+
* no word follows (`next` is undefined: an operator or the end), no pattern
|
|
591
|
+
* can read on, so the word is not left. The test is read off
|
|
592
|
+
* `VALUE_REDACTIONS` itself, so a pattern added later is covered without a
|
|
593
|
+
* list of header names here.
|
|
594
|
+
*/
|
|
595
|
+
function startsValueHeader(text, next) {
|
|
596
|
+
if (next === undefined)
|
|
597
|
+
return false;
|
|
598
|
+
return VALUE_PATTERNS.some((pattern) => [' name=value', ' value', ...(next ? [` ${next}`] : [])].some((following) => {
|
|
599
|
+
const match = pattern.exec(text + following);
|
|
600
|
+
return (match !== null &&
|
|
601
|
+
match.index < text.length &&
|
|
602
|
+
match.index + match[0].length > text.length);
|
|
603
|
+
}));
|
|
604
|
+
}
|
|
605
|
+
/** Whether the word after `word` is a credential flag's value. */
|
|
606
|
+
function continuesCredentialChain(word) {
|
|
607
|
+
// Decoded too: quotes hide a flag glued after a substitution or the
|
|
608
|
+
// redaction marker that replaced one (`[redacted]"--passwd"`).
|
|
609
|
+
return (isBareCredentialFlag(word) ||
|
|
610
|
+
endsInCredentialFlag(word) ||
|
|
611
|
+
endsInCredentialFlag(decodeShellWord(word)));
|
|
612
|
+
}
|
|
613
|
+
/**
|
|
614
|
+
* The words to redact after `value`, a word ending at `end` that a rule has
|
|
615
|
+
* just taken as a credential's value. If `value` is itself a credential flag,
|
|
616
|
+
* the redaction hides the flag the space-separated rule would read, so its
|
|
617
|
+
* value, and the chain after it, are redacted here instead. This is the one
|
|
618
|
+
* rule for every value edit: each rule that redacts a word as a value passes
|
|
619
|
+
* the word here, whatever flag or tool took it. `seen` holds the ends a chain
|
|
620
|
+
* has already left, so a run of flags stays linear.
|
|
621
|
+
*/
|
|
622
|
+
function followCredentialChain(value, end, next, seen, text) {
|
|
623
|
+
const ranges = [];
|
|
624
|
+
for (let word = value; continuesCredentialChain(word);) {
|
|
625
|
+
if (seen.has(end))
|
|
626
|
+
break;
|
|
627
|
+
seen.add(end);
|
|
628
|
+
const following = next(end);
|
|
629
|
+
if (!following)
|
|
630
|
+
break;
|
|
631
|
+
if (isAttachedCredentialFlag(following.word))
|
|
632
|
+
break;
|
|
633
|
+
if (isChainedValue(following.word, wordAfter(text, following.end, next)))
|
|
634
|
+
ranges.push({ start: following.start, end: following.end, text: REDACTED });
|
|
635
|
+
word = following.word;
|
|
636
|
+
end = following.end;
|
|
637
|
+
}
|
|
638
|
+
return ranges;
|
|
639
|
+
}
|
|
640
|
+
/**
|
|
641
|
+
* The word after position `end` for `startsValueHeader`: undefined where an
|
|
642
|
+
* operator follows, so no pattern can read on, and empty at the end of the
|
|
643
|
+
* text, which may be a nested word the enclosing command continues after.
|
|
644
|
+
*/
|
|
645
|
+
function wordAfter(text, end, next) {
|
|
646
|
+
const following = next(end);
|
|
647
|
+
if (following)
|
|
648
|
+
return following.word;
|
|
649
|
+
return text.slice(end).trim() === '' ? '' : undefined;
|
|
650
|
+
}
|
|
651
|
+
/** Steps through `text` by shell word: a chain ends at an operator. */
|
|
652
|
+
function textChainCursor(text) {
|
|
653
|
+
return (end) => {
|
|
654
|
+
NEXT_SHELL_WORD.lastIndex = end;
|
|
655
|
+
let next = NEXT_SHELL_WORD.exec(text);
|
|
656
|
+
// A line continuation left in a nested script is not a word.
|
|
657
|
+
while (next && LINE_CONTINUATION.test(next[2]))
|
|
658
|
+
next = NEXT_SHELL_WORD.exec(text);
|
|
659
|
+
if (!next)
|
|
660
|
+
return undefined;
|
|
661
|
+
return {
|
|
662
|
+
word: next[2],
|
|
663
|
+
start: NEXT_SHELL_WORD.lastIndex - next[2].length,
|
|
664
|
+
end: NEXT_SHELL_WORD.lastIndex,
|
|
665
|
+
};
|
|
666
|
+
};
|
|
667
|
+
}
|
|
668
|
+
/**
|
|
669
|
+
* Replace each range of `text` with its text. Where ranges overlap, the
|
|
670
|
+
* earlier one is written and the text under the later one is dropped, so an
|
|
671
|
+
* overlap never puts back what either range removed.
|
|
672
|
+
*/
|
|
673
|
+
function applyRanges(text, ranges) {
|
|
674
|
+
ranges.sort((a, b) => a.start - b.start);
|
|
675
|
+
let out = '';
|
|
676
|
+
let cursor = 0;
|
|
677
|
+
for (const range of ranges) {
|
|
678
|
+
if (range.end <= cursor)
|
|
679
|
+
continue;
|
|
680
|
+
if (range.start >= cursor)
|
|
681
|
+
out += text.slice(cursor, range.start) + range.text;
|
|
682
|
+
cursor = range.end;
|
|
683
|
+
}
|
|
684
|
+
return out + text.slice(cursor);
|
|
685
|
+
}
|
|
686
|
+
/**
|
|
687
|
+
* `text.replace(pattern, replace)` for a pattern whose last group is a value
|
|
688
|
+
* the replacement may redact. A redacted value keeps its credential chain,
|
|
689
|
+
* which may run on into `after`, the text that follows `text`; the words it
|
|
690
|
+
* redacts there are returned as ranges of `after`.
|
|
691
|
+
*/
|
|
692
|
+
function replaceValues(text, pattern, replace, after = '') {
|
|
693
|
+
const next = textChainCursor(text + after);
|
|
694
|
+
const seen = new Set();
|
|
695
|
+
const ranges = [];
|
|
696
|
+
const beyond = [];
|
|
697
|
+
for (const match of text.matchAll(pattern)) {
|
|
698
|
+
const end = match.index + match[0].length;
|
|
699
|
+
const replaced = replace(match[0], ...match.slice(1).map((group) => group ?? ''));
|
|
700
|
+
if (replaced === match[0])
|
|
701
|
+
continue;
|
|
702
|
+
ranges.push({ start: match.index, end, text: replaced });
|
|
703
|
+
for (const range of followCredentialChain(match.at(-1) ?? '', end, next, seen, text + after)) {
|
|
704
|
+
if (range.start < text.length)
|
|
705
|
+
ranges.push(range);
|
|
706
|
+
else
|
|
707
|
+
beyond.push({
|
|
708
|
+
start: range.start - text.length,
|
|
709
|
+
end: range.end - text.length,
|
|
710
|
+
text: range.text,
|
|
711
|
+
});
|
|
712
|
+
}
|
|
713
|
+
}
|
|
714
|
+
return { text: applyRanges(text, ranges), after: beyond };
|
|
715
|
+
}
|
|
716
|
+
function redactCredentialFlagValues(command) {
|
|
717
|
+
const next = textChainCursor(command);
|
|
718
|
+
const ranges = [];
|
|
719
|
+
// A chain runs the same from any word it reaches, so one that reaches a word
|
|
720
|
+
// another chain already left stops there, keeping a run of flags linear.
|
|
721
|
+
const chained = new Set();
|
|
722
|
+
for (const match of command.matchAll(CREDENTIAL_FLAG)) {
|
|
723
|
+
const end = match.index + match[1].length + match[2].length + match[3].length;
|
|
724
|
+
ranges.push({ start: end - match[3].length, end, text: REDACTED });
|
|
725
|
+
ranges.push(...followCredentialChain(match[3], end, next, chained, command));
|
|
726
|
+
}
|
|
727
|
+
// A quote or escape in a flag (`'--token'`, `\--token`) hides it from the
|
|
728
|
+
// pattern above, though the shell passes the tool the same flag. Read each
|
|
729
|
+
// word as the shell decodes it, so the flag takes its value wherever it
|
|
730
|
+
// stands, including where a command starts, which the per-tool walker
|
|
731
|
+
// reads as the program's name.
|
|
732
|
+
let wordEnd = 0;
|
|
733
|
+
for (const word of command.matchAll(SHELL_WORDS)) {
|
|
734
|
+
// A quote between words opens a string that never closes (a word would
|
|
735
|
+
// have taken a closed one), and the shell reads the rest as its text.
|
|
736
|
+
if (/['"]/.test(command.slice(wordEnd, word.index)))
|
|
737
|
+
break;
|
|
738
|
+
wordEnd = word.index + word[0].length;
|
|
739
|
+
if (word.index > 0 && !/[\s;&|(]/.test(command[word.index - 1]))
|
|
740
|
+
continue;
|
|
741
|
+
// A substitution a pass replaced with the redaction marker ends where its
|
|
742
|
+
// backtick did, so a flag glued after either starts a word.
|
|
743
|
+
const flag = word[0].replace(/^(?:\x60|\[redacted\])+/, '');
|
|
744
|
+
if (decodeShellWord(flag) === flag || !isBareCredentialFlag(flag))
|
|
745
|
+
continue;
|
|
746
|
+
const end = word.index + word[0].length;
|
|
747
|
+
const value = next(end);
|
|
748
|
+
// A value that spells a credential key or starts a header is left whole
|
|
749
|
+
// for the rules that read one, as the chain leaves it; so is one that
|
|
750
|
+
// opens a backtick substitution, which the substitution passes read.
|
|
751
|
+
if (!value ||
|
|
752
|
+
value.word.includes('\x60') ||
|
|
753
|
+
!isChainedValue(value.word, wordAfter(command, value.end, next)))
|
|
754
|
+
continue;
|
|
755
|
+
ranges.push({ start: value.start, end: value.end, text: REDACTED });
|
|
756
|
+
ranges.push(...followCredentialChain(value.word, value.end, next, chained, command));
|
|
757
|
+
}
|
|
758
|
+
return applyRanges(command, ranges);
|
|
759
|
+
}
|
|
760
|
+
function applyCommandRedactions(command) {
|
|
761
|
+
let out = command;
|
|
762
|
+
for (const redaction of COMMAND_REDACTIONS)
|
|
763
|
+
out = typeof redaction === 'function' ? redaction(out) : out.replace(...redaction);
|
|
764
|
+
return out;
|
|
261
765
|
}
|
|
262
766
|
/**
|
|
263
767
|
* Redactions applied to command text, in order: heredoc bodies (bulk inline
|
|
@@ -281,20 +785,8 @@ const COMMAND_REDACTIONS = [
|
|
|
281
785
|
`$1$2$3 ${REDACTED}$4`,
|
|
282
786
|
],
|
|
283
787
|
[new RegExp(PRIVATE_KEY_BLOCK_SOURCE, 'g'), REDACTED],
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
[/\b(authorization\s*:\s*)(?:(?:bearer|basic|token)\s+)?\S+/gi, `$1${REDACTED}`],
|
|
287
|
-
[new RegExp(COOKIE_HEADER_SOURCE, 'gi'), `$1${REDACTED}`],
|
|
288
|
-
[
|
|
289
|
-
new RegExp(`([?&#]${COMMAND_SENSITIVE_KEY_SOURCE}=)[^&\\s"'<>]+`, 'gi'),
|
|
290
|
-
`$1${REDACTED}`,
|
|
291
|
-
],
|
|
292
|
-
// Space-separated credential flags have no assignment operator or token
|
|
293
|
-
// prefix. Keep the flag for evidence, but never its value.
|
|
294
|
-
[
|
|
295
|
-
new RegExp(`(^|[\\s;&|(])(--${COMMAND_SENSITIVE_KEY_SOURCE}\\s+)${SHELL_WORD_SOURCE}`, 'gi'),
|
|
296
|
-
`$1$2${REDACTED}`,
|
|
297
|
-
],
|
|
788
|
+
...VALUE_REDACTIONS,
|
|
789
|
+
redactCredentialFlagValues,
|
|
298
790
|
// JSON bodies embedded in shell commands are still command text. Quoted
|
|
299
791
|
// keys evade the shell-assignment scanner below, including escaped values.
|
|
300
792
|
[
|
|
@@ -306,10 +798,11 @@ const COMMAND_REDACTIONS = [
|
|
|
306
798
|
// match, not just a bare word) followed by `=`/`:` and a value that is a
|
|
307
799
|
// double/single-quoted string (spaces and all) or an unquoted run up to the
|
|
308
800
|
// next whitespace. Anchored at a shell-token boundary ($1, preserved) so it
|
|
309
|
-
// never fires mid-word
|
|
801
|
+
// never fires mid-word (a backtick opens a substitution's first word, as
|
|
802
|
+
// `(` does); the whole value — quotes included — is redacted so
|
|
310
803
|
// whitespace-containing passphrases can't slip through.
|
|
311
804
|
[
|
|
312
|
-
new RegExp(`(^|[\\s;&|(])(${COMMAND_SENSITIVE_KEY_SOURCE}\\s*[=:]\\s*)${SHELL_WORD_SOURCE}`, 'gi'),
|
|
805
|
+
new RegExp(`(^|[\\s;&|(\\x60])(${COMMAND_SENSITIVE_KEY_SOURCE}\\s*[=:]\\s*)${SHELL_WORD_SOURCE}`, 'gi'),
|
|
313
806
|
`$1$2${REDACTED}`,
|
|
314
807
|
],
|
|
315
808
|
// Our own CLI token (`API_KEY_PREFIX`, the prefix the dashboard mints CLI
|
|
@@ -441,10 +934,16 @@ function redactCredentialAssignments(command, inputs) {
|
|
|
441
934
|
// retaining untouched words and also scanning unquoted multiword fields.
|
|
442
935
|
let text = body.replace(new RegExp(SHELL_WORD_SOURCE, 'g'), (word) => {
|
|
443
936
|
const decoded = decodeShellWord(word);
|
|
444
|
-
const redacted = decoded
|
|
937
|
+
const redacted = replaceValues(decoded, fields, redactField).text;
|
|
445
938
|
return redacted === decoded ? word : JSON.stringify(redacted);
|
|
446
939
|
});
|
|
447
|
-
|
|
940
|
+
// The body ends at the producer's operator, but a field's credential
|
|
941
|
+
// chain reaches on across a newline, as `redactCredentialFlagValues`
|
|
942
|
+
// does, so the chain's words after the body are edited too.
|
|
943
|
+
const fieldEdits = replaceValues(text, fields, redactField, assignments.slice(end));
|
|
944
|
+
text = fieldEdits.text;
|
|
945
|
+
for (const range of fieldEdits.after)
|
|
946
|
+
edits.push({ start: end + range.start, end: end + range.end, text: range.text });
|
|
448
947
|
// A printf template may take the credential from later arguments. Once
|
|
449
948
|
// its format is private, drop the argument list rather than expose the
|
|
450
949
|
// values substituted into it or attempt to interpret printf formatting.
|
|
@@ -454,20 +953,33 @@ function redactCredentialAssignments(command, inputs) {
|
|
|
454
953
|
edits.push({ start: producerStart, end, text });
|
|
455
954
|
producerStart = null;
|
|
456
955
|
};
|
|
956
|
+
// Decided at each command's first word, so a file argument named echo under
|
|
957
|
+
// a program that never runs its arguments is not mistaken for a producer.
|
|
958
|
+
// Wrappers are not looked past here, which only errs toward redaction.
|
|
959
|
+
let commandSeen = false;
|
|
960
|
+
let argumentsInert = false;
|
|
457
961
|
for (const word of words) {
|
|
458
|
-
if (/^[;&|()\n]$/.test(word[0]))
|
|
962
|
+
if (/^[;&|()\n]$/.test(word[0])) {
|
|
459
963
|
finishProducer(word.index);
|
|
460
|
-
|
|
461
|
-
|
|
964
|
+
commandSeen = false;
|
|
965
|
+
argumentsInert = false;
|
|
966
|
+
continue;
|
|
967
|
+
}
|
|
968
|
+
const decoded = decodeShellWord(word[0]);
|
|
969
|
+
if (!commandSeen && !/^\w+\+?=/.test(decoded)) {
|
|
970
|
+
commandSeen = true;
|
|
971
|
+
argumentsInert = isNonDispatching(assignments, word.index, decoded);
|
|
972
|
+
}
|
|
973
|
+
const tool = invokedTool(decoded);
|
|
974
|
+
// A backtick substitution runs its own command, whatever the outer one is.
|
|
975
|
+
const inert = argumentsInert && !word[0].includes('`');
|
|
976
|
+
if (producerStart === null && !inert && ['echo', 'printf'].includes(tool)) {
|
|
462
977
|
producerStart = word.index + word[0].length;
|
|
463
|
-
producerPrintf =
|
|
978
|
+
producerPrintf = tool === 'printf';
|
|
464
979
|
}
|
|
465
980
|
}
|
|
466
981
|
finishProducer(assignments.length);
|
|
467
|
-
|
|
468
|
-
for (const edit of edits.reverse())
|
|
469
|
-
result = result.slice(0, edit.start) + edit.text + result.slice(edit.end);
|
|
470
|
-
return result;
|
|
982
|
+
return applyRanges(assignments, edits);
|
|
471
983
|
}
|
|
472
984
|
/** Redact known tool credential flags without matching unrelated short flags. */
|
|
473
985
|
function redactToolArguments(command, inputs) {
|
|
@@ -477,6 +989,11 @@ function redactToolArguments(command, inputs) {
|
|
|
477
989
|
let options = true;
|
|
478
990
|
let opensslPasswd = false;
|
|
479
991
|
let opensslSubcommandRead = false;
|
|
992
|
+
// Set once a wrapper in the current command receives an option it does not
|
|
993
|
+
// model, which may have taken the next word as its value.
|
|
994
|
+
let wrapperOptionUnknown = false;
|
|
995
|
+
// The current command is a program that never runs its arguments.
|
|
996
|
+
let argumentsInert = false;
|
|
480
997
|
const variables = new Map();
|
|
481
998
|
const expansions = [];
|
|
482
999
|
const resolveVariable = (contents, depth = 0) => {
|
|
@@ -485,7 +1002,7 @@ function redactToolArguments(command, inputs) {
|
|
|
485
1002
|
return contents.replace(CURL_EXPANSION, (_, name) => resolveVariable(variables.get(name)?.at(-1)?.contents ?? '', depth + 1));
|
|
486
1003
|
};
|
|
487
1004
|
const finishCurl = () => {
|
|
488
|
-
const privateNames = new Set();
|
|
1005
|
+
const privateNames = new Set(inputs?.curlVariables);
|
|
489
1006
|
for (const expansion of expansions) {
|
|
490
1007
|
if (CURL_URL_FIELD.test(expansion.field) &&
|
|
491
1008
|
!PROXY_USERINFO.test(expansion.resolved) &&
|
|
@@ -511,6 +1028,8 @@ function redactToolArguments(command, inputs) {
|
|
|
511
1028
|
});
|
|
512
1029
|
}
|
|
513
1030
|
}
|
|
1031
|
+
for (const name of privateNames)
|
|
1032
|
+
inputs?.curlVariables.add(name);
|
|
514
1033
|
variables.clear();
|
|
515
1034
|
expansions.length = 0;
|
|
516
1035
|
};
|
|
@@ -524,6 +1043,8 @@ function redactToolArguments(command, inputs) {
|
|
|
524
1043
|
client = null;
|
|
525
1044
|
opensslPasswd = false;
|
|
526
1045
|
opensslSubcommandRead = false;
|
|
1046
|
+
wrapperOptionUnknown = false;
|
|
1047
|
+
argumentsInert = false;
|
|
527
1048
|
options = true;
|
|
528
1049
|
continue;
|
|
529
1050
|
}
|
|
@@ -576,7 +1097,11 @@ function redactToolArguments(command, inputs) {
|
|
|
576
1097
|
}
|
|
577
1098
|
}
|
|
578
1099
|
const executable = invokedTool(decoded);
|
|
579
|
-
|
|
1100
|
+
// A backtick substitution runs its own command, whatever the outer one is.
|
|
1101
|
+
const inert = argumentsInert && !raw.includes('`');
|
|
1102
|
+
if (!inert &&
|
|
1103
|
+
WRAPPER_VALUE_FLAGS[executable] &&
|
|
1104
|
+
!CREDENTIAL_TOOLS.includes(client ?? '')) {
|
|
580
1105
|
client = executable;
|
|
581
1106
|
options = true;
|
|
582
1107
|
continue;
|
|
@@ -584,7 +1109,8 @@ function redactToolArguments(command, inputs) {
|
|
|
584
1109
|
// Dispatching tools may take their own options and positional arguments.
|
|
585
1110
|
// Once a credential-consuming tool appears, its subsequent flags still carry
|
|
586
1111
|
// credentials; a wrapper's -u or timeout must not hide that client.
|
|
587
|
-
if (
|
|
1112
|
+
if (!inert &&
|
|
1113
|
+
CREDENTIAL_TOOLS.includes(executable) &&
|
|
588
1114
|
!CREDENTIAL_TOOLS.includes(client ?? '')) {
|
|
589
1115
|
client = executable;
|
|
590
1116
|
opensslPasswd = false;
|
|
@@ -592,11 +1118,30 @@ function redactToolArguments(command, inputs) {
|
|
|
592
1118
|
options = true;
|
|
593
1119
|
continue;
|
|
594
1120
|
}
|
|
1121
|
+
// A wrapper's first operand (its modelled valued options were consumed
|
|
1122
|
+
// above) is the program it runs, which decides whether later words can be
|
|
1123
|
+
// invocations. After an option the table does not model, that operand may
|
|
1124
|
+
// be the option's value instead, so the wrapper keeps dispatching.
|
|
1125
|
+
if (client !== null && WRAPPER_VALUE_FLAGS[client]) {
|
|
1126
|
+
if (options && decoded.startsWith('-')) {
|
|
1127
|
+
if (decoded !== '--' &&
|
|
1128
|
+
!decoded.includes('=') &&
|
|
1129
|
+
!WRAPPER_SWITCHES[client]?.test(decoded))
|
|
1130
|
+
wrapperOptionUnknown = true;
|
|
1131
|
+
}
|
|
1132
|
+
else if (!wrapperOptionUnknown) {
|
|
1133
|
+
client = executable;
|
|
1134
|
+
argumentsInert = isNonDispatching(command, word.index, decoded);
|
|
1135
|
+
options = true;
|
|
1136
|
+
continue;
|
|
1137
|
+
}
|
|
1138
|
+
}
|
|
595
1139
|
if (client === null) {
|
|
596
1140
|
if (/^[\w]+=.*/.test(decoded) ||
|
|
597
1141
|
['env', 'sudo', 'command', 'exec', 'nohup'].includes(decoded))
|
|
598
1142
|
continue;
|
|
599
1143
|
client = invokedTool(decoded);
|
|
1144
|
+
argumentsInert = isNonDispatching(command, word.index, decoded);
|
|
600
1145
|
continue;
|
|
601
1146
|
}
|
|
602
1147
|
if (client === 'openssl' && !opensslSubcommandRead && !decoded.startsWith('-')) {
|
|
@@ -804,6 +1349,46 @@ function redactToolArguments(command, inputs) {
|
|
|
804
1349
|
}
|
|
805
1350
|
}
|
|
806
1351
|
finishCurl();
|
|
1352
|
+
// Every edit above replaces a whole word, and a word an edit replaces is
|
|
1353
|
+
// gone before `redactCredentialFlagValues` reads the text. Where that word
|
|
1354
|
+
// was a credential flag, the flag's value would escape, so the chain is
|
|
1355
|
+
// followed here for every edit, whichever tool or flag made it.
|
|
1356
|
+
const wordAt = new Map(words.map((word, at) => [word.index, at]));
|
|
1357
|
+
const wordEndingAt = new Map(words.map((word, at) => [word.index + word[0].length, at]));
|
|
1358
|
+
const nextWord = (end) => {
|
|
1359
|
+
let next = wordEndingAt.get(end);
|
|
1360
|
+
if (next === undefined)
|
|
1361
|
+
return undefined;
|
|
1362
|
+
next += 1;
|
|
1363
|
+
while (words[next] && /^(?:\n|\\\r?\n)$/.test(words[next][0]))
|
|
1364
|
+
next += 1;
|
|
1365
|
+
const following = words[next];
|
|
1366
|
+
if (!following || /^[;&|()\n]$/.test(following[0]))
|
|
1367
|
+
return undefined;
|
|
1368
|
+
return {
|
|
1369
|
+
word: following[0],
|
|
1370
|
+
start: following.index,
|
|
1371
|
+
end: following.index + following[0].length,
|
|
1372
|
+
};
|
|
1373
|
+
};
|
|
1374
|
+
const chainedWords = new Set();
|
|
1375
|
+
const chainEdits = edits.flatMap((edit) => {
|
|
1376
|
+
const at = wordAt.get(edit.start);
|
|
1377
|
+
const word = at === undefined ? undefined : words[at];
|
|
1378
|
+
if (!word || edit.end !== word.index + word[0].length)
|
|
1379
|
+
return [];
|
|
1380
|
+
return followCredentialChain(word[0], edit.end, nextWord, chainedWords, command);
|
|
1381
|
+
});
|
|
1382
|
+
// A chain edit either matches an edit's word or misses it; on a match the
|
|
1383
|
+
// whole-word redaction replaces it, so the chain only ever adds.
|
|
1384
|
+
const editAt = new Map(edits.map((edit, at) => [edit.start, at]));
|
|
1385
|
+
for (const chainEdit of chainEdits) {
|
|
1386
|
+
const same = editAt.get(chainEdit.start);
|
|
1387
|
+
if (same === undefined)
|
|
1388
|
+
edits.push(chainEdit);
|
|
1389
|
+
else
|
|
1390
|
+
edits[same] = chainEdit;
|
|
1391
|
+
}
|
|
807
1392
|
edits.sort((a, b) => a.start - b.start);
|
|
808
1393
|
for (const edit of edits.reverse())
|
|
809
1394
|
command = command.slice(0, edit.start) + edit.text + command.slice(edit.end);
|
|
@@ -815,6 +1400,7 @@ function getToolInputs(command, depth = 0) {
|
|
|
815
1400
|
curlConfig: depth > 8,
|
|
816
1401
|
env: new Set(),
|
|
817
1402
|
passSources: new Set(),
|
|
1403
|
+
curlVariables: new Set(),
|
|
818
1404
|
};
|
|
819
1405
|
if (depth > 8)
|
|
820
1406
|
return inputs;
|
|
@@ -831,6 +1417,8 @@ function getToolInputs(command, depth = 0) {
|
|
|
831
1417
|
inputs.env.add(key);
|
|
832
1418
|
for (const source of nested.passSources)
|
|
833
1419
|
inputs.passSources.add(source);
|
|
1420
|
+
for (const name of nested.curlVariables)
|
|
1421
|
+
inputs.curlVariables.add(name);
|
|
834
1422
|
}
|
|
835
1423
|
if (inputs.curlConfig) {
|
|
836
1424
|
for (const word of words) {
|
|
@@ -844,21 +1432,97 @@ function getToolInputs(command, depth = 0) {
|
|
|
844
1432
|
return inputs;
|
|
845
1433
|
}
|
|
846
1434
|
/** Apply the same boundary to scripts carried inside quoted shell words. */
|
|
847
|
-
|
|
1435
|
+
/** The part of `before` that `after` replaced: what a redaction removed. */
|
|
1436
|
+
function changedPart(before, after) {
|
|
1437
|
+
let start = 0;
|
|
1438
|
+
while (start < before.length && before[start] === after[start])
|
|
1439
|
+
start += 1;
|
|
1440
|
+
let end = 0;
|
|
1441
|
+
while (end < before.length - start &&
|
|
1442
|
+
before[before.length - 1 - end] === after[after.length - 1 - end])
|
|
1443
|
+
end += 1;
|
|
1444
|
+
return before.slice(start, before.length - end);
|
|
1445
|
+
}
|
|
1446
|
+
function redactShellSyntax(command, depth = 0, inheritedInputs, valuesFirst = true) {
|
|
848
1447
|
// Nested shell/eval wrappers cannot create unbounded recursive scanning.
|
|
849
1448
|
if (depth > 8)
|
|
850
1449
|
return REDACTED;
|
|
851
|
-
|
|
1450
|
+
// Redacting a backtick body can erase the reference (`{{VALUE}}`) that
|
|
1451
|
+
// marks an outer assignment private, so read its inputs from the command
|
|
1452
|
+
// as written too.
|
|
1453
|
+
const writtenInputs = command.includes('`')
|
|
1454
|
+
? withInheritedInputs(backtickAwareInputs(command), inheritedInputs)
|
|
1455
|
+
: inheritedInputs;
|
|
1456
|
+
// Rewriting part of a backtick body can separate a header from its value, so
|
|
1457
|
+
// the value patterns redact such a match before any body is rewritten. Only
|
|
1458
|
+
// such a match: one whose value the later passes remove anyway is left to
|
|
1459
|
+
// them, since the word-based rules see the rest of its word (`"Cookie: a"b`).
|
|
1460
|
+
// Redacting one match early changes what the later passes see, so a value
|
|
1461
|
+
// still in their output is redacted early too and the passes run again.
|
|
1462
|
+
if (valuesFirst && command.includes('`') && !command.includes('\uE000')) {
|
|
1463
|
+
const values = valueRedactionTargets(command);
|
|
1464
|
+
if (values.length > 0) {
|
|
1465
|
+
const withoutValuesFirst = redactShellSyntax(command, depth, inheritedInputs, false);
|
|
1466
|
+
const early = new Set(values.filter((value) => withoutValuesFirst.includes(value)));
|
|
1467
|
+
for (let round = 0;; round += 1) {
|
|
1468
|
+
const out = redactAfterValues(redactValuesFirst(command, round < 3 ? early : new Set(values)), depth, writtenInputs);
|
|
1469
|
+
const stranded = values.filter((value) => !early.has(value) && out.includes(value));
|
|
1470
|
+
if (stranded.length === 0 || round === 3)
|
|
1471
|
+
return out;
|
|
1472
|
+
for (const value of stranded)
|
|
1473
|
+
early.add(value);
|
|
1474
|
+
}
|
|
1475
|
+
}
|
|
1476
|
+
}
|
|
1477
|
+
return redactAfterValues(command, depth, writtenInputs, valuesFirst);
|
|
1478
|
+
}
|
|
1479
|
+
/** The values the `VALUE_REDACTIONS` would remove from text with no backtick in the match. */
|
|
1480
|
+
function valueRedactionTargets(command) {
|
|
1481
|
+
const values = [];
|
|
1482
|
+
for (const [pattern, replacement] of VALUE_REDACTIONS) {
|
|
1483
|
+
const single = new RegExp(pattern.source, pattern.flags.replace('g', ''));
|
|
1484
|
+
for (const match of command.match(pattern) ?? []) {
|
|
1485
|
+
if (/[`\uE000\uE001]/.test(match))
|
|
1486
|
+
continue;
|
|
1487
|
+
const value = changedPart(match, match.replace(single, replacement));
|
|
1488
|
+
if (value !== '' && !values.includes(value))
|
|
1489
|
+
values.push(value);
|
|
1490
|
+
}
|
|
1491
|
+
}
|
|
1492
|
+
return values;
|
|
1493
|
+
}
|
|
1494
|
+
/**
|
|
1495
|
+
* Apply the value patterns whose value is in `early`, holding every other
|
|
1496
|
+
* match aside so no later pattern can match part of it. A match that spans a
|
|
1497
|
+
* backtick is always held: the substitution pass owns that boundary, and an
|
|
1498
|
+
* unterminated one must still fail closed. A reference this erases is still
|
|
1499
|
+
* in the caller's `writtenInputs`.
|
|
1500
|
+
*/
|
|
1501
|
+
function redactValuesFirst(command, early) {
|
|
1502
|
+
const held = [];
|
|
1503
|
+
let whole = command;
|
|
1504
|
+
for (const [pattern, replacement] of VALUE_REDACTIONS) {
|
|
1505
|
+
const single = new RegExp(pattern.source, pattern.flags.replace('g', ''));
|
|
1506
|
+
whole = whole.replace(pattern, (match) => {
|
|
1507
|
+
const redacted = match.replace(single, replacement);
|
|
1508
|
+
if (!/[`\uE000\uE001]/.test(match) && early.has(changedPart(match, redacted)))
|
|
1509
|
+
return redacted;
|
|
1510
|
+
held.push(match);
|
|
1511
|
+
return `\uE000${held.length - 1}\uE001`;
|
|
1512
|
+
});
|
|
1513
|
+
}
|
|
1514
|
+
// A held match can contain an earlier one; each refers only to earlier ones.
|
|
1515
|
+
while (whole.includes('\uE000'))
|
|
1516
|
+
whole = whole.replace(/\uE000(\d+)\uE001/g, (_, index) => held[Number(index)]);
|
|
1517
|
+
return whole;
|
|
1518
|
+
}
|
|
1519
|
+
function redactAfterValues(command, depth, writtenInputs, valuesFirst = true) {
|
|
1520
|
+
command = redactCommandSubstitutions(command, depth, writtenInputs, valuesFirst);
|
|
852
1521
|
// Nested scripts may be dispatched by any tool. Scan decoded compound
|
|
853
1522
|
// words instead of maintaining another list of interpreter/wrapper flags.
|
|
854
1523
|
// JSON and sensitive assignments already have structural/whole-word passes;
|
|
855
1524
|
// treating their values as commands would lose evidence or word boundaries.
|
|
856
|
-
const inputs =
|
|
857
|
-
inputs.curlConfig ||= inheritedInputs?.curlConfig ?? false;
|
|
858
|
-
for (const key of inheritedInputs?.env ?? [])
|
|
859
|
-
inputs.env.add(key);
|
|
860
|
-
for (const source of inheritedInputs?.passSources ?? [])
|
|
861
|
-
inputs.passSources.add(source);
|
|
1525
|
+
const inputs = withInheritedInputs(backtickAwareInputs(command), writtenInputs);
|
|
862
1526
|
const sensitiveAssignment = new RegExp(`^(${COMMAND_SENSITIVE_KEY_SOURCE})(\\+?=)`, 'i');
|
|
863
1527
|
// Resolve credential references before nested word redaction can erase the
|
|
864
1528
|
// expansion marker while leaving its original variable definition behind.
|
|
@@ -882,13 +1546,10 @@ function redactShellSyntax(command, depth = 0, inheritedInputs) {
|
|
|
882
1546
|
// structural JSON pass instead of the nested-command pass.
|
|
883
1547
|
}
|
|
884
1548
|
}
|
|
885
|
-
const redacted = redactShellSyntax(decoded, depth + 1, inputs);
|
|
1549
|
+
const redacted = redactShellSyntax(decoded, depth + 1, inputs, valuesFirst);
|
|
886
1550
|
return redacted === decoded ? word : JSON.stringify(redacted);
|
|
887
1551
|
});
|
|
888
|
-
|
|
889
|
-
for (const [pattern, replacement] of COMMAND_REDACTIONS)
|
|
890
|
-
out = out.replace(pattern, replacement);
|
|
891
|
-
return out;
|
|
1552
|
+
return applyCommandRedactions(redactCredentialAssignments(out, inputs));
|
|
892
1553
|
}
|
|
893
1554
|
/**
|
|
894
1555
|
* Redact common secret shapes from a shell command and cap its length so
|