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