@mossbear/eval-core 0.1.0-alpha.107 → 0.1.0-alpha.108
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/README.md +1 -1
- package/dist/redact-prose.d.ts +5 -41
- package/dist/redact-prose.d.ts.map +1 -1
- package/dist/redact-prose.js +9 -67
- package/dist/redact-prose.js.map +1 -1
- package/dist/redact-prose.test.js +2 -6
- package/dist/redact-prose.test.js.map +1 -1
- package/dist/redaction.d.ts +2 -10
- package/dist/redaction.d.ts.map +1 -1
- package/dist/redaction.js +2 -10
- package/dist/redaction.js.map +1 -1
- package/dist/sanitize-command.d.ts +0 -19
- package/dist/sanitize-command.d.ts.map +1 -1
- package/dist/sanitize-command.js +73 -1842
- package/dist/sanitize-command.js.map +1 -1
- package/dist/sanitize-command.test.js +44 -1412
- package/dist/sanitize-command.test.js.map +1 -1
- package/package.json +2 -2
package/dist/sanitize-command.js
CHANGED
|
@@ -1,1866 +1,97 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
2
|
+
* Command evidence is an executable label, never arguments or shell source.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
* every surviving argument is credential-free.
|
|
4
|
+
* Only literal, known executable names are admitted. An arbitrary first word
|
|
5
|
+
* can itself be a credential or file content, so unsupported syntax and custom
|
|
6
|
+
* executables produce a fixed withholding marker. Wrappers are not unwrapped:
|
|
7
|
+
* `sh -c ...` reports `sh`, and nothing inside its script is inspected.
|
|
9
8
|
*
|
|
10
|
-
*
|
|
11
|
-
* result: this is the boundary that decides what a grader may ever see of a
|
|
12
|
-
* command.
|
|
9
|
+
* The exported name is retained for callers that also print hook diagnostics.
|
|
13
10
|
*/
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
* Short commands convey which tools the agent invoked — which is all a grader
|
|
19
|
-
* needs; anything longer is bulk payload (heredocs, inline file writes) that
|
|
20
|
-
* must not leave the machine.
|
|
21
|
-
*/
|
|
22
|
-
const MAX_COMMAND_CHARS = 200;
|
|
23
|
-
// SSHPASS, REDISCLI_AUTH, VALKEYCLI_AUTH and MYSQL_PWD are each tool's dedicated
|
|
24
|
-
// environment credential, despite not containing the usual password/token spelling.
|
|
25
|
-
const COMMAND_SENSITIVE_KEY_SOURCE = `(?:${SENSITIVE_KEY_SOURCE}|SSHPASS|REDISCLI_AUTH|VALKEYCLI_AUTH|MYSQL_PWD)`;
|
|
26
|
-
const SENSITIVE_KEY = new RegExp(`^${COMMAND_SENSITIVE_KEY_SOURCE}$`, 'i');
|
|
27
|
-
const SENSITIVE_CONTENT = new RegExp(SENSITIVE_WORD_SOURCE, 'i');
|
|
28
|
-
// Only switches that consume no argument may precede an argument flag in
|
|
29
|
-
// curl's short-option clusters. For example, -sx is proxy auth, while -osx
|
|
30
|
-
// names an output file and must not be interpreted as a proxy option.
|
|
31
|
-
const CURL_SHORT_SWITCHES = '[012346aBfgGiIjJklLMnNOpqRsSvVZ:#]*';
|
|
32
|
-
const CURL_PAYLOAD_FLAG = new RegExp(`^(-${CURL_SHORT_SWITCHES}[dHF])(.+)?$|^(--(?:data|data-raw|data-binary|data-urlencode|json|form|form-string|header|proxy-header))(?:=(.*))?$`);
|
|
33
|
-
const CURL_CONFIG_FLAG = new RegExp(`^(?:-${CURL_SHORT_SWITCHES}K|--config(?:=|$))`);
|
|
34
|
-
const CURL_URL_OPTIONS = 'url|proxy|proxy1\\.0|preproxy|socks4|socks4a|socks5|socks5-hostname';
|
|
35
|
-
const CURL_URL_FIELD = new RegExp(`^(?:${CURL_URL_OPTIONS})$`, 'i');
|
|
36
|
-
// Tool families share one credential syntax, so each family is defined once and
|
|
37
|
-
// every member inherits its password flags, URI handling and attached-value rules.
|
|
38
|
-
// MySQL-family clients read a bare -p as a password prompt, so only the attached
|
|
39
|
-
// -p<password> spelling carries a credential.
|
|
40
|
-
const MYSQL_FAMILY = [
|
|
41
|
-
'mysql',
|
|
42
|
-
'mariadb',
|
|
43
|
-
'mysqldump',
|
|
44
|
-
'mysqladmin',
|
|
45
|
-
'mariadb-dump',
|
|
46
|
-
'mariadb-admin',
|
|
47
|
-
];
|
|
48
|
-
// redis-cli and valkey-cli (a Redis fork) take the same password flags and URIs.
|
|
49
|
-
const REDIS_FAMILY = ['redis-cli', 'valkey-cli'];
|
|
50
|
-
const MYSQL_PASSWORD_FLAG = /^(-p)(.*)$/;
|
|
51
|
-
const REDIS_PASSWORD_FLAG = /^(-a|--pass|--cluster-from-pass)(?:=(.*))?$/;
|
|
52
|
-
// Both families accept redis:// and valkey:// URIs, with or without TLS (`s`).
|
|
53
|
-
const REDIS_URI = /^(?:rediss?|valkeys?):\/\//i;
|
|
54
|
-
/**
|
|
55
|
-
* The tool a word invokes: its basename, lowercased, without a Windows
|
|
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.
|
|
59
|
-
*/
|
|
60
|
-
function invokedTool(word) {
|
|
61
|
-
return (word.replaceAll('`', '').split(/[\\/]/).at(-1) ?? word)
|
|
62
|
-
.replace(/\.exe$/i, '')
|
|
63
|
-
.toLowerCase();
|
|
64
|
-
}
|
|
65
|
-
const CREDENTIAL_TOOLS = [
|
|
66
|
-
'curl',
|
|
67
|
-
'http',
|
|
68
|
-
'https',
|
|
69
|
-
'httpie',
|
|
70
|
-
'openssl',
|
|
71
|
-
'sshpass',
|
|
72
|
-
...REDIS_FAMILY,
|
|
73
|
-
...MYSQL_FAMILY,
|
|
74
|
-
];
|
|
75
|
-
// Password flags by tool. Each regex captures the flag name and, when the value
|
|
76
|
-
// is attached, the value itself.
|
|
77
|
-
const DIRECT_PASSWORD_FLAGS = new Map([
|
|
78
|
-
[
|
|
79
|
-
'openssl',
|
|
80
|
-
/^(-{1,2}(?:pass(?:in|out)?|password|srppass|pwri_password|secretkey|hmac|[kK]))(?:=(.*))?$/,
|
|
81
|
-
],
|
|
82
|
-
['sshpass', /^(-[46v]*p)(.*)$/],
|
|
83
|
-
...REDIS_FAMILY.map((tool) => [tool, REDIS_PASSWORD_FLAG]),
|
|
84
|
-
...MYSQL_FAMILY.map((tool) => [tool, MYSQL_PASSWORD_FLAG]),
|
|
85
|
-
]);
|
|
86
|
-
// Valued wrapper options name users, files or scripts, never the child tool.
|
|
87
|
-
const WRAPPER_VALUE_FLAGS = {
|
|
88
|
-
sudo: /^-(?:[AbEHIKknPSVbveis]*[ughpDTRCrt])(.+)?$|^--(?:user|group|host|prompt|chdir|chroot|command-timeout|close-from|role|type)(?:=(.*))?$/,
|
|
89
|
-
doas: /^-[uC](.+)?$/,
|
|
90
|
-
env: /^-[uCSa](.+)?$|^--(?:unset|chdir|split-string|argv0)(?:=(.*))?$/,
|
|
91
|
-
time: /^-[of](.+)?$|^--(?:output|format)(?:=(.*))?$/,
|
|
92
|
-
xargs: /^-[adEILnPs](.+)?$|^--(?:arg-file|delimiter|eof|replace|max-lines|max-args|max-procs|max-chars)(?:=(.*))?$/,
|
|
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',
|
|
11
|
+
const EXECUTABLES = new Set([
|
|
12
|
+
'awk',
|
|
13
|
+
'bash',
|
|
14
|
+
'bun',
|
|
110
15
|
'cat',
|
|
111
|
-
'
|
|
16
|
+
'cd',
|
|
112
17
|
'chmod',
|
|
113
18
|
'chown',
|
|
114
19
|
'cmp',
|
|
115
20
|
'cp',
|
|
21
|
+
'curl',
|
|
22
|
+
'cut',
|
|
23
|
+
'date',
|
|
24
|
+
'deno',
|
|
116
25
|
'diff',
|
|
117
|
-
'
|
|
118
|
-
'
|
|
119
|
-
'
|
|
26
|
+
'docker',
|
|
27
|
+
'echo',
|
|
28
|
+
'env',
|
|
29
|
+
'find',
|
|
30
|
+
'gh',
|
|
31
|
+
'git',
|
|
32
|
+
'go',
|
|
120
33
|
'grep',
|
|
121
34
|
'head',
|
|
122
|
-
'
|
|
35
|
+
'java',
|
|
36
|
+
'jq',
|
|
37
|
+
'less',
|
|
123
38
|
'ls',
|
|
39
|
+
'make',
|
|
124
40
|
'mkdir',
|
|
41
|
+
'mossbear',
|
|
125
42
|
'mv',
|
|
126
|
-
'
|
|
127
|
-
'
|
|
43
|
+
'mysql',
|
|
44
|
+
'mysqladmin',
|
|
45
|
+
'node',
|
|
46
|
+
'npm',
|
|
47
|
+
'npx',
|
|
48
|
+
'openssl',
|
|
49
|
+
'perl',
|
|
50
|
+
'php',
|
|
51
|
+
'pip',
|
|
52
|
+
'pip3',
|
|
53
|
+
'pnpm',
|
|
54
|
+
'powershell',
|
|
55
|
+
'printf',
|
|
56
|
+
'ps',
|
|
57
|
+
'psql',
|
|
58
|
+
'pwsh',
|
|
59
|
+
'python',
|
|
60
|
+
'python3',
|
|
61
|
+
'redis-cli',
|
|
62
|
+
'rg',
|
|
128
63
|
'rm',
|
|
129
|
-
'
|
|
64
|
+
'ruby',
|
|
65
|
+
'rustc',
|
|
66
|
+
'cargo',
|
|
67
|
+
'sed',
|
|
68
|
+
'sh',
|
|
69
|
+
'sort',
|
|
70
|
+
'ssh',
|
|
71
|
+
'sudo',
|
|
130
72
|
'tail',
|
|
73
|
+
'tar',
|
|
74
|
+
'tee',
|
|
75
|
+
'test',
|
|
131
76
|
'touch',
|
|
77
|
+
'tr',
|
|
78
|
+
'true',
|
|
79
|
+
'tsc',
|
|
80
|
+
'unzip',
|
|
81
|
+
'uv',
|
|
82
|
+
'vitest',
|
|
132
83
|
'wc',
|
|
84
|
+
'wget',
|
|
133
85
|
'which',
|
|
86
|
+
'xargs',
|
|
87
|
+
'yarn',
|
|
88
|
+
'zsh',
|
|
134
89
|
]);
|
|
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
|
-
}
|
|
156
|
-
const CURL_AUTH_OPTIONS = `user|proxy-user|${CURL_URL_OPTIONS}|cookie|oauth2-bearer|cert|proxy-cert|pass|proxy-pass`;
|
|
157
|
-
const CURL_AUTH_FLAG = new RegExp(`^(-${CURL_SHORT_SWITCHES}[uUbEx])(.+)?$|^(--(?:${CURL_AUTH_OPTIONS}))(?:=(.*))?$`);
|
|
158
|
-
const CURL_EXPANSION = /\{\{([A-Za-z0-9_]+)(?::[^{}]*)?\}\}/g;
|
|
159
|
-
const CURL_EXPAND_FLAG = new RegExp(`^--expand-(${CURL_AUTH_OPTIONS}|header|proxy-header|data|data-raw|data-binary|data-urlencode|json|form|form-string)(?:=(.*))?$`);
|
|
160
|
-
function hasPrivateConfigVariables(body) {
|
|
161
|
-
if (!/\b(?:expand-)?variable\s*[=: \t]/i.test(body))
|
|
162
|
-
return false;
|
|
163
|
-
if (/\bexpand-(?:user|proxy-user|oauth2-bearer|cookie|cert|proxy-cert|pass|proxy-pass|header|proxy-header|data(?:-raw|-binary|-urlencode)?|json|form(?:-string)?)\s*[=: \t]/i.test(body))
|
|
164
|
-
return true;
|
|
165
|
-
return (/\bexpand-(?:url|proxy|preproxy|socks[45](?:a|-hostname)?)\s*[=: \t]/i.test(body) &&
|
|
166
|
-
(/[^/\s:@]+:[^@\s]*@/.test(body) ||
|
|
167
|
-
new RegExp(`[?&#]${COMMAND_SENSITIVE_KEY_SOURCE}=`, 'i').test(body)));
|
|
168
|
-
}
|
|
169
|
-
function decodeShellWord(word) {
|
|
170
|
-
return word.replace(/\$'((?:\\[\s\S]|[^'\\])*)'|'([^']*)'|"((?:\\.|[^"\\])*)"|\\([\s\S])|([^'"\\]+)/g, (_, ansi, single, double, escaped, plain) => {
|
|
171
|
-
if (ansi !== undefined)
|
|
172
|
-
return ansi.replace(/\\(?:x([\da-f]{1,2})|u([\da-f]{1,4})|U([\da-f]{1,8})|([0-7]{1,3})|([\s\S]))/gi, (escape, hex, unicode, wide, octal, character) => {
|
|
173
|
-
if (hex || unicode || wide || octal) {
|
|
174
|
-
const code = Number.parseInt(hex ?? unicode ?? wide ?? octal, octal ? 8 : 16);
|
|
175
|
-
return code <= 0x10ffff ? String.fromCodePoint(code) : escape;
|
|
176
|
-
}
|
|
177
|
-
const controls = {
|
|
178
|
-
a: '\x07',
|
|
179
|
-
b: '\b',
|
|
180
|
-
e: '\x1b',
|
|
181
|
-
E: '\x1b',
|
|
182
|
-
f: '\f',
|
|
183
|
-
n: '\n',
|
|
184
|
-
r: '\r',
|
|
185
|
-
t: '\t',
|
|
186
|
-
v: '\v',
|
|
187
|
-
};
|
|
188
|
-
return controls[character] ?? (/['"\\?]/.test(character) ? character : escape);
|
|
189
|
-
});
|
|
190
|
-
if (single !== undefined)
|
|
191
|
-
return single;
|
|
192
|
-
if (double !== undefined)
|
|
193
|
-
return double.replace(/\\([$`"\\\n])/g, (_, character) => character === '\n' ? '' : character);
|
|
194
|
-
return escaped ?? plain ?? '';
|
|
195
|
-
});
|
|
196
|
-
}
|
|
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
|
-
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
|
-
}
|
|
298
|
-
function redactCommandSubstitutions(command, depth = 0, inheritedInputs, valuesFirst = true) {
|
|
299
|
-
if (depth > 8)
|
|
300
|
-
return REDACTED;
|
|
301
|
-
const marker = '__SUBSTITUTION_BOUNDARY__';
|
|
302
|
-
if (command.includes(marker))
|
|
303
|
-
return REDACTED;
|
|
304
|
-
let contextInputs;
|
|
305
|
-
// A body also feeds, or consumes, the commands around it.
|
|
306
|
-
const surroundingInputs = () => (contextInputs ??= withInheritedInputs(backtickAwareInputs(command), inheritedInputs));
|
|
307
|
-
const isCredentialInput = (start, end) => {
|
|
308
|
-
const probe = command.slice(0, start) + marker + command.slice(end + 1);
|
|
309
|
-
const inputs = getToolInputs(probe);
|
|
310
|
-
const redacted = redactCredentialAssignments(redactToolArguments(redactJsonArguments(probe), inputs), inputs);
|
|
311
|
-
return !applyCommandRedactions(redacted).includes(marker);
|
|
312
|
-
};
|
|
313
|
-
const findEnd = (start, closing, depth) => {
|
|
314
|
-
if (depth > 8)
|
|
315
|
-
return command.length;
|
|
316
|
-
// A backtick body ends at the first unescaped backtick, as bash reads it:
|
|
317
|
-
// quotes inside it do not hide one, and `\` escapes even inside `'…'`.
|
|
318
|
-
if (closing === '`') {
|
|
319
|
-
for (let index = start; index < command.length; index += 1) {
|
|
320
|
-
if (command[index] === '\\')
|
|
321
|
-
index += 1;
|
|
322
|
-
else if (command[index] === '`')
|
|
323
|
-
return index;
|
|
324
|
-
}
|
|
325
|
-
return command.length;
|
|
326
|
-
}
|
|
327
|
-
let quote = null;
|
|
328
|
-
let groups = 0;
|
|
329
|
-
for (let index = start; index < command.length; index += 1) {
|
|
330
|
-
const char = command[index];
|
|
331
|
-
if (char === '\\' && quote !== "'") {
|
|
332
|
-
index += 1;
|
|
333
|
-
continue;
|
|
334
|
-
}
|
|
335
|
-
if (quote === "'") {
|
|
336
|
-
if (char === "'")
|
|
337
|
-
quote = null;
|
|
338
|
-
continue;
|
|
339
|
-
}
|
|
340
|
-
if (char === '$' && command[index + 1] === '(') {
|
|
341
|
-
index = findEnd(index + 2, ')', depth + 1);
|
|
342
|
-
continue;
|
|
343
|
-
}
|
|
344
|
-
if (char === '`') {
|
|
345
|
-
index = findEnd(index + 1, '`', depth + 1);
|
|
346
|
-
continue;
|
|
347
|
-
}
|
|
348
|
-
if (char === "'" && quote === null)
|
|
349
|
-
quote = "'";
|
|
350
|
-
else if (char === '"')
|
|
351
|
-
quote = quote === '"' ? null : '"';
|
|
352
|
-
else if (quote === null && char === '(')
|
|
353
|
-
groups += 1;
|
|
354
|
-
else if (quote === null && char === ')') {
|
|
355
|
-
if (groups === 0 && closing === ')')
|
|
356
|
-
return index;
|
|
357
|
-
groups -= 1;
|
|
358
|
-
}
|
|
359
|
-
}
|
|
360
|
-
return command.length;
|
|
361
|
-
};
|
|
362
|
-
let quote = null;
|
|
363
|
-
let out = '';
|
|
364
|
-
let substitutions = 0;
|
|
365
|
-
// Where in `out` the open double quote began.
|
|
366
|
-
let quoteOpenedAt = 0;
|
|
367
|
-
for (let index = 0; index < command.length; index += 1) {
|
|
368
|
-
const char = command[index];
|
|
369
|
-
if (char === '\\' && quote !== "'") {
|
|
370
|
-
out += command.slice(index, index + 2);
|
|
371
|
-
index += 1;
|
|
372
|
-
continue;
|
|
373
|
-
}
|
|
374
|
-
if (quote !== "'" && (char === '`' || (char === '$' && command[index + 1] === '('))) {
|
|
375
|
-
if (++substitutions > 64)
|
|
376
|
-
return out + REDACTED;
|
|
377
|
-
const start = index;
|
|
378
|
-
const bodyStart = index + (char === '`' ? 1 : 2);
|
|
379
|
-
index = findEnd(bodyStart, char === '`' ? '`' : ')', 0);
|
|
380
|
-
const original = command.slice(start, index + 1);
|
|
381
|
-
if (isCredentialInput(start, index))
|
|
382
|
-
out += REDACTED;
|
|
383
|
-
else if (char === '`') {
|
|
384
|
-
// In the command as written, an unterminated backtick leaves the
|
|
385
|
-
// body's extent unknown: fail closed. A nested pass sees words the
|
|
386
|
-
// parser split at a backtick (`"a b"\``), so it parses the rest instead.
|
|
387
|
-
const unterminated = command[index] !== '`';
|
|
388
|
-
if (unterminated && depth === 0)
|
|
389
|
-
return `${out}\`${REDACTED}`;
|
|
390
|
-
const body = unescapeBacktickBody(command.slice(bodyStart, index), quote === '"');
|
|
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}`;
|
|
398
|
-
out +=
|
|
399
|
-
redacted === body
|
|
400
|
-
? original
|
|
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
|
-
}
|
|
416
|
-
}
|
|
417
|
-
else if (original.includes('`')) {
|
|
418
|
-
// `(` already exposes this body to the outer parse; only backticks
|
|
419
|
-
// nested inside it need a pass of their own.
|
|
420
|
-
const body = command.slice(bodyStart, index);
|
|
421
|
-
out += `$(${redactCommandSubstitutions(body, depth + 1, surroundingInputs(), valuesFirst)}${command.slice(index, index + 1)}`;
|
|
422
|
-
}
|
|
423
|
-
else
|
|
424
|
-
out += original;
|
|
425
|
-
continue;
|
|
426
|
-
}
|
|
427
|
-
if (char === "'" && quote !== '"')
|
|
428
|
-
quote = quote === "'" ? null : "'";
|
|
429
|
-
else if (char === '"' && quote !== "'") {
|
|
430
|
-
if (quote === null)
|
|
431
|
-
quoteOpenedAt = out.length;
|
|
432
|
-
quote = quote === '"' ? null : '"';
|
|
433
|
-
}
|
|
434
|
-
out += char;
|
|
435
|
-
}
|
|
436
|
-
return out;
|
|
437
|
-
}
|
|
438
|
-
function hasSensitiveContent(text) {
|
|
439
|
-
// Even malformed JSON may spell a key with unicode escapes.
|
|
440
|
-
return SENSITIVE_CONTENT.test(text.replace(/\\u([0-9a-f]{4})/gi, (_, hex) => String.fromCharCode(Number.parseInt(hex, 16))));
|
|
441
|
-
}
|
|
442
|
-
function redactJsonValue(value, depth = 0) {
|
|
443
|
-
// Bound traversal independently of the later command-length cap. Deep
|
|
444
|
-
// payloads are content, so dropping a subtree is the conservative answer.
|
|
445
|
-
if (depth > 20)
|
|
446
|
-
return REDACTED;
|
|
447
|
-
if (typeof value === 'string' && /^\s*[[{]/.test(value)) {
|
|
448
|
-
try {
|
|
449
|
-
const parsed = JSON.parse(value);
|
|
450
|
-
const redacted = JSON.stringify(redactJsonValue(parsed, depth + 1));
|
|
451
|
-
return redacted === JSON.stringify(parsed) ? value : redacted;
|
|
452
|
-
}
|
|
453
|
-
catch {
|
|
454
|
-
return hasSensitiveContent(value) ? REDACTED : value;
|
|
455
|
-
}
|
|
456
|
-
}
|
|
457
|
-
if (Array.isArray(value))
|
|
458
|
-
return value.map((item) => redactJsonValue(item, depth + 1));
|
|
459
|
-
if (!value || typeof value !== 'object')
|
|
460
|
-
return value;
|
|
461
|
-
return Object.fromEntries(Object.entries(value).map(([key, item]) => [
|
|
462
|
-
key,
|
|
463
|
-
SENSITIVE_KEY.test(key) ? REDACTED : redactJsonValue(item, depth + 1),
|
|
464
|
-
]));
|
|
465
|
-
}
|
|
466
|
-
function redactJsonArguments(command) {
|
|
467
|
-
const redactArgument = (argument) => {
|
|
468
|
-
// Shell double quotes only consume escapes for these characters. JSON's
|
|
469
|
-
// string decoder rejects valid shell escapes such as \$, so decode the
|
|
470
|
-
// shell layer before parsing the request body.
|
|
471
|
-
const raw = decodeShellWord(argument);
|
|
472
|
-
if (!/^\s*[[{]/.test(raw))
|
|
473
|
-
return argument;
|
|
474
|
-
try {
|
|
475
|
-
const parsed = JSON.parse(raw);
|
|
476
|
-
const redacted = JSON.stringify(redactJsonValue(parsed));
|
|
477
|
-
if (redacted === JSON.stringify(parsed))
|
|
478
|
-
return argument;
|
|
479
|
-
return argument.startsWith("'") ? `'${redacted}'` : JSON.stringify(redacted);
|
|
480
|
-
}
|
|
481
|
-
catch {
|
|
482
|
-
// A JSON-like payload that cannot be decoded may still contain escaped
|
|
483
|
-
// credential keys. Drop its contents rather than scan partial syntax.
|
|
484
|
-
if (!hasSensitiveContent(raw))
|
|
485
|
-
return argument;
|
|
486
|
-
return argument.startsWith("'") ? `'${REDACTED}'` : `"${REDACTED}"`;
|
|
487
|
-
}
|
|
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);
|
|
505
|
-
}
|
|
506
|
-
/**
|
|
507
|
-
* Credential values recognised by the name beside them (URL userinfo,
|
|
508
|
-
* Authorization and Cookie headers, query keys) rather than by shell words.
|
|
509
|
-
*/
|
|
510
|
-
const VALUE_REDACTIONS = [
|
|
511
|
-
[/\b([a-z][a-z0-9+.-]*:\/\/[^/\s:@]+:)[^@\s]+@/gi, `$1${REDACTED}@`],
|
|
512
|
-
[/\b([a-z][a-z0-9+.-]*:\/\/)[^/\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
|
-
],
|
|
518
|
-
[new RegExp(COOKIE_HEADER_SOURCE, 'gi'), `$1${REDACTED}`],
|
|
519
|
-
[
|
|
520
|
-
new RegExp(`([?&#]${COMMAND_SENSITIVE_KEY_SOURCE}=)[^&\\s"'<>]+`, 'gi'),
|
|
521
|
-
`$1${REDACTED}`,
|
|
522
|
-
],
|
|
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)));
|
|
629
|
-
/**
|
|
630
|
-
* Space-separated credential flags have no assignment operator or token
|
|
631
|
-
* prefix. Keep the flag for evidence, but never its value. The value is the
|
|
632
|
-
* next shell word across any whitespace, newlines included: a word typed on
|
|
633
|
-
* the line after `--password` is very likely the password, even though bash
|
|
634
|
-
* would read it as a new command.
|
|
635
|
-
*
|
|
636
|
-
* That reach means the value can itself be a credential flag
|
|
637
|
-
* (`--password --token <value>`). Taking it as the first flag's value must not
|
|
638
|
-
* spare its own value, so the word after a swallowed credential flag is
|
|
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
|
|
643
|
-
* match the rule makes on its own is still redacted: the chain only adds.
|
|
644
|
-
*/
|
|
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');
|
|
649
|
-
// The words a credential key is spelled with. Keys are tested by scanning a
|
|
650
|
-
// word's `[\w.-]` runs for one rather than by a regex with `[\w.-]*` on both
|
|
651
|
-
// sides of the key, which backtracks quadratically on a long word.
|
|
652
|
-
const CREDENTIAL_KEY_WORD = new RegExp(`${SENSITIVE_WORD_SOURCE}|SSHPASS|REDISCLI_AUTH|VALKEYCLI_AUTH|MYSQL_PWD`, 'i');
|
|
653
|
-
const ASSIGNMENT_OPERATOR = /\s*[=:]/y;
|
|
654
|
-
const LINE_CONTINUATION = /^\\\r?\n$/;
|
|
655
|
-
/** `SENSITIVE_KEY`'s test, without its backtracking on a long name. */
|
|
656
|
-
function isCredentialKeyName(name) {
|
|
657
|
-
return ((/^[\w.-]+$/.test(name) && SENSITIVE_CONTENT.test(name)) ||
|
|
658
|
-
/^(?:SSHPASS|REDISCLI_AUTH|VALKEYCLI_AUTH|MYSQL_PWD)$/i.test(name));
|
|
659
|
-
}
|
|
660
|
-
function isBareCredentialFlag(word) {
|
|
661
|
-
const flag = /^--([^=]+)$/.exec(decodeShellWord(word));
|
|
662
|
-
return flag !== null && isCredentialKeyName(flag[1]);
|
|
663
|
-
}
|
|
664
|
-
/**
|
|
665
|
-
* `--token=<value>` carries its own value, which the attached-flag and
|
|
666
|
-
* assignment rules redact while keeping the flag's name readable to the
|
|
667
|
-
* passes after them.
|
|
668
|
-
*/
|
|
669
|
-
function isAttachedCredentialFlag(word) {
|
|
670
|
-
const flag = /^--([^=]+)=/.exec(decodeShellWord(word));
|
|
671
|
-
return flag !== null && isCredentialKeyName(flag[1]);
|
|
672
|
-
}
|
|
673
|
-
/**
|
|
674
|
-
* Whether `text` spells a credential key where the other rules read one: a
|
|
675
|
-
* `[\w.-]` run at the start, or after a shell boundary, quote or escape (where
|
|
676
|
-
* a decoded word starts), that is a `--` flag or is followed by `=` or `:`.
|
|
677
|
-
*/
|
|
678
|
-
function spellsCredentialKey(text) {
|
|
679
|
-
for (const run of text.matchAll(/[\w.-]+/g)) {
|
|
680
|
-
if (run.index > 0 && !/[\s;&|(\x60'"\\]/.test(text[run.index - 1]))
|
|
681
|
-
continue;
|
|
682
|
-
if (!CREDENTIAL_KEY_WORD.test(run[0]))
|
|
683
|
-
continue;
|
|
684
|
-
if (run[0].startsWith('--'))
|
|
685
|
-
return true;
|
|
686
|
-
ASSIGNMENT_OPERATOR.lastIndex = run.index + run[0].length;
|
|
687
|
-
if (ASSIGNMENT_OPERATOR.test(text))
|
|
688
|
-
return true;
|
|
689
|
-
}
|
|
690
|
-
return false;
|
|
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'"\\])$/;
|
|
699
|
-
/**
|
|
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.
|
|
708
|
-
*/
|
|
709
|
-
// Longer than any credential key `CREDENTIAL_KEY_WORD` matches.
|
|
710
|
-
const KEY_WINDOW = 32;
|
|
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;
|
|
719
|
-
let start = word.length;
|
|
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;
|
|
740
|
-
}
|
|
741
|
-
// `VALUE_REDACTIONS` without the global flag, for testing a single word.
|
|
742
|
-
const VALUE_PATTERNS = VALUE_REDACTIONS.map(([pattern]) => new RegExp(pattern.source, pattern.flags.replace('g', '')));
|
|
743
|
-
/**
|
|
744
|
-
* Whether `text` begins a match of a value pattern that runs on into the next
|
|
745
|
-
* word, as a header name does (`Cookie: name=value`). Redacting that word
|
|
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.
|
|
751
|
-
*/
|
|
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);
|
|
767
|
-
return (match !== null &&
|
|
768
|
-
match.index < text.length &&
|
|
769
|
-
match.index + match[0].length > text.length);
|
|
770
|
-
}));
|
|
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
|
-
}
|
|
808
|
-
/** Whether the word after `word` is a credential flag's value. */
|
|
809
|
-
function continuesCredentialChain(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)));
|
|
815
|
-
}
|
|
816
|
-
/**
|
|
817
|
-
* The words to redact after `value`, a word ending at `end` that a rule has
|
|
818
|
-
* just taken as a credential's value. If `value` is itself a credential flag,
|
|
819
|
-
* the redaction hides the flag the space-separated rule would read, so its
|
|
820
|
-
* value, and the chain after it, are redacted here instead. This is the one
|
|
821
|
-
* rule for every value edit: each rule that redacts a word as a value passes
|
|
822
|
-
* the word here, whatever flag or tool took it. `seen` holds the ends a chain
|
|
823
|
-
* has already left, so a run of flags stays linear.
|
|
824
|
-
*/
|
|
825
|
-
function followCredentialChain(value, end, next, seen, text) {
|
|
826
|
-
const ranges = [];
|
|
827
|
-
for (let word = value; continuesCredentialChain(word);) {
|
|
828
|
-
if (seen.has(end))
|
|
829
|
-
break;
|
|
830
|
-
seen.add(end);
|
|
831
|
-
const following = next(end);
|
|
832
|
-
if (!following)
|
|
833
|
-
break;
|
|
834
|
-
if (isAttachedCredentialFlag(following.word))
|
|
835
|
-
break;
|
|
836
|
-
if (!isBareCredentialFlag(following.word) &&
|
|
837
|
-
!handsValueOn(following.word, wordAfter(text, following.end, next)))
|
|
838
|
-
ranges.push({ start: following.start, end: following.end, text: REDACTED });
|
|
839
|
-
word = following.word;
|
|
840
|
-
end = following.end;
|
|
841
|
-
}
|
|
842
|
-
return ranges;
|
|
843
|
-
}
|
|
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
|
-
*/
|
|
859
|
-
function textChainCursor(text) {
|
|
860
|
-
return (end) => {
|
|
861
|
-
NEXT_SHELL_WORD.lastIndex = end;
|
|
862
|
-
let next = NEXT_SHELL_WORD.exec(text);
|
|
863
|
-
// A line continuation left in a nested script is not a word.
|
|
864
|
-
while (next && LINE_CONTINUATION.test(next[2]))
|
|
865
|
-
next = NEXT_SHELL_WORD.exec(text);
|
|
866
|
-
if (!next)
|
|
867
|
-
return undefined;
|
|
868
|
-
return {
|
|
869
|
-
word: next[2],
|
|
870
|
-
start: NEXT_SHELL_WORD.lastIndex - next[2].length,
|
|
871
|
-
end: NEXT_SHELL_WORD.lastIndex,
|
|
872
|
-
};
|
|
873
|
-
};
|
|
874
|
-
}
|
|
875
|
-
/**
|
|
876
|
-
* Replace each range of `text` with its text. Where ranges overlap, the
|
|
877
|
-
* earlier one is written and the text under the later one is dropped, so an
|
|
878
|
-
* overlap never puts back what either range removed.
|
|
879
|
-
*/
|
|
880
|
-
function applyRanges(text, ranges) {
|
|
881
|
-
ranges.sort((a, b) => a.start - b.start);
|
|
882
|
-
let out = '';
|
|
883
|
-
let cursor = 0;
|
|
884
|
-
for (const range of ranges) {
|
|
885
|
-
if (range.end <= cursor)
|
|
886
|
-
continue;
|
|
887
|
-
if (range.start >= cursor)
|
|
888
|
-
out += text.slice(cursor, range.start) + range.text;
|
|
889
|
-
cursor = range.end;
|
|
890
|
-
}
|
|
891
|
-
return out + text.slice(cursor);
|
|
892
|
-
}
|
|
893
|
-
/**
|
|
894
|
-
* `text.replace(pattern, replace)` for a pattern whose last group is a value
|
|
895
|
-
* the replacement may redact. A redacted value keeps its credential chain,
|
|
896
|
-
* which may run on into `after`, the text that follows `text`; the words it
|
|
897
|
-
* redacts there are returned as ranges of `after`.
|
|
898
|
-
*/
|
|
899
|
-
function replaceValues(text, pattern, replace, after = '') {
|
|
900
|
-
const next = textChainCursor(text + after);
|
|
901
|
-
const seen = new Set();
|
|
902
|
-
const ranges = [];
|
|
903
|
-
const beyond = [];
|
|
904
|
-
for (const match of text.matchAll(pattern)) {
|
|
905
|
-
const end = match.index + match[0].length;
|
|
906
|
-
const replaced = replace(match[0], ...match.slice(1).map((group) => group ?? ''));
|
|
907
|
-
if (replaced === match[0])
|
|
908
|
-
continue;
|
|
909
|
-
ranges.push({ start: match.index, end, text: replaced });
|
|
910
|
-
for (const range of followCredentialChain(match.at(-1) ?? '', end, next, seen, text + after)) {
|
|
911
|
-
if (range.start < text.length)
|
|
912
|
-
ranges.push(range);
|
|
913
|
-
else
|
|
914
|
-
beyond.push({
|
|
915
|
-
start: range.start - text.length,
|
|
916
|
-
end: range.end - text.length,
|
|
917
|
-
text: range.text,
|
|
918
|
-
});
|
|
919
|
-
}
|
|
920
|
-
}
|
|
921
|
-
return { text: applyRanges(text, ranges), after: beyond };
|
|
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
|
-
}
|
|
939
|
-
function redactCredentialFlagValues(command) {
|
|
940
|
-
return applyRanges(command, credentialFlagValueRanges(command));
|
|
941
|
-
}
|
|
942
|
-
/** The ranges `redactCredentialFlagValues` redacts in `command`. */
|
|
943
|
-
function credentialFlagValueRanges(command, depth = 0) {
|
|
944
|
-
const next = textChainCursor(command);
|
|
945
|
-
const ranges = [];
|
|
946
|
-
// A chain runs the same from any word it reaches, so one that reaches a word
|
|
947
|
-
// another chain already left stops there, keeping a run of flags linear.
|
|
948
|
-
const chained = new Set();
|
|
949
|
-
for (const match of command.matchAll(CREDENTIAL_FLAG)) {
|
|
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;
|
|
959
|
-
ranges.push({ start: end - match[3].length, end, text: REDACTED });
|
|
960
|
-
ranges.push(...followCredentialChain(match[3], end, next, chained, command));
|
|
961
|
-
}
|
|
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;
|
|
1009
|
-
}
|
|
1010
|
-
function applyCommandRedactions(command) {
|
|
1011
|
-
let out = command;
|
|
1012
|
-
for (const redaction of COMMAND_REDACTIONS)
|
|
1013
|
-
out = typeof redaction === 'function' ? redaction(out) : out.replace(...redaction);
|
|
1014
|
-
return out;
|
|
1015
|
-
}
|
|
1016
|
-
/**
|
|
1017
|
-
* Redactions applied to command text, in order: heredoc bodies (bulk inline
|
|
1018
|
-
* file content), private-key blocks, URL userinfo credentials, Authorization
|
|
1019
|
-
* headers, sensitive key=value / key: value pairs (quoted values with spaces
|
|
1020
|
-
* included), then well-known bare token prefixes (OpenAI/Anthropic sk-,
|
|
1021
|
-
* GitHub ghp_/github_pat_, Slack xox*, AWS AKIA). Redaction is best-effort —
|
|
1022
|
-
* the length cap below is the backstop for anything these patterns miss.
|
|
1023
|
-
*/
|
|
1024
|
-
const COMMAND_REDACTIONS = [
|
|
1025
|
-
[new RegExp(`(<<<\\s*)${SHELL_WORD_SOURCE}`, 'g'), `$1${REDACTED}`],
|
|
1026
|
-
// Heredoc: `cmd <<[-]['"]?WORD['"]? \n <body> \n WORD`. The body is inline
|
|
1027
|
-
// file content (secrets, source) that must never leave the machine — replace
|
|
1028
|
-
// it wholesale, keeping only the delimiters. Runs first so a heredoc'd
|
|
1029
|
-
// private key / token is gone before the narrower patterns below even scan.
|
|
1030
|
-
// Terminates on the closing delimiter line or end-of-input (unterminated
|
|
1031
|
-
// capture). Over-matching a rare `a << B` bit-shift only over-redacts, which
|
|
1032
|
-
// is the safe failure mode here.
|
|
1033
|
-
[
|
|
1034
|
-
/(<<-?\s*['"]?)([A-Za-z_]\w*)(['"]?)[\s\S]*?(\n[ \t]*\2\b|$)/g,
|
|
1035
|
-
`$1$2$3 ${REDACTED}$4`,
|
|
1036
|
-
],
|
|
1037
|
-
[new RegExp(PRIVATE_KEY_BLOCK_SOURCE, 'g'), REDACTED],
|
|
1038
|
-
...VALUE_REDACTION_PASSES,
|
|
1039
|
-
redactCredentialFlagValues,
|
|
1040
|
-
// JSON bodies embedded in shell commands are still command text. Quoted
|
|
1041
|
-
// keys evade the shell-assignment scanner below, including escaped values.
|
|
1042
|
-
[
|
|
1043
|
-
new RegExp(`("${COMMAND_SENSITIVE_KEY_SOURCE}"\\s*:\\s*)"(?:\\\\.|[^"\\\\])*"`, 'gi'),
|
|
1044
|
-
`$1"${REDACTED}"`,
|
|
1045
|
-
],
|
|
1046
|
-
// Sensitive assignment: an env-var-ish key (the sensitive word optionally
|
|
1047
|
-
// wrapped in `DB_…` / `…_FILE` segments, so `DB_PASSWORD`/`OPENAI_API_KEY`
|
|
1048
|
-
// match, not just a bare word) followed by `=`/`:` and a value that is a
|
|
1049
|
-
// double/single-quoted string (spaces and all) or an unquoted run up to the
|
|
1050
|
-
// next whitespace. Anchored at a shell-token boundary ($1, preserved) so it
|
|
1051
|
-
// never fires mid-word (a backtick opens a substitution's first word, as
|
|
1052
|
-
// `(` does); the whole value — quotes included — is redacted so
|
|
1053
|
-
// whitespace-containing passphrases can't slip through.
|
|
1054
|
-
[
|
|
1055
|
-
new RegExp(`(^|[\\s;&|(\\x60])(${COMMAND_SENSITIVE_KEY_SOURCE}\\s*[=:]\\s*)${SHELL_WORD_SOURCE}`, 'gi'),
|
|
1056
|
-
`$1$2${REDACTED}`,
|
|
1057
|
-
],
|
|
1058
|
-
// Our own CLI token (`API_KEY_PREFIX`, the prefix the dashboard mints CLI
|
|
1059
|
-
// tokens under). Listed with the third-party prefixes because the documented
|
|
1060
|
-
// setup path — `mossbear sync --token mossbear_…` — is a *space-separated*
|
|
1061
|
-
// flag, so the `key=value` rule above never sees it: without this, the hook
|
|
1062
|
-
// logs that command and the next sync uploads a live dashboard credential in
|
|
1063
|
-
// an action summary. The product's own secret shape is the one most likely to
|
|
1064
|
-
// appear in a command a user runs. Any future re-mint under a new prefix has
|
|
1065
|
-
// to be added here in the same change — a token this rule does not match is
|
|
1066
|
-
// one the action log keeps in plaintext.
|
|
1067
|
-
[/\bmossbear_[A-Za-z0-9_-]{8,}/g, REDACTED],
|
|
1068
|
-
// The pre-rename prefix. Every key issued under it is deleted by migration
|
|
1069
|
-
// `0056-revoke_legacy_prefix_cli_tokens.ts`, so this matches nothing that can
|
|
1070
|
-
// still authenticate — it is kept because a CLI redacting on the user's
|
|
1071
|
-
// machine can be older or newer than the dashboard it syncs to, and the
|
|
1072
|
-
// window where one of these is still live is exactly the window where
|
|
1073
|
-
// dropping the rule would upload it in plaintext.
|
|
1074
|
-
[/\bauden_[A-Za-z0-9_-]{8,}/g, REDACTED],
|
|
1075
|
-
[/\bsk-[A-Za-z0-9_-]{8,}/g, REDACTED],
|
|
1076
|
-
[/\bgh[pousr]_[A-Za-z0-9]{8,}/g, REDACTED],
|
|
1077
|
-
[/\bgithub_pat_[A-Za-z0-9_]{8,}/g, REDACTED],
|
|
1078
|
-
[/\bxox[baprs]-[A-Za-z0-9-]{8,}/g, REDACTED],
|
|
1079
|
-
[/\bAKIA[0-9A-Z]{16}\b/g, REDACTED],
|
|
1080
|
-
];
|
|
1081
|
-
/**
|
|
1082
|
-
* Proxy addresses take userinfo with or without a URL scheme, so the general
|
|
1083
|
-
* URL rule cannot see the scheme-less form. A bare `a:b@c` is not matched
|
|
1084
|
-
* everywhere because ordinary text has that shape too (`image:tag@sha256:…`);
|
|
1085
|
-
* only values in HTTP-client commands or proxy assignments are checked.
|
|
1086
|
-
*/
|
|
1087
|
-
const PROXY_USERINFO = /^((?:[a-z][a-z\d+.-]*:\/\/)?[^/?#:@]*:)[^/?#]*@/i;
|
|
1088
|
-
/**
|
|
1089
|
-
* Redact the password from proxy environment assignments (`https_proxy=`,
|
|
1090
|
-
* `ALL_PROXY=`, …), which HTTP clients read at startup. Keeps the user and
|
|
1091
|
-
* host, matching the URL-userinfo rule, and leaves unauthenticated values as
|
|
1092
|
-
* they are. Other inline curl authentication fields lose their whole value.
|
|
1093
|
-
*/
|
|
1094
|
-
function redactCredentialAssignments(command, inputs) {
|
|
1095
|
-
let assignments = command.replace(new RegExp(SHELL_WORD_SOURCE, 'g'), (word) => {
|
|
1096
|
-
// `env` accepts a quoted whole NAME=value argument. Decode the complete
|
|
1097
|
-
// word before identifying its key, just as for shell-encoded options.
|
|
1098
|
-
const assignment = /^([^=\0]+)=([\s\S]*)$/.exec(decodeShellWord(word));
|
|
1099
|
-
if (!assignment)
|
|
1100
|
-
return word;
|
|
1101
|
-
let key = assignment[1];
|
|
1102
|
-
let operator = '=';
|
|
1103
|
-
const proxy = assignment[2];
|
|
1104
|
-
if (key.endsWith('+') && !inputs.env.has(key)) {
|
|
1105
|
-
key = key.slice(0, -1);
|
|
1106
|
-
operator = '+=';
|
|
1107
|
-
}
|
|
1108
|
-
if (inputs.env.has(key))
|
|
1109
|
-
return `${key}${operator}${REDACTED}`;
|
|
1110
|
-
if (!/^(?:\w+_)?proxy$/i.test(key))
|
|
1111
|
-
return word;
|
|
1112
|
-
if (!PROXY_USERINFO.test(proxy))
|
|
1113
|
-
return word;
|
|
1114
|
-
const redacted = proxy.replace(PROXY_USERINFO, `$1${REDACTED}@`);
|
|
1115
|
-
return `${key}${operator}${redacted}`;
|
|
1116
|
-
});
|
|
1117
|
-
if (inputs.passSources.size || inputs.env.size)
|
|
1118
|
-
assignments = assignments.replace(new RegExp(`(<<<[ \\t]*)${SHELL_WORD_SOURCE}`, 'g'), `$1${REDACTED}`);
|
|
1119
|
-
// Config literals are produced by echo/printf before curl consumes -K.
|
|
1120
|
-
// Keep that channel separate from executable flags: sort -u and OpenSSL
|
|
1121
|
-
// passphrase source options must not become curl configuration by spelling.
|
|
1122
|
-
const words = [
|
|
1123
|
-
...assignments.matchAll(new RegExp(`${SHELL_WORD_SOURCE}|[;&|()\\n]`, 'g')),
|
|
1124
|
-
];
|
|
1125
|
-
if (!inputs.curlConfig && !inputs.passSources.size && !inputs.env.size)
|
|
1126
|
-
return assignments;
|
|
1127
|
-
const fields = new RegExp(`(^|[\\s'\"])(-{0,2}(?:(?:\\w+_)?proxy|${CURL_AUTH_OPTIONS})|-[uUbEx])([ \\t]*(?:=|:)[ \\t]*|[ \\t]+)(${SHELL_WORD_SOURCE})`, 'gi');
|
|
1128
|
-
const edits = [];
|
|
1129
|
-
let producerStart = null;
|
|
1130
|
-
let producerPrintf = false;
|
|
1131
|
-
const finishProducer = (end) => {
|
|
1132
|
-
if (producerStart === null)
|
|
1133
|
-
return;
|
|
1134
|
-
const body = assignments.slice(producerStart, end);
|
|
1135
|
-
const prefix = assignments.slice(0, producerStart);
|
|
1136
|
-
const producesPassphrase = (assignments[end] === '|' &&
|
|
1137
|
-
(inputs.passSources.has('stdin') ||
|
|
1138
|
-
[...inputs.passSources].some((source) => source.startsWith('fd:')))) ||
|
|
1139
|
-
([...inputs.passSources].some((source) => /^(?:fd:|file:)/.test(source)) &&
|
|
1140
|
-
prefix.includes('<(')) ||
|
|
1141
|
-
[...inputs.passSources].some((source) => source.startsWith('file:') &&
|
|
1142
|
-
body.includes('>') &&
|
|
1143
|
-
body.includes(source.slice(5))) ||
|
|
1144
|
-
[...inputs.env].some((key) => prefix.includes(`${key}=`) && prefix.includes('('));
|
|
1145
|
-
if (producesPassphrase) {
|
|
1146
|
-
edits.push({ start: producerStart, end, text: ` ${REDACTED} ` });
|
|
1147
|
-
producerStart = null;
|
|
1148
|
-
return;
|
|
1149
|
-
}
|
|
1150
|
-
if (!inputs.curlConfig) {
|
|
1151
|
-
producerStart = null;
|
|
1152
|
-
return;
|
|
1153
|
-
}
|
|
1154
|
-
const decodedBody = [...body.matchAll(new RegExp(SHELL_WORD_SOURCE, 'g'))]
|
|
1155
|
-
.map((word) => decodeShellWord(word[0]))
|
|
1156
|
-
.join(' ');
|
|
1157
|
-
if (hasPrivateConfigVariables(decodedBody)) {
|
|
1158
|
-
edits.push({ start: producerStart, end, text: ` ${REDACTED} ` });
|
|
1159
|
-
producerStart = null;
|
|
1160
|
-
return;
|
|
1161
|
-
}
|
|
1162
|
-
const redactField = (match, prefix, key, separator, value) => {
|
|
1163
|
-
if (/^-[a-z]$/i.test(key) && !['-u', '-U', '-b', '-E', '-x'].includes(key))
|
|
1164
|
-
return match;
|
|
1165
|
-
const credential = decodeShellWord(value);
|
|
1166
|
-
const field = key.replace(/^-+/, '');
|
|
1167
|
-
if (producerPrintf && credential.includes('%'))
|
|
1168
|
-
return `${prefix}${key}${separator}${REDACTED}`;
|
|
1169
|
-
if (/^(?:proxy-)?(?:user|cert)$/i.test(field)) {
|
|
1170
|
-
if (!credential.includes(':'))
|
|
1171
|
-
return match;
|
|
1172
|
-
return `${prefix}${key}${separator}${credential.replace(/^([^:]*:)[\s\S]*$/, `$1${REDACTED}`)}`;
|
|
1173
|
-
}
|
|
1174
|
-
if (/^cookie$/i.test(field) && !credential.includes('='))
|
|
1175
|
-
return match;
|
|
1176
|
-
if (/^(?:(?:\w+_)?proxy|x)$/i.test(field) || CURL_URL_FIELD.test(field))
|
|
1177
|
-
return PROXY_USERINFO.test(credential)
|
|
1178
|
-
? `${prefix}${key}${separator}${credential.replace(PROXY_USERINFO, `$1${REDACTED}@`)}`
|
|
1179
|
-
: match;
|
|
1180
|
-
return `${prefix}${key}${separator}${REDACTED}`;
|
|
1181
|
-
};
|
|
1182
|
-
// A producer's literals obey shell quoting before becoming config text.
|
|
1183
|
-
// Decode concatenated/escaped field names before matching them, while
|
|
1184
|
-
// retaining untouched words and also scanning unquoted multiword fields.
|
|
1185
|
-
let text = body.replace(new RegExp(SHELL_WORD_SOURCE, 'g'), (word) => {
|
|
1186
|
-
const decoded = decodeShellWord(word);
|
|
1187
|
-
const redacted = replaceValues(decoded, fields, redactField).text;
|
|
1188
|
-
return redacted === decoded ? word : JSON.stringify(redacted);
|
|
1189
|
-
});
|
|
1190
|
-
// The body ends at the producer's operator, but a field's credential
|
|
1191
|
-
// chain reaches on across a newline, as `redactCredentialFlagValues`
|
|
1192
|
-
// does, so the chain's words after the body are edited too.
|
|
1193
|
-
const fieldEdits = replaceValues(text, fields, redactField, assignments.slice(end));
|
|
1194
|
-
text = fieldEdits.text;
|
|
1195
|
-
for (const range of fieldEdits.after)
|
|
1196
|
-
edits.push({ start: end + range.start, end: end + range.end, text: range.text });
|
|
1197
|
-
// A printf template may take the credential from later arguments. Once
|
|
1198
|
-
// its format is private, drop the argument list rather than expose the
|
|
1199
|
-
// values substituted into it or attempt to interpret printf formatting.
|
|
1200
|
-
if (text !== body && producerPrintf && body.includes('%'))
|
|
1201
|
-
text = ` ${REDACTED} `;
|
|
1202
|
-
if (text !== body)
|
|
1203
|
-
edits.push({ start: producerStart, end, text });
|
|
1204
|
-
producerStart = null;
|
|
1205
|
-
};
|
|
1206
|
-
// Decided at each command's first word, so a file argument named echo under
|
|
1207
|
-
// a program that never runs its arguments is not mistaken for a producer.
|
|
1208
|
-
// Wrappers are not looked past here, which only errs toward redaction.
|
|
1209
|
-
let commandSeen = false;
|
|
1210
|
-
let argumentsInert = false;
|
|
1211
|
-
for (const word of words) {
|
|
1212
|
-
if (/^[;&|()\n]$/.test(word[0])) {
|
|
1213
|
-
finishProducer(word.index);
|
|
1214
|
-
commandSeen = false;
|
|
1215
|
-
argumentsInert = false;
|
|
1216
|
-
continue;
|
|
1217
|
-
}
|
|
1218
|
-
const decoded = decodeShellWord(word[0]);
|
|
1219
|
-
if (!commandSeen && !/^\w+\+?=/.test(decoded)) {
|
|
1220
|
-
commandSeen = true;
|
|
1221
|
-
argumentsInert = isNonDispatching(assignments, word.index, decoded);
|
|
1222
|
-
}
|
|
1223
|
-
const tool = invokedTool(decoded);
|
|
1224
|
-
// A backtick substitution runs its own command, whatever the outer one is.
|
|
1225
|
-
const inert = argumentsInert && !word[0].includes('`');
|
|
1226
|
-
if (producerStart === null && !inert && ['echo', 'printf'].includes(tool)) {
|
|
1227
|
-
producerStart = word.index + word[0].length;
|
|
1228
|
-
producerPrintf = tool === 'printf';
|
|
1229
|
-
}
|
|
1230
|
-
}
|
|
1231
|
-
finishProducer(assignments.length);
|
|
1232
|
-
return applyRanges(assignments, edits);
|
|
1233
|
-
}
|
|
1234
|
-
/** Redact known tool credential flags without matching unrelated short flags. */
|
|
1235
|
-
function redactToolArguments(command, inputs) {
|
|
1236
|
-
const words = [...command.matchAll(new RegExp(`${SHELL_WORD_SOURCE}|[;&|()\n]`, 'g'))];
|
|
1237
|
-
const edits = [];
|
|
1238
|
-
let client = null;
|
|
1239
|
-
let options = true;
|
|
1240
|
-
let opensslPasswd = false;
|
|
1241
|
-
let opensslSubcommandRead = false;
|
|
1242
|
-
// Set once a wrapper in the current command receives an option it does not
|
|
1243
|
-
// model, which may have taken the next word as its value.
|
|
1244
|
-
let wrapperOptionUnknown = false;
|
|
1245
|
-
// The current command is a program that never runs its arguments.
|
|
1246
|
-
let argumentsInert = false;
|
|
1247
|
-
const variables = new Map();
|
|
1248
|
-
const expansions = [];
|
|
1249
|
-
const resolveVariable = (contents, depth = 0) => {
|
|
1250
|
-
if (depth > 20)
|
|
1251
|
-
return 'private:@';
|
|
1252
|
-
return contents.replace(CURL_EXPANSION, (_, name) => resolveVariable(variables.get(name)?.at(-1)?.contents ?? '', depth + 1));
|
|
1253
|
-
};
|
|
1254
|
-
const finishCurl = () => {
|
|
1255
|
-
const privateNames = new Set(inputs?.curlVariables);
|
|
1256
|
-
for (const expansion of expansions) {
|
|
1257
|
-
if (CURL_URL_FIELD.test(expansion.field) &&
|
|
1258
|
-
!PROXY_USERINFO.test(expansion.resolved) &&
|
|
1259
|
-
!new RegExp(`[?&#]${COMMAND_SENSITIVE_KEY_SOURCE}=`, 'i').test(expansion.resolved))
|
|
1260
|
-
continue;
|
|
1261
|
-
for (const match of expansion.contents.matchAll(CURL_EXPANSION))
|
|
1262
|
-
privateNames.add(match[1]);
|
|
1263
|
-
}
|
|
1264
|
-
// Expanded variable definitions can themselves refer to other definitions.
|
|
1265
|
-
// Walk each name once, including all assignments, without evaluating shell code.
|
|
1266
|
-
for (const name of privateNames) {
|
|
1267
|
-
for (const variable of variables.get(name) ?? []) {
|
|
1268
|
-
if (variable.env)
|
|
1269
|
-
inputs?.env.add(name);
|
|
1270
|
-
if (variable.contents === undefined)
|
|
1271
|
-
continue;
|
|
1272
|
-
for (const match of variable.contents.matchAll(CURL_EXPANSION))
|
|
1273
|
-
privateNames.add(match[1]);
|
|
1274
|
-
edits.push({
|
|
1275
|
-
start: variable.start,
|
|
1276
|
-
end: variable.end,
|
|
1277
|
-
text: `${variable.prefix}${REDACTED}`,
|
|
1278
|
-
});
|
|
1279
|
-
}
|
|
1280
|
-
}
|
|
1281
|
-
for (const name of privateNames)
|
|
1282
|
-
inputs?.curlVariables.add(name);
|
|
1283
|
-
variables.clear();
|
|
1284
|
-
expansions.length = 0;
|
|
1285
|
-
};
|
|
1286
|
-
for (let index = 0; index < words.length; index += 1) {
|
|
1287
|
-
const word = words[index];
|
|
1288
|
-
if (!word)
|
|
1289
|
-
continue;
|
|
1290
|
-
const raw = word[0];
|
|
1291
|
-
if (/^[;&|()\n]$/.test(raw)) {
|
|
1292
|
-
finishCurl();
|
|
1293
|
-
client = null;
|
|
1294
|
-
opensslPasswd = false;
|
|
1295
|
-
opensslSubcommandRead = false;
|
|
1296
|
-
wrapperOptionUnknown = false;
|
|
1297
|
-
argumentsInert = false;
|
|
1298
|
-
options = true;
|
|
1299
|
-
continue;
|
|
1300
|
-
}
|
|
1301
|
-
const decoded = decodeShellWord(raw);
|
|
1302
|
-
if (opensslPasswd) {
|
|
1303
|
-
// passwd hashes each positional plaintext password. Its source and salt
|
|
1304
|
-
// flags consume nonsecret references, which must not be mistaken for it.
|
|
1305
|
-
const sourceFlag = /^-{1,2}(?:in|salt|rand|writerand|provider|provider-path|provparam|propquery)(?:=(.*))?$/.exec(decoded);
|
|
1306
|
-
if (options && sourceFlag) {
|
|
1307
|
-
if (inputs && /^-{1,2}in(?:=|$)/.test(decoded)) {
|
|
1308
|
-
const source = sourceFlag[1] ?? words[index + 1]?.[0];
|
|
1309
|
-
if (source)
|
|
1310
|
-
inputs.passSources.add(`file:${decodeShellWord(source)}`);
|
|
1311
|
-
}
|
|
1312
|
-
if (sourceFlag[1] === undefined)
|
|
1313
|
-
index += 1;
|
|
1314
|
-
continue;
|
|
1315
|
-
}
|
|
1316
|
-
if (options && /^-{1,2}stdin$/.test(decoded))
|
|
1317
|
-
inputs?.passSources.add('stdin');
|
|
1318
|
-
if (!options || !decoded.startsWith('-')) {
|
|
1319
|
-
edits.push({ start: word.index, end: word.index + raw.length, text: REDACTED });
|
|
1320
|
-
continue;
|
|
1321
|
-
}
|
|
1322
|
-
}
|
|
1323
|
-
if (client === 'sshpass') {
|
|
1324
|
-
// sshpass dispatches a child after its own credential/source options.
|
|
1325
|
-
// Consume source arguments so a filename named curl is not executable.
|
|
1326
|
-
const source = /^-[fdP](.*)$/.exec(decoded);
|
|
1327
|
-
if (options && source) {
|
|
1328
|
-
if (!source[1])
|
|
1329
|
-
index += 1;
|
|
1330
|
-
continue;
|
|
1331
|
-
}
|
|
1332
|
-
if (options && /^-[evhV]+$/.test(decoded))
|
|
1333
|
-
continue;
|
|
1334
|
-
if (!options || !decoded.startsWith('-'))
|
|
1335
|
-
client = null;
|
|
1336
|
-
}
|
|
1337
|
-
if (!CREDENTIAL_TOOLS.includes(client ?? '')) {
|
|
1338
|
-
if (/^\w+\+?=/.test(decoded))
|
|
1339
|
-
continue;
|
|
1340
|
-
const wrapperValue = options
|
|
1341
|
-
? WRAPPER_VALUE_FLAGS[client ?? '']?.exec(decoded)
|
|
1342
|
-
: null;
|
|
1343
|
-
if (wrapperValue) {
|
|
1344
|
-
if (wrapperValue[1] === undefined && wrapperValue[2] === undefined)
|
|
1345
|
-
index += 1;
|
|
1346
|
-
continue;
|
|
1347
|
-
}
|
|
1348
|
-
}
|
|
1349
|
-
const executable = invokedTool(decoded);
|
|
1350
|
-
// A backtick substitution runs its own command, whatever the outer one is.
|
|
1351
|
-
const inert = argumentsInert && !raw.includes('`');
|
|
1352
|
-
if (!inert &&
|
|
1353
|
-
WRAPPER_VALUE_FLAGS[executable] &&
|
|
1354
|
-
!CREDENTIAL_TOOLS.includes(client ?? '')) {
|
|
1355
|
-
client = executable;
|
|
1356
|
-
options = true;
|
|
1357
|
-
continue;
|
|
1358
|
-
}
|
|
1359
|
-
// Dispatching tools may take their own options and positional arguments.
|
|
1360
|
-
// Once a credential-consuming tool appears, its subsequent flags still carry
|
|
1361
|
-
// credentials; a wrapper's -u or timeout must not hide that client.
|
|
1362
|
-
if (!inert &&
|
|
1363
|
-
CREDENTIAL_TOOLS.includes(executable) &&
|
|
1364
|
-
!CREDENTIAL_TOOLS.includes(client ?? '')) {
|
|
1365
|
-
client = executable;
|
|
1366
|
-
opensslPasswd = false;
|
|
1367
|
-
opensslSubcommandRead = false;
|
|
1368
|
-
options = true;
|
|
1369
|
-
continue;
|
|
1370
|
-
}
|
|
1371
|
-
// A wrapper's first operand (its modelled valued options were consumed
|
|
1372
|
-
// above) is the program it runs, which decides whether later words can be
|
|
1373
|
-
// invocations. After an option the table does not model, that operand may
|
|
1374
|
-
// be the option's value instead, so the wrapper keeps dispatching.
|
|
1375
|
-
if (client !== null && WRAPPER_VALUE_FLAGS[client]) {
|
|
1376
|
-
if (options && decoded.startsWith('-')) {
|
|
1377
|
-
if (decoded !== '--' &&
|
|
1378
|
-
!decoded.includes('=') &&
|
|
1379
|
-
!WRAPPER_SWITCHES[client]?.test(decoded))
|
|
1380
|
-
wrapperOptionUnknown = true;
|
|
1381
|
-
}
|
|
1382
|
-
else if (!wrapperOptionUnknown) {
|
|
1383
|
-
client = executable;
|
|
1384
|
-
argumentsInert = isNonDispatching(command, word.index, decoded);
|
|
1385
|
-
options = true;
|
|
1386
|
-
continue;
|
|
1387
|
-
}
|
|
1388
|
-
}
|
|
1389
|
-
if (client === null) {
|
|
1390
|
-
if (/^[\w]+=.*/.test(decoded) ||
|
|
1391
|
-
['env', 'sudo', 'command', 'exec', 'nohup'].includes(decoded))
|
|
1392
|
-
continue;
|
|
1393
|
-
client = invokedTool(decoded);
|
|
1394
|
-
argumentsInert = isNonDispatching(command, word.index, decoded);
|
|
1395
|
-
continue;
|
|
1396
|
-
}
|
|
1397
|
-
if (client === 'openssl' && !opensslSubcommandRead && !decoded.startsWith('-')) {
|
|
1398
|
-
opensslSubcommandRead = true;
|
|
1399
|
-
opensslPasswd = decoded === 'passwd';
|
|
1400
|
-
continue;
|
|
1401
|
-
}
|
|
1402
|
-
if (decoded === '--') {
|
|
1403
|
-
options = false;
|
|
1404
|
-
continue;
|
|
1405
|
-
}
|
|
1406
|
-
if (client === 'curl' && options) {
|
|
1407
|
-
// Public source/output arguments cannot become config flags by spelling.
|
|
1408
|
-
const publicSource = /^(-[oAw])(.+)?$|^(--(?:output|output-dir|write-out|user-agent|cacert|capath))(?:=(.*))?$/.exec(decoded);
|
|
1409
|
-
if (publicSource) {
|
|
1410
|
-
if (publicSource[2] === undefined && publicSource[4] === undefined)
|
|
1411
|
-
index += 1;
|
|
1412
|
-
continue;
|
|
1413
|
-
}
|
|
1414
|
-
if (CURL_CONFIG_FLAG.test(decoded) && inputs)
|
|
1415
|
-
inputs.curlConfig = true;
|
|
1416
|
-
const variableFlag = /^--(?:expand-)?variable(?:=(.*))?$/.exec(decoded);
|
|
1417
|
-
const expandFlag = CURL_EXPAND_FLAG.exec(decoded);
|
|
1418
|
-
if (variableFlag || expandFlag) {
|
|
1419
|
-
const attached = variableFlag ? variableFlag[1] : expandFlag[2];
|
|
1420
|
-
const value = attached === undefined ? words[index + 1] : word;
|
|
1421
|
-
if (!value || /^[;&|()\n]$/.test(value[0]))
|
|
1422
|
-
continue;
|
|
1423
|
-
const contents = attached ?? decodeShellWord(value[0]);
|
|
1424
|
-
if (variableFlag) {
|
|
1425
|
-
const definition = /^(%?)([A-Za-z0-9_]+)(?:\[[^\]]*\])?(?:=([\s\S]*)|@[^\s]*|$)/.exec(contents);
|
|
1426
|
-
if (definition) {
|
|
1427
|
-
const name = definition[2];
|
|
1428
|
-
const entries = variables.get(name) ?? [];
|
|
1429
|
-
entries.push({
|
|
1430
|
-
start: value.index,
|
|
1431
|
-
end: value.index + value[0].length,
|
|
1432
|
-
prefix: (attached === undefined ? '' : `${decoded.split('=')[0]}=`) +
|
|
1433
|
-
contents.slice(0, contents.indexOf('=') + 1),
|
|
1434
|
-
contents: definition[3],
|
|
1435
|
-
env: definition[1] === '%',
|
|
1436
|
-
});
|
|
1437
|
-
variables.set(name, entries);
|
|
1438
|
-
}
|
|
1439
|
-
}
|
|
1440
|
-
else
|
|
1441
|
-
expansions.push({
|
|
1442
|
-
field: expandFlag[1],
|
|
1443
|
-
contents,
|
|
1444
|
-
resolved: resolveVariable(contents),
|
|
1445
|
-
});
|
|
1446
|
-
if (attached === undefined)
|
|
1447
|
-
index += 1;
|
|
1448
|
-
continue;
|
|
1449
|
-
}
|
|
1450
|
-
}
|
|
1451
|
-
if (client === 'openssl' && options && /^-{1,2}kfile(?:=|$)/.test(decoded)) {
|
|
1452
|
-
const attached = decoded.includes('=')
|
|
1453
|
-
? decoded.slice(decoded.indexOf('=') + 1)
|
|
1454
|
-
: undefined;
|
|
1455
|
-
const source = attached ?? words[index + 1]?.[0];
|
|
1456
|
-
if (source)
|
|
1457
|
-
inputs?.passSources.add(`file:${decodeShellWord(source)}`);
|
|
1458
|
-
if (attached === undefined)
|
|
1459
|
-
index += 1;
|
|
1460
|
-
continue;
|
|
1461
|
-
}
|
|
1462
|
-
// Curl also accepts URLs without a scheme and uses their userinfo as
|
|
1463
|
-
// credentials. Restrict this shape to HTTP-client arguments so image
|
|
1464
|
-
// digests and SSH paths in other commands retain their meaning. The Redis
|
|
1465
|
-
// family reads a URI only with an explicit scheme; its other positional
|
|
1466
|
-
// words are Redis command arguments, which must keep their meaning.
|
|
1467
|
-
if (['curl', 'http', 'https', 'httpie', ...REDIS_FAMILY].includes(client) &&
|
|
1468
|
-
(!REDIS_FAMILY.includes(client) || REDIS_URI.test(decoded)) &&
|
|
1469
|
-
(!options || !decoded.startsWith('-')) &&
|
|
1470
|
-
PROXY_USERINFO.test(decoded)) {
|
|
1471
|
-
edits.push({
|
|
1472
|
-
start: word.index,
|
|
1473
|
-
end: word.index + raw.length,
|
|
1474
|
-
text: decoded.replace(PROXY_USERINFO, `$1${REDACTED}@`),
|
|
1475
|
-
});
|
|
1476
|
-
continue;
|
|
1477
|
-
}
|
|
1478
|
-
if (!options)
|
|
1479
|
-
continue;
|
|
1480
|
-
// Decode option spellings before classifying them: shell quotes and
|
|
1481
|
-
// escapes change syntax, never the credential the invoked tool receives.
|
|
1482
|
-
const sensitiveFlag = /^--([^=]+)(?:=(.*))?$/.exec(decoded);
|
|
1483
|
-
if (sensitiveFlag && SENSITIVE_KEY.test(sensitiveFlag[1])) {
|
|
1484
|
-
const attached = sensitiveFlag[2];
|
|
1485
|
-
const value = attached === undefined ? words[index + 1] : word;
|
|
1486
|
-
if (value && !/^[;&|()\n]$/.test(value[0])) {
|
|
1487
|
-
edits.push({
|
|
1488
|
-
start: value.index,
|
|
1489
|
-
end: value.index + value[0].length,
|
|
1490
|
-
text: attached === undefined ? REDACTED : `--${sensitiveFlag[1]}=${REDACTED}`,
|
|
1491
|
-
});
|
|
1492
|
-
if (attached === undefined)
|
|
1493
|
-
index += 1;
|
|
1494
|
-
}
|
|
1495
|
-
continue;
|
|
1496
|
-
}
|
|
1497
|
-
// Password flags are per tool: the spelling is what names the credential,
|
|
1498
|
-
// so each tool's table entry is the only place its flags are defined.
|
|
1499
|
-
const directPasswordFlag = DIRECT_PASSWORD_FLAGS.get(client ?? '')?.exec(decoded);
|
|
1500
|
-
if (directPasswordFlag) {
|
|
1501
|
-
const name = directPasswordFlag[1];
|
|
1502
|
-
const mysqlFamily = MYSQL_FAMILY.includes(client ?? '');
|
|
1503
|
-
const attached = client === 'sshpass' || mysqlFamily
|
|
1504
|
-
? directPasswordFlag[2] || undefined
|
|
1505
|
-
: directPasswordFlag[2];
|
|
1506
|
-
// MySQL reads a bare -p as a prompt, so the next word is the database, not
|
|
1507
|
-
// a password. Only the attached -p<pw> spelling carries one.
|
|
1508
|
-
if (mysqlFamily && attached === undefined)
|
|
1509
|
-
continue;
|
|
1510
|
-
const value = attached === undefined ? words[index + 1] : word;
|
|
1511
|
-
if (!value || /^[;&|()\n]$/.test(value[0]))
|
|
1512
|
-
continue;
|
|
1513
|
-
const password = attached ?? decodeShellWord(value[0]);
|
|
1514
|
-
if (client === 'openssl' && inputs) {
|
|
1515
|
-
if (/^env:[^=\0]+$/.test(password))
|
|
1516
|
-
inputs.env.add(password.slice(4));
|
|
1517
|
-
else if (/^(?:stdin|(?:file|fd):)/.test(password))
|
|
1518
|
-
inputs.passSources.add(password);
|
|
1519
|
-
}
|
|
1520
|
-
if (client !== 'openssl' ||
|
|
1521
|
-
/^(?:k|srppass|pwri_password|secretkey|hmac)$/i.test(name.replace(/^-+/, '')) ||
|
|
1522
|
-
password.startsWith('pass:')) {
|
|
1523
|
-
edits.push({
|
|
1524
|
-
start: value.index,
|
|
1525
|
-
end: value.index + value[0].length,
|
|
1526
|
-
text: attached === undefined
|
|
1527
|
-
? REDACTED
|
|
1528
|
-
: `${name}${client === 'openssl' || name.startsWith('--') ? '=' : ''}${REDACTED}`,
|
|
1529
|
-
});
|
|
1530
|
-
}
|
|
1531
|
-
if (attached === undefined)
|
|
1532
|
-
index += 1;
|
|
1533
|
-
continue;
|
|
1534
|
-
}
|
|
1535
|
-
if (!['curl', 'http', 'https', 'httpie'].includes(client))
|
|
1536
|
-
continue;
|
|
1537
|
-
const payloadFlag = client === 'curl' ? CURL_PAYLOAD_FLAG.exec(decoded) : null;
|
|
1538
|
-
if (payloadFlag) {
|
|
1539
|
-
const name = payloadFlag[1] ?? payloadFlag[3];
|
|
1540
|
-
const attached = payloadFlag[2] ?? payloadFlag[4];
|
|
1541
|
-
const value = attached === undefined ? words[index + 1] : word;
|
|
1542
|
-
if (!value || /^[;&|()\n]$/.test(value[0]))
|
|
1543
|
-
continue;
|
|
1544
|
-
const payload = attached ?? decodeShellWord(value[0]);
|
|
1545
|
-
const header = /^([^:]+):/.exec(payload);
|
|
1546
|
-
const isHeader = ['--header', '--proxy-header'].includes(name) ||
|
|
1547
|
-
(!name.startsWith('--') && name.endsWith('H'));
|
|
1548
|
-
// JSON is handled structurally above. Form fields must also inspect
|
|
1549
|
-
// percent-decoded names: passwords do not become public by encoding.
|
|
1550
|
-
const formHasCredential = [...new URLSearchParams(payload).keys()].some((key) => SENSITIVE_KEY.test(key));
|
|
1551
|
-
if (isHeader ? header && SENSITIVE_KEY.test(header[1].trim()) : formHasCredential) {
|
|
1552
|
-
edits.push({
|
|
1553
|
-
start: value.index,
|
|
1554
|
-
end: value.index + value[0].length,
|
|
1555
|
-
text: attached === undefined
|
|
1556
|
-
? REDACTED
|
|
1557
|
-
: `${name}${name.startsWith('--') ? '=' : ''}${REDACTED}`,
|
|
1558
|
-
});
|
|
1559
|
-
}
|
|
1560
|
-
if (attached === undefined)
|
|
1561
|
-
index += 1;
|
|
1562
|
-
continue;
|
|
1563
|
-
}
|
|
1564
|
-
const flag = client === 'curl'
|
|
1565
|
-
? CURL_AUTH_FLAG.exec(decoded)
|
|
1566
|
-
: /^(-a)(.+)?$|^(--auth)(?:=(.*))?$/.exec(decoded);
|
|
1567
|
-
if (!flag)
|
|
1568
|
-
continue;
|
|
1569
|
-
const name = flag[1] ?? flag[3];
|
|
1570
|
-
const attached = flag[2] ?? flag[4];
|
|
1571
|
-
const value = attached === undefined ? words[index + 1] : word;
|
|
1572
|
-
if (!value || /^[;&|()\n]$/.test(value[0]))
|
|
1573
|
-
continue;
|
|
1574
|
-
let replacement = REDACTED;
|
|
1575
|
-
if (CURL_URL_FIELD.test(name.replace(/^-+/, '')) ||
|
|
1576
|
-
(!name.startsWith('--') && name.endsWith('x'))) {
|
|
1577
|
-
const proxy = attached ?? decodeShellWord(value[0]);
|
|
1578
|
-
if (!PROXY_USERINFO.test(proxy)) {
|
|
1579
|
-
if (attached === undefined)
|
|
1580
|
-
index += 1;
|
|
1581
|
-
continue;
|
|
1582
|
-
}
|
|
1583
|
-
replacement = proxy.replace(PROXY_USERINFO, `$1${REDACTED}@`);
|
|
1584
|
-
}
|
|
1585
|
-
if (attached !== undefined) {
|
|
1586
|
-
edits.push({
|
|
1587
|
-
start: word.index,
|
|
1588
|
-
end: word.index + raw.length,
|
|
1589
|
-
text: `${name}${name?.startsWith('--') ? '=' : ''}${replacement}`,
|
|
1590
|
-
});
|
|
1591
|
-
}
|
|
1592
|
-
else {
|
|
1593
|
-
edits.push({
|
|
1594
|
-
start: value.index,
|
|
1595
|
-
end: value.index + value[0].length,
|
|
1596
|
-
text: replacement,
|
|
1597
|
-
});
|
|
1598
|
-
index += 1;
|
|
1599
|
-
}
|
|
1600
|
-
}
|
|
1601
|
-
finishCurl();
|
|
1602
|
-
// Every edit above replaces a whole word, and a word an edit replaces is
|
|
1603
|
-
// gone before `redactCredentialFlagValues` reads the text. Where that word
|
|
1604
|
-
// was a credential flag, the flag's value would escape, so the chain is
|
|
1605
|
-
// followed here for every edit, whichever tool or flag made it.
|
|
1606
|
-
const wordAt = new Map(words.map((word, at) => [word.index, at]));
|
|
1607
|
-
const wordEndingAt = new Map(words.map((word, at) => [word.index + word[0].length, at]));
|
|
1608
|
-
const nextWord = (end) => {
|
|
1609
|
-
let next = wordEndingAt.get(end);
|
|
1610
|
-
if (next === undefined)
|
|
1611
|
-
return undefined;
|
|
1612
|
-
next += 1;
|
|
1613
|
-
while (words[next] && /^(?:\n|\\\r?\n)$/.test(words[next][0]))
|
|
1614
|
-
next += 1;
|
|
1615
|
-
const following = words[next];
|
|
1616
|
-
if (!following || /^[;&|()\n]$/.test(following[0]))
|
|
1617
|
-
return undefined;
|
|
1618
|
-
return {
|
|
1619
|
-
word: following[0],
|
|
1620
|
-
start: following.index,
|
|
1621
|
-
end: following.index + following[0].length,
|
|
1622
|
-
};
|
|
1623
|
-
};
|
|
1624
|
-
const chainedWords = new Set();
|
|
1625
|
-
const chainEdits = edits.flatMap((edit) => {
|
|
1626
|
-
const at = wordAt.get(edit.start);
|
|
1627
|
-
const word = at === undefined ? undefined : words[at];
|
|
1628
|
-
if (!word || edit.end !== word.index + word[0].length)
|
|
1629
|
-
return [];
|
|
1630
|
-
return followCredentialChain(word[0], edit.end, nextWord, chainedWords, command);
|
|
1631
|
-
});
|
|
1632
|
-
// A chain edit either matches an edit's word or misses it; on a match the
|
|
1633
|
-
// whole-word redaction replaces it, so the chain only ever adds.
|
|
1634
|
-
const editAt = new Map(edits.map((edit, at) => [edit.start, at]));
|
|
1635
|
-
for (const chainEdit of chainEdits) {
|
|
1636
|
-
const same = editAt.get(chainEdit.start);
|
|
1637
|
-
if (same === undefined)
|
|
1638
|
-
edits.push(chainEdit);
|
|
1639
|
-
else
|
|
1640
|
-
edits[same] = chainEdit;
|
|
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
|
-
}
|
|
1649
|
-
edits.sort((a, b) => a.start - b.start);
|
|
1650
|
-
for (const edit of edits.reverse())
|
|
1651
|
-
command = command.slice(0, edit.start) + edit.text + command.slice(edit.end);
|
|
1652
|
-
return command;
|
|
1653
|
-
}
|
|
1654
|
-
/** Find config consumers across shell wrappers before scanning their producers. */
|
|
1655
|
-
function getToolInputs(command, depth = 0) {
|
|
1656
|
-
const inputs = {
|
|
1657
|
-
curlConfig: depth > 8,
|
|
1658
|
-
env: new Set(),
|
|
1659
|
-
passSources: new Set(),
|
|
1660
|
-
curlVariables: new Set(),
|
|
1661
|
-
};
|
|
1662
|
-
if (depth > 8)
|
|
1663
|
-
return inputs;
|
|
1664
|
-
// Use the same invocation parser as redaction; an SSH -K is not curl config.
|
|
1665
|
-
redactToolArguments(command, inputs);
|
|
1666
|
-
const words = [...command.matchAll(new RegExp(SHELL_WORD_SOURCE, 'g'))];
|
|
1667
|
-
for (const word of words) {
|
|
1668
|
-
const decoded = decodeShellWord(word[0]);
|
|
1669
|
-
if (decoded === word[0] || !/[\s;&|()]/.test(decoded))
|
|
1670
|
-
continue;
|
|
1671
|
-
const nested = getToolInputs(decoded, depth + 1);
|
|
1672
|
-
inputs.curlConfig ||= nested.curlConfig;
|
|
1673
|
-
for (const key of nested.env)
|
|
1674
|
-
inputs.env.add(key);
|
|
1675
|
-
for (const source of nested.passSources)
|
|
1676
|
-
inputs.passSources.add(source);
|
|
1677
|
-
for (const name of nested.curlVariables)
|
|
1678
|
-
inputs.curlVariables.add(name);
|
|
1679
|
-
}
|
|
1680
|
-
if (inputs.curlConfig) {
|
|
1681
|
-
for (const word of words) {
|
|
1682
|
-
const body = decodeShellWord(word[0]);
|
|
1683
|
-
if (!hasPrivateConfigVariables(body))
|
|
1684
|
-
continue;
|
|
1685
|
-
for (const match of body.matchAll(/\b(?:expand-)?variable\s*[=: \t]+["']?%+([A-Za-z0-9_]+)/g))
|
|
1686
|
-
inputs.env.add(match[1]);
|
|
1687
|
-
}
|
|
1688
|
-
}
|
|
1689
|
-
return inputs;
|
|
1690
|
-
}
|
|
1691
|
-
/** Apply the same boundary to scripts carried inside quoted shell words. */
|
|
1692
|
-
/** The part of `before` that `after` replaced: what a redaction removed. */
|
|
1693
|
-
function changedPart(before, after) {
|
|
1694
|
-
let start = 0;
|
|
1695
|
-
while (start < before.length && before[start] === after[start])
|
|
1696
|
-
start += 1;
|
|
1697
|
-
let end = 0;
|
|
1698
|
-
while (end < before.length - start &&
|
|
1699
|
-
before[before.length - 1 - end] === after[after.length - 1 - end])
|
|
1700
|
-
end += 1;
|
|
1701
|
-
return before.slice(start, before.length - end);
|
|
1702
|
-
}
|
|
1703
|
-
function redactShellSyntax(command, depth = 0, inheritedInputs, valuesFirst = true) {
|
|
1704
|
-
// Nested shell/eval wrappers cannot create unbounded recursive scanning.
|
|
1705
|
-
if (depth > 8)
|
|
1706
|
-
return REDACTED;
|
|
1707
|
-
// Redacting a backtick body can erase the reference (`{{VALUE}}`) that
|
|
1708
|
-
// marks an outer assignment private, so read its inputs from the command
|
|
1709
|
-
// as written too.
|
|
1710
|
-
const writtenInputs = command.includes('`')
|
|
1711
|
-
? withInheritedInputs(backtickAwareInputs(command), inheritedInputs)
|
|
1712
|
-
: inheritedInputs;
|
|
1713
|
-
// Rewriting part of a backtick body can separate a header from its value, so
|
|
1714
|
-
// the value patterns redact such a match before any body is rewritten. Only
|
|
1715
|
-
// such a match: one whose value the later passes remove anyway is left to
|
|
1716
|
-
// them, since the word-based rules see the rest of its word (`"Cookie: a"b`).
|
|
1717
|
-
// Redacting one match early changes what the later passes see, so a value
|
|
1718
|
-
// still in their output is redacted early too and the passes run again.
|
|
1719
|
-
if (valuesFirst && command.includes('`') && !command.includes('\uE000')) {
|
|
1720
|
-
const values = valueRedactionTargets(command);
|
|
1721
|
-
if (values.length > 0) {
|
|
1722
|
-
const withoutValuesFirst = redactShellSyntax(command, depth, inheritedInputs, false);
|
|
1723
|
-
const early = new Set(values.filter((value) => withoutValuesFirst.includes(value)));
|
|
1724
|
-
for (let round = 0;; round += 1) {
|
|
1725
|
-
const out = redactAfterValues(redactValuesFirst(command, round < 3 ? early : new Set(values)), depth, writtenInputs);
|
|
1726
|
-
const stranded = values.filter((value) => !early.has(value) && out.includes(value));
|
|
1727
|
-
if (stranded.length === 0 || round === 3)
|
|
1728
|
-
return out;
|
|
1729
|
-
for (const value of stranded)
|
|
1730
|
-
early.add(value);
|
|
1731
|
-
}
|
|
1732
|
-
}
|
|
1733
|
-
}
|
|
1734
|
-
return redactAfterValues(command, depth, writtenInputs, valuesFirst);
|
|
1735
|
-
}
|
|
1736
|
-
/** The values the `VALUE_REDACTIONS` would remove from text with no backtick in the match. */
|
|
1737
|
-
function valueRedactionTargets(command) {
|
|
1738
|
-
const values = [];
|
|
1739
|
-
for (const [pattern, replacement] of VALUE_REDACTIONS) {
|
|
1740
|
-
const single = new RegExp(pattern.source, pattern.flags.replace('g', ''));
|
|
1741
|
-
for (const match of command.match(pattern) ?? []) {
|
|
1742
|
-
if (/[`\uE000\uE001]/.test(match))
|
|
1743
|
-
continue;
|
|
1744
|
-
const value = changedPart(match, redactValueMatch(match, single, replacement));
|
|
1745
|
-
if (value !== '' && !values.includes(value))
|
|
1746
|
-
values.push(value);
|
|
1747
|
-
}
|
|
1748
|
-
}
|
|
1749
|
-
return values;
|
|
1750
|
-
}
|
|
1751
|
-
/**
|
|
1752
|
-
* Apply the value patterns whose value is in `early`, holding every other
|
|
1753
|
-
* match aside so no later pattern can match part of it. A match that spans a
|
|
1754
|
-
* backtick is always held: the substitution pass owns that boundary, and an
|
|
1755
|
-
* unterminated one must still fail closed. A reference this erases is still
|
|
1756
|
-
* in the caller's `writtenInputs`.
|
|
1757
|
-
*/
|
|
1758
|
-
function redactValuesFirst(command, early) {
|
|
1759
|
-
const held = [];
|
|
1760
|
-
let whole = command;
|
|
1761
|
-
for (const [pattern, replacement] of VALUE_REDACTIONS) {
|
|
1762
|
-
const single = new RegExp(pattern.source, pattern.flags.replace('g', ''));
|
|
1763
|
-
whole = whole.replace(pattern, (match) => {
|
|
1764
|
-
const redacted = redactValueMatch(match, single, replacement);
|
|
1765
|
-
if (!/[`\uE000\uE001]/.test(match) && early.has(changedPart(match, redacted)))
|
|
1766
|
-
return redacted;
|
|
1767
|
-
held.push(match);
|
|
1768
|
-
return `\uE000${held.length - 1}\uE001`;
|
|
1769
|
-
});
|
|
1770
|
-
}
|
|
1771
|
-
// A held match can contain an earlier one; each refers only to earlier ones.
|
|
1772
|
-
while (whole.includes('\uE000'))
|
|
1773
|
-
whole = whole.replace(/\uE000(\d+)\uE001/g, (_, index) => held[Number(index)]);
|
|
1774
|
-
return whole;
|
|
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
|
-
}
|
|
1792
|
-
function redactAfterValues(command, depth, writtenInputs, valuesFirst = true) {
|
|
1793
|
-
command = redactCommandSubstitutions(command, depth, writtenInputs, valuesFirst);
|
|
1794
|
-
const inputs = withInheritedInputs(backtickAwareInputs(command), writtenInputs);
|
|
1795
|
-
const sensitiveAssignment = new RegExp(`^(${COMMAND_SENSITIVE_KEY_SOURCE})(\\+?=)`, 'i');
|
|
1796
|
-
// Nested scripts may be dispatched by any tool. Scan decoded compound
|
|
1797
|
-
// words instead of maintaining another list of interpreter/wrapper flags.
|
|
1798
|
-
// JSON and sensitive assignments already have structural/whole-word passes;
|
|
1799
|
-
// treating their values as commands would lose evidence or word boundaries.
|
|
1800
|
-
const rewriteWord = (word) => {
|
|
1801
|
-
const decoded = decodeShellWord(word);
|
|
1802
|
-
// A compound word beginning with a credential assignment may be either
|
|
1803
|
-
// an env value or a whole script. Its entire value is private in both
|
|
1804
|
-
// cases; dropping it avoids guessing which later spans belong to it.
|
|
1805
|
-
const assignment = sensitiveAssignment.exec(decoded);
|
|
1806
|
-
if (assignment)
|
|
1807
|
-
return `${assignment[1]}${assignment[2]}${REDACTED}`;
|
|
1808
|
-
if (decoded === word || !/[\s;&|()]/.test(decoded))
|
|
1809
|
-
return word;
|
|
1810
|
-
if (/^\s*[[{]/.test(decoded)) {
|
|
1811
|
-
try {
|
|
1812
|
-
JSON.parse(decoded);
|
|
1813
|
-
return word;
|
|
1814
|
-
}
|
|
1815
|
-
catch {
|
|
1816
|
-
// A shell group begins with '{' too; only valid JSON belongs to the
|
|
1817
|
-
// structural JSON pass instead of the nested-command pass.
|
|
1818
|
-
}
|
|
1819
|
-
}
|
|
1820
|
-
const redacted = redactShellSyntax(decoded, depth + 1, inputs, valuesFirst);
|
|
1821
|
-
return redacted === decoded ? word : JSON.stringify(redacted);
|
|
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);
|
|
1849
|
-
return applyCommandRedactions(redactCredentialAssignments(out, inputs));
|
|
1850
|
-
}
|
|
1851
|
-
/**
|
|
1852
|
-
* Redact common secret shapes from a shell command and cap its length so
|
|
1853
|
-
* command text never carries credentials or bulk file content off-box.
|
|
1854
|
-
*
|
|
1855
|
-
* Pure function — no I/O.
|
|
1856
|
-
*/
|
|
90
|
+
const COMMAND_WITHHELD = '[command withheld]';
|
|
1857
91
|
export function sanitizeCommand(command) {
|
|
1858
|
-
//
|
|
1859
|
-
//
|
|
1860
|
-
|
|
1861
|
-
|
|
1862
|
-
out = `${out.slice(0, MAX_COMMAND_CHARS)} [... truncated, ${out.length - MAX_COMMAND_CHARS} characters omitted]`;
|
|
1863
|
-
}
|
|
1864
|
-
return out;
|
|
92
|
+
// Inspect a bounded prefix only. Never decode quotes, substitutions, escaped
|
|
93
|
+
// words, paths or assignments, and never copy any unrecognized input back.
|
|
94
|
+
const first = /^[ \t\r\n]*([a-z][a-z0-9.+-]{0,31})(?=[ \t\r\n;|&<>]|$)/.exec(command.slice(0, 128))?.[1];
|
|
95
|
+
return first && EXECUTABLES.has(first) ? first : COMMAND_WITHHELD;
|
|
1865
96
|
}
|
|
1866
97
|
//# sourceMappingURL=sanitize-command.js.map
|