ssh-mcp 2.5.1 → 2.7.0
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 +58 -10
- package/build/cli.js +76 -0
- package/build/index.js +13 -4
- package/build/policy/classifier.js +503 -81
- package/build/policy/engine.js +74 -7
- package/build/tools/descriptions.js +2 -2
- package/build/transport/http.js +240 -19
- package/package.json +3 -3
|
@@ -82,7 +82,19 @@ const FORBIDDEN_PATTERNS = [
|
|
|
82
82
|
/rm\s+-rf?\s+\/(\s|$)/, // rm -rf / — the filesystem root itself
|
|
83
83
|
/mkfs\./,
|
|
84
84
|
/>\s*\/dev\/sd/,
|
|
85
|
-
|
|
85
|
+
// Fork bomb, with whitespace allowed at the five positions bash allows it and the old
|
|
86
|
+
// pattern did not: before the parentheses, inside them, either side of the pipe, and
|
|
87
|
+
// before the ampersand. `: () { : | : & } ; :` ran and classified `safe`.
|
|
88
|
+
//
|
|
89
|
+
// What this still does not catch, so that nobody reads it as "fork bombs are handled":
|
|
90
|
+
// the same bomb under another name (`f(){ f|f& };f`), a body that separates the two
|
|
91
|
+
// calls with `&` or `;` rather than `|` (`:(){ :&:& };:`), and a comment between the
|
|
92
|
+
// tokens (`:() # x\n{ :|:& };:`). Matching the first needs a backreference over an
|
|
93
|
+
// unbounded body and this file has already shipped one ReDoS; the others would widen a
|
|
94
|
+
// list no role, tier or approval mode can override. The impact is a denial of service
|
|
95
|
+
// against the target host rather than against this server, which is not worth either
|
|
96
|
+
// trade — but it is a hole, not a closed door.
|
|
97
|
+
/:\s*\(\s*\)\s*\{\s*:\s*\|\s*:\s*&\s*\}\s*;\s*:/,
|
|
86
98
|
/>\s*\/etc\/cron/,
|
|
87
99
|
/>\s*\/etc\/systemd/,
|
|
88
100
|
/>\s*~\/.ssh\/authorized_keys/,
|
|
@@ -135,9 +147,45 @@ const CLASS_RANK = {
|
|
|
135
147
|
privileged: 3,
|
|
136
148
|
};
|
|
137
149
|
/** Shells that run their next argument as a command when given `-c`. */
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
150
|
+
/**
|
|
151
|
+
* Interpreters, the flags that hand them a program, and whether this file can read it.
|
|
152
|
+
*
|
|
153
|
+
* One table rather than three overlapping name lists: adding an interpreter here is the
|
|
154
|
+
* whole change, and a shell list that drifts out of step with a readability list is how a
|
|
155
|
+
* new shell ends up treated as an unreadable interpreter.
|
|
156
|
+
*
|
|
157
|
+
* `readable` means the program is shell, so classifying it is this same job done again
|
|
158
|
+
* and the class it earns is real. Python, Perl, Ruby, Node and PHP are not: their program
|
|
159
|
+
* is a different language, and `os.system('sudo id')` carries an elevation the shell
|
|
160
|
+
* classifier cannot see. Pretending to parse them would be a worse answer than admitting
|
|
161
|
+
* we cannot, so an unreadable program makes the command `destructive` — "we cannot tell",
|
|
162
|
+
* not "this is root", the same answer `hasUnnameableCommand` gives.
|
|
163
|
+
*
|
|
164
|
+
* `busybox` is not here: it takes an applet name, not `-c`, so `busybox sh -c …` is an
|
|
165
|
+
* `sh` invocation behind a wrapper. It is in EXEC_WRAPPERS instead, which is what it is.
|
|
166
|
+
*/
|
|
167
|
+
// Null-prototype for the reason `mergePolicyRules` spells out (#172): this is indexed by
|
|
168
|
+
// a command word, which is a free string, so on a plain object `INTERPRETERS['toString']`
|
|
169
|
+
// resolves to a function and reading `.flags` off it throws inside the policy gate.
|
|
170
|
+
const INTERPRETERS = Object.assign(Object.create(null), {
|
|
171
|
+
sh: { flags: ['-c'], readable: true },
|
|
172
|
+
bash: { flags: ['-c'], readable: true },
|
|
173
|
+
dash: { flags: ['-c'], readable: true },
|
|
174
|
+
zsh: { flags: ['-c'], readable: true },
|
|
175
|
+
ksh: { flags: ['-c'], readable: true },
|
|
176
|
+
ash: { flags: ['-c'], readable: true },
|
|
177
|
+
python: { flags: ['-c'], readable: false },
|
|
178
|
+
python2: { flags: ['-c'], readable: false },
|
|
179
|
+
python3: { flags: ['-c'], readable: false },
|
|
180
|
+
perl: { flags: ['-e', '-E'], readable: false },
|
|
181
|
+
ruby: { flags: ['-e'], readable: false },
|
|
182
|
+
// `-p`/`--print` evaluate exactly as `-e` does and then print the result.
|
|
183
|
+
node: { flags: ['-e', '--eval', '-p', '--print'], readable: false },
|
|
184
|
+
php: { flags: ['-r'], readable: false },
|
|
185
|
+
});
|
|
186
|
+
/** Flags whose value is a separate word, which is not the program. `awk -F ':' '{…}'`. */
|
|
187
|
+
/** `find … -exec <cmd> +` runs cmd. */
|
|
188
|
+
const FIND_EXEC_FLAGS = new Set(['-exec', '-execdir', '-ok', '-okdir']);
|
|
141
189
|
/**
|
|
142
190
|
* How deep a substitution may nest before we stop reading and refuse to guess.
|
|
143
191
|
*
|
|
@@ -147,39 +195,156 @@ const SHELL_BINARIES = new Set([
|
|
|
147
195
|
*/
|
|
148
196
|
const MAX_NESTING_DEPTH = 8;
|
|
149
197
|
/**
|
|
150
|
-
* Split
|
|
198
|
+
* Split a command into segments of words, resolving quoting as a shell would.
|
|
199
|
+
*
|
|
200
|
+
* The classifier's regexes were written against the command as sent, but the transport
|
|
201
|
+
* removes quoting before the command runs, so `rm -rf "/etc"` matched no destructive
|
|
202
|
+
* pattern and `s"u"do id` named no privilege prefix. Every consumer that used to split on
|
|
203
|
+
* `/[;&|\n]/` and `/\s+/` reads this instead, so quote removal happens once, in one
|
|
204
|
+
* place, rather than being re-derived per rule.
|
|
151
205
|
*
|
|
152
|
-
*
|
|
153
|
-
* `
|
|
154
|
-
* downstream can classify it as the command it is.
|
|
206
|
+
* Not a shell parser. Variable expansion, arithmetic and here-documents are out of scope —
|
|
207
|
+
* `hasUnnameableCommand` is what refuses a command word this cannot resolve.
|
|
155
208
|
*/
|
|
156
|
-
function
|
|
157
|
-
|
|
209
|
+
function tokenizeSegments(command) {
|
|
210
|
+
return tokenizeSegmentsDetailed(command).map((segment) => segment.words);
|
|
211
|
+
}
|
|
212
|
+
/**
|
|
213
|
+
* The same split, keeping the separator that introduced each segment.
|
|
214
|
+
*
|
|
215
|
+
* Only the pipe rules need it: `|` feeds one command's output into the next, while `;`
|
|
216
|
+
* and `&` merely sequence, and a rule about pipes must not fire on a rule about sequences.
|
|
217
|
+
* Deriving both views from one tokeniser is what stops the two from drifting apart.
|
|
218
|
+
*/
|
|
219
|
+
function tokenizeSegmentsDetailed(command, honorQuotes = true) {
|
|
220
|
+
const segments = [];
|
|
221
|
+
let pending = '';
|
|
222
|
+
let words = [];
|
|
158
223
|
let current = '';
|
|
159
224
|
let quote = null;
|
|
225
|
+
let escaped = false;
|
|
226
|
+
const endWord = () => {
|
|
227
|
+
if (current)
|
|
228
|
+
words.push(current);
|
|
229
|
+
current = '';
|
|
230
|
+
};
|
|
231
|
+
const endSegment = (sep) => {
|
|
232
|
+
endWord();
|
|
233
|
+
if (words.length > 0)
|
|
234
|
+
segments.push({ words, sep: pending });
|
|
235
|
+
words = [];
|
|
236
|
+
pending = sep;
|
|
237
|
+
};
|
|
160
238
|
for (const ch of command) {
|
|
239
|
+
if (escaped) {
|
|
240
|
+
// A backslash quotes the next character, so `\reboot` runs reboot. Keeping the
|
|
241
|
+
// character and dropping the backslash is what the shell does.
|
|
242
|
+
current += ch;
|
|
243
|
+
escaped = false;
|
|
244
|
+
continue;
|
|
245
|
+
}
|
|
161
246
|
if (quote) {
|
|
247
|
+
// Backslash is literal inside single quotes; inside double quotes it escapes.
|
|
248
|
+
if (ch === '\\' && quote === '"') {
|
|
249
|
+
escaped = true;
|
|
250
|
+
continue;
|
|
251
|
+
}
|
|
162
252
|
if (ch === quote)
|
|
163
253
|
quote = null;
|
|
164
254
|
else
|
|
165
255
|
current += ch;
|
|
166
256
|
continue;
|
|
167
257
|
}
|
|
168
|
-
if (ch === '
|
|
258
|
+
if (ch === '\\') {
|
|
259
|
+
escaped = true;
|
|
260
|
+
continue;
|
|
261
|
+
}
|
|
262
|
+
if (honorQuotes && (ch === '"' || ch === "'")) {
|
|
169
263
|
quote = ch;
|
|
170
264
|
continue;
|
|
171
265
|
}
|
|
266
|
+
if (ch === ';' || ch === '&' || ch === '|' || ch === '\n') {
|
|
267
|
+
endSegment(ch);
|
|
268
|
+
continue;
|
|
269
|
+
}
|
|
172
270
|
if (/\s/.test(ch)) {
|
|
173
|
-
|
|
174
|
-
words.push(current);
|
|
175
|
-
current = '';
|
|
271
|
+
endWord();
|
|
176
272
|
continue;
|
|
177
273
|
}
|
|
178
274
|
current += ch;
|
|
179
275
|
}
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
276
|
+
endSegment('');
|
|
277
|
+
// A quote left open at the end is not a command a shell would run — it is a syntax
|
|
278
|
+
// error. Reading it as one long quoted argument is the dangerous reading: `echo "hi;
|
|
279
|
+
// sudo id` then never splits on the `;` and the elevation disappears, which measured as
|
|
280
|
+
// `privileged` before this tokeniser and `safe` after. So fall back to the scan that
|
|
281
|
+
// treats the quote character as ordinary text, which splits and still sees `sudo id`.
|
|
282
|
+
if (quote !== null)
|
|
283
|
+
return tokenizeSegmentsDetailed(command, false);
|
|
284
|
+
return segments;
|
|
285
|
+
}
|
|
286
|
+
/**
|
|
287
|
+
* The command rebuilt from its tokens, for the regex rules to match against.
|
|
288
|
+
*
|
|
289
|
+
* Segments are rejoined with `; ` so a pattern cannot match across two commands that the
|
|
290
|
+
* shell would run separately.
|
|
291
|
+
*/
|
|
292
|
+
let lastNormalizedInput = null;
|
|
293
|
+
let lastNormalizedOutput = '';
|
|
294
|
+
function normalizeCommand(command) {
|
|
295
|
+
// One evaluate asks for this about nineteen times, once per regex rule, and each ask
|
|
296
|
+
// re-ran the tokeniser over the whole command. The asks arrive in a row on the same
|
|
297
|
+
// input, so a one-entry cache removes almost all of it.
|
|
298
|
+
if (command === lastNormalizedInput)
|
|
299
|
+
return lastNormalizedOutput;
|
|
300
|
+
lastNormalizedInput = command;
|
|
301
|
+
lastNormalizedOutput = normalizeUncached(command);
|
|
302
|
+
return lastNormalizedOutput;
|
|
303
|
+
}
|
|
304
|
+
function normalizeUncached(command) {
|
|
305
|
+
return tokenizeSegments(command)
|
|
306
|
+
.map((words) => words.map((w) => (QUOTED_CONTENT.test(w) ? PLACEHOLDER : w)).join(' '))
|
|
307
|
+
.join('; ');
|
|
308
|
+
}
|
|
309
|
+
/**
|
|
310
|
+
* A character the tokeniser splits on or at, which a token can only hold if it was quoted.
|
|
311
|
+
*
|
|
312
|
+
* The tokeniser splits on unquoted separators and at unquoted whitespace, so a token
|
|
313
|
+
* holding either came from inside quotes — the shell will not split there, and its contents are data. Rejoining it
|
|
314
|
+
* into the pattern text is what makes an argument read as a command: `echo "hello; rm -rf
|
|
315
|
+
* /"` prints a string, and normalising it produced `echo hello; rm -rf /`, which matched
|
|
316
|
+
* the never-allowed list. Dropping those tokens keeps quote removal doing the job it was
|
|
317
|
+
* added for — `rm -rf "/etc"`, whose tokens hold no separator — without letting a quoted
|
|
318
|
+
* argument impersonate a command. A carrier that really does hand over a quoted command
|
|
319
|
+
* (`sh -c "ls; rm -rf /etc"`) is read by `nestedCommands`, not by this.
|
|
320
|
+
*/
|
|
321
|
+
const QUOTED_CONTENT = /[\s;&|\n]/;
|
|
322
|
+
/**
|
|
323
|
+
* What a quoted token becomes in the pattern text.
|
|
324
|
+
*
|
|
325
|
+
* Deleting it instead spliced its neighbours together and created an adjacency that appears
|
|
326
|
+
* nowhere in the command: `echo rm 'a|b' -rf /` normalised to `echo rm -rf /` and landed on
|
|
327
|
+
* the never-allowed list. A character no pattern contains and `\s` does not match stands in
|
|
328
|
+
* its place, so the two sides can no longer be read as one phrase.
|
|
329
|
+
*/
|
|
330
|
+
const PLACEHOLDER = '\u0000';
|
|
331
|
+
/**
|
|
332
|
+
* Try a regex against the command as written and against the tokenised form.
|
|
333
|
+
*
|
|
334
|
+
* Normalising alone is not enough. It rebuilds the command from tokens, so the shell
|
|
335
|
+
* metacharacters between them are gone, and a pattern that matches across a separator no
|
|
336
|
+
* longer fires — the fork bomb, whose `:|:&` needs the literal `|` and `&`, is the case
|
|
337
|
+
* that shows this. Normalising is still what defeats `rm -rf "/etc"`. Neither form is a
|
|
338
|
+
* superset of the other, so both are tried: for a pattern whose whole job is to notice
|
|
339
|
+
* something, the union is the only direction that cannot lose a match.
|
|
340
|
+
*
|
|
341
|
+
* Deliberately scoped to regexes. The word-based rules in `FORBIDDEN_RULES` already
|
|
342
|
+
* tokenise, and handing them the normalised string turns a separator that was safely
|
|
343
|
+
* inside a quoted argument into a real one — `grep -E "warn|reboot" syslog` became an
|
|
344
|
+
* unconditional refusal, which is the mention-vs-invocation bug of #91 all over again.
|
|
345
|
+
*/
|
|
346
|
+
function matchesEitherForm(command, test) {
|
|
347
|
+
return test(command) || test(normalizeCommand(command));
|
|
183
348
|
}
|
|
184
349
|
/**
|
|
185
350
|
* The commands hiding inside a command.
|
|
@@ -230,24 +395,58 @@ export function nestedCommands(command) {
|
|
|
230
395
|
for (const raw of backticks)
|
|
231
396
|
found.push(raw.slice(1, -1));
|
|
232
397
|
}
|
|
233
|
-
//
|
|
234
|
-
// see it
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
398
|
+
// A carrier hands a program to something that runs it. Carries no substitution, so the
|
|
399
|
+
// scan above does not see it. Read each segment's command word rather than scanning
|
|
400
|
+
// every word: matching a mention rather than an invocation is #91, and
|
|
401
|
+
// `cat /usr/bin/python3` names an interpreter without running one.
|
|
402
|
+
const segments = tokenizeSegmentsDetailed(command);
|
|
403
|
+
for (const { words } of segments) {
|
|
404
|
+
// Every word, not just the command word. Stopping at the head lost the carrier behind
|
|
405
|
+
// any wrapper this file does not list: `xargs -I {} sh -c 'sudo id'` stops on `{}`, and
|
|
406
|
+
// `flock`, `chroot`, `nsenter` and `systemd-run` are not wrappers it knows — all six
|
|
407
|
+
// went from `privileged` to `safe`. What keeps this from matching a *mention* is not
|
|
408
|
+
// where it looks but what it requires: `programAfterFlag` returns null unless a
|
|
409
|
+
// program-bearing flag actually follows, so `cat /usr/bin/python3` carries nothing.
|
|
410
|
+
//
|
|
411
|
+
// awk is deliberately absent. Its program is a positional operand rather than the
|
|
412
|
+
// value of a flag, so nothing distinguishes running awk from naming it, and its four
|
|
413
|
+
// implementations disagree about which flags consume a value — modelling that wrongly
|
|
414
|
+
// opened five separate holes across two review rounds. `awk` keeps the class it has on
|
|
415
|
+
// 2.5.1 and is tracked separately.
|
|
416
|
+
if (!operandsAreData(words)) {
|
|
417
|
+
for (let i = 0; i < words.length; i++) {
|
|
418
|
+
const spec = INTERPRETERS[stripPath(unquote(words[i]))];
|
|
419
|
+
if (spec === undefined || isFlagValue(words, i, spec.flags))
|
|
420
|
+
continue;
|
|
421
|
+
const program = programAfterFlag(words, i, spec.flags);
|
|
422
|
+
if (program !== null)
|
|
423
|
+
found.push(program);
|
|
244
424
|
}
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
425
|
+
}
|
|
426
|
+
// `-exec` is a flag rather than a command word, so this one is still a scan.
|
|
427
|
+
for (let i = 0; i < words.length; i++) {
|
|
428
|
+
if (!FIND_EXEC_FLAGS.has(words[i]) || words[i + 1] === undefined)
|
|
429
|
+
continue;
|
|
430
|
+
const rest = words.slice(i + 1);
|
|
431
|
+
const stop = rest.findIndex((w) => w === '+' || w === ';');
|
|
432
|
+
found.push((stop === -1 ? rest : rest.slice(0, stop)).join(' '));
|
|
433
|
+
// Step past what was consumed. Leaving `i` where it was emitted one child per
|
|
434
|
+
// `-exec` token, each an overlapping suffix of the last and each still holding the
|
|
435
|
+
// rest of them, so the recursion re-expanded the same tail once per token: twenty
|
|
436
|
+
// `-ok` tokens in 81 bytes cost 17 seconds of a single-threaded event loop.
|
|
437
|
+
i = stop === -1 ? words.length : i + 1 + stop;
|
|
249
438
|
}
|
|
250
439
|
}
|
|
440
|
+
// A pipe stage whose command word is an interpreter with no program of its own runs
|
|
441
|
+
// whatever the previous stage printed. Starts at 1: a leading `|` records its separator
|
|
442
|
+
// on the first segment, and reading `segments[-1]` threw.
|
|
443
|
+
for (let i = 1; i < segments.length; i++) {
|
|
444
|
+
if (segments[i].sep !== '|')
|
|
445
|
+
continue;
|
|
446
|
+
if (!readsProgramFromStdin(segments[i].words))
|
|
447
|
+
continue;
|
|
448
|
+
found.push(segments[i - 1].words.join(' '));
|
|
449
|
+
}
|
|
251
450
|
return found.filter((c) => c.trim().length > 0);
|
|
252
451
|
}
|
|
253
452
|
const PRIVILEGE_PREFIXES = new Set([
|
|
@@ -255,6 +454,8 @@ const PRIVILEGE_PREFIXES = new Set([
|
|
|
255
454
|
// BusyBox/Alpine, Docker entrypoints, the Rust sudo now default on some
|
|
256
455
|
// distributions, systemd's replacement, and the Solaris/illumos spelling.
|
|
257
456
|
'su-exec', 'gosu', 'sudo-rs', 'run0', 'pfexec',
|
|
457
|
+
// Both reach root with the command otherwise classified `safe`.
|
|
458
|
+
'runuser', 'setpriv',
|
|
258
459
|
]);
|
|
259
460
|
/**
|
|
260
461
|
* Binaries whose job is to run another command.
|
|
@@ -273,6 +474,8 @@ const PRIVILEGE_PREFIXES = new Set([
|
|
|
273
474
|
const EXEC_WRAPPERS = new Set([
|
|
274
475
|
'env', 'nohup', 'nice', 'ionice', 'command', 'exec', 'setsid', 'stdbuf',
|
|
275
476
|
'timeout', 'chrt', 'taskset', 'xargs', 'watch',
|
|
477
|
+
// `busybox <applet>` runs the applet, so it wraps rather than interprets.
|
|
478
|
+
'busybox',
|
|
276
479
|
]);
|
|
277
480
|
/**
|
|
278
481
|
* Arguments that turn an allowlisted binary into one that writes or executes.
|
|
@@ -283,9 +486,8 @@ const EXEC_WRAPPERS = new Set([
|
|
|
283
486
|
* \;` form only escaped because `;` happens to be a shell metacharacter, while
|
|
284
487
|
* the `+` terminator carries none.
|
|
285
488
|
*/
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
};
|
|
489
|
+
// Null-prototype for the same reason as INTERPRETERS: indexed by the command word.
|
|
490
|
+
const DISQUALIFYING_ARGS = Object.assign(Object.create(null), { find: /^-(exec|execdir|ok|okdir|delete|fprintf?|fls)$/ });
|
|
289
491
|
/** A leading `NAME=value`, which a shell treats as an assignment, not a command. */
|
|
290
492
|
const ASSIGNMENT = /^[A-Za-z_][A-Za-z0-9_]*=/;
|
|
291
493
|
/** `timeout 5s`, `nice 10` — a bare argument some wrappers take before the command. */
|
|
@@ -317,8 +519,16 @@ function stripPath(word) {
|
|
|
317
519
|
}
|
|
318
520
|
function parseSegments(command) {
|
|
319
521
|
const segments = [];
|
|
320
|
-
for (const
|
|
321
|
-
const
|
|
522
|
+
for (const words of tokenizeSegments(command)) {
|
|
523
|
+
const segment = parseWords(words);
|
|
524
|
+
if (segment !== null)
|
|
525
|
+
segments.push(segment);
|
|
526
|
+
}
|
|
527
|
+
return segments;
|
|
528
|
+
}
|
|
529
|
+
/** The same, for callers that already hold the tokenised words. */
|
|
530
|
+
function parseWords(words) {
|
|
531
|
+
{
|
|
322
532
|
let i = 0;
|
|
323
533
|
while (i < words.length && PRIVILEGE_PREFIXES.has(stripPath(words[i]))) {
|
|
324
534
|
i++;
|
|
@@ -330,10 +540,10 @@ function parseSegments(command) {
|
|
|
330
540
|
}
|
|
331
541
|
}
|
|
332
542
|
if (i < words.length) {
|
|
333
|
-
|
|
543
|
+
return { head: stripPath(words[i]), args: words.slice(i + 1) };
|
|
334
544
|
}
|
|
335
545
|
}
|
|
336
|
-
return
|
|
546
|
+
return null;
|
|
337
547
|
}
|
|
338
548
|
/**
|
|
339
549
|
* Whether a segment asks for elevation, reading past anything that is not yet
|
|
@@ -395,8 +605,8 @@ function elevatedBinary(words) {
|
|
|
395
605
|
* privileged decision — in the audit log, the OTel span and OPA's input (#134).
|
|
396
606
|
*/
|
|
397
607
|
function elevatedBinaryOf(command) {
|
|
398
|
-
for (const
|
|
399
|
-
const found = elevatedBinary(
|
|
608
|
+
for (const words of tokenizeSegments(command)) {
|
|
609
|
+
const found = elevatedBinary(words);
|
|
400
610
|
if (found !== null)
|
|
401
611
|
return found;
|
|
402
612
|
}
|
|
@@ -441,7 +651,13 @@ const DOWNLOADERS = new Set(['curl', 'wget']);
|
|
|
441
651
|
* running one when it succeeds.
|
|
442
652
|
*/
|
|
443
653
|
function pipesDownloadIntoShell(command) {
|
|
444
|
-
|
|
654
|
+
// One head per pipe stage. Splitting on every separator would make `curl -O x;
|
|
655
|
+
// bash build.sh` — download, then run a local script — match a rule whose label says
|
|
656
|
+
// "piping a download into a shell", on a list that cannot be switched off.
|
|
657
|
+
const segments = tokenizeSegmentsDetailed(command);
|
|
658
|
+
const heads = segments
|
|
659
|
+
.filter((segment, i) => i === 0 || segment.sep === '|')
|
|
660
|
+
.map((segment) => parseWords(segment.words)?.head);
|
|
445
661
|
const firstDownload = heads.findIndex((h) => h !== undefined && DOWNLOADERS.has(h));
|
|
446
662
|
if (firstDownload === -1)
|
|
447
663
|
return false;
|
|
@@ -466,7 +682,10 @@ function chownsRoot(command) {
|
|
|
466
682
|
});
|
|
467
683
|
}
|
|
468
684
|
const FORBIDDEN_RULES = [
|
|
469
|
-
...FORBIDDEN_PATTERNS.map((re) => ({
|
|
685
|
+
...FORBIDDEN_PATTERNS.map((re) => ({
|
|
686
|
+
label: String(re),
|
|
687
|
+
test: (c) => matchesEitherForm(c, (form) => re.test(form)),
|
|
688
|
+
})),
|
|
470
689
|
{
|
|
471
690
|
label: 'invoking a power-state command (shutdown, reboot, halt, poweroff) or eval',
|
|
472
691
|
test: (command) => invokedWords(command).some((w) => FORBIDDEN_INVOCATIONS.has(w)),
|
|
@@ -524,7 +743,8 @@ const DESTRUCTIVE_PATTERNS = [
|
|
|
524
743
|
* either.
|
|
525
744
|
*/
|
|
526
745
|
function isDestructive(command) {
|
|
527
|
-
return isForbidden(command) ||
|
|
746
|
+
return (isForbidden(command) ||
|
|
747
|
+
matchesEitherForm(command, (form) => DESTRUCTIVE_PATTERNS.some((re) => re.test(form))));
|
|
528
748
|
}
|
|
529
749
|
const LEADING_PRIVILEGE_PREFIXES = [
|
|
530
750
|
/^\s*sudo\b/,
|
|
@@ -533,7 +753,12 @@ const LEADING_PRIVILEGE_PREFIXES = [
|
|
|
533
753
|
/^\s*pkexec\b/,
|
|
534
754
|
];
|
|
535
755
|
export function extractBinary(command) {
|
|
536
|
-
|
|
756
|
+
// The first segment's words, so quoting is resolved — `s"u"do id` used to report
|
|
757
|
+
// `s"u"do` in the audit record and the refusal message. Reading the normalised whole
|
|
758
|
+
// command instead would be wrong in the other direction: it rejoins segments with
|
|
759
|
+
// `; `, so `ls | grep x` would report `ls;`, and this name reaches the audit record,
|
|
760
|
+
// the OTel span and OPA's input (#134). A separator is not a binary.
|
|
761
|
+
let cmd = (tokenizeSegments(command)[0] ?? []).join(' ').trim();
|
|
537
762
|
for (const prefix of LEADING_PRIVILEGE_PREFIXES) {
|
|
538
763
|
cmd = cmd.replace(prefix, '').trim();
|
|
539
764
|
}
|
|
@@ -551,6 +776,16 @@ export function extractBinary(command) {
|
|
|
551
776
|
* whether that name is knowable at all.
|
|
552
777
|
*/
|
|
553
778
|
function effectiveCommandWord(words) {
|
|
779
|
+
const i = effectiveCommandIndex(words);
|
|
780
|
+
return i === -1 ? null : words[i];
|
|
781
|
+
}
|
|
782
|
+
/**
|
|
783
|
+
* The position of that word, for callers that need to read its arguments.
|
|
784
|
+
*
|
|
785
|
+
* Deriving the word from the index rather than searching for it afterwards is what keeps
|
|
786
|
+
* `sh x sh -c 'sudo id'` from finding the wrong `sh`.
|
|
787
|
+
*/
|
|
788
|
+
function effectiveCommandIndex(words) {
|
|
554
789
|
let i = 0;
|
|
555
790
|
while (i < words.length) {
|
|
556
791
|
const raw = words[i];
|
|
@@ -569,10 +804,167 @@ function effectiveCommandWord(words) {
|
|
|
569
804
|
i++;
|
|
570
805
|
continue;
|
|
571
806
|
}
|
|
572
|
-
return
|
|
807
|
+
return i;
|
|
808
|
+
}
|
|
809
|
+
return -1;
|
|
810
|
+
}
|
|
811
|
+
/**
|
|
812
|
+
* Whether this segment's command word is one whose operands are data.
|
|
813
|
+
*
|
|
814
|
+
* The carrier scan reads every word, because a wrapper this file does not list would
|
|
815
|
+
* otherwise hide `sh -c`. The cost is that an outer tool's own flag can be mistaken for an
|
|
816
|
+
* interpreter's: `grep python3 -c /var/log/x` counts lines, and `-c` is also python's
|
|
817
|
+
* program flag. An allowlisted command is one this file already vouches for as reading
|
|
818
|
+
* rather than running, so its arguments are subjects, not commands — the positive form of
|
|
819
|
+
* the mention-vs-invocation rule at #91. `find` keeps its `-exec` scan, which is separate.
|
|
820
|
+
*/
|
|
821
|
+
function operandsAreData(words) {
|
|
822
|
+
const idx = effectiveCommandIndex(words);
|
|
823
|
+
return idx !== -1 && READ_ONLY_ALLOWLIST.has(stripPath(unquote(words[idx])));
|
|
824
|
+
}
|
|
825
|
+
/**
|
|
826
|
+
* Whether an interpreter name is the value of the flag before it rather than a command.
|
|
827
|
+
*
|
|
828
|
+
* The scan looks at every word so that a carrier behind an unlisted wrapper is not lost,
|
|
829
|
+
* and requires a program-bearing flag to follow before it believes one. That is not quite
|
|
830
|
+
* enough: `grep -e perl -e python` puts `perl` after a flag and a second `-e` after it,
|
|
831
|
+
* which reads as perl being handed a program. A search term is not a command.
|
|
832
|
+
*
|
|
833
|
+
* The test is deliberately narrow — the preceding flag must be one of *this interpreter's*
|
|
834
|
+
* program flags. Any flag at all was too much: `nsenter -t 1 -m sh -c 'sudo id'` puts the
|
|
835
|
+
* shell after `-m`, which takes no value, and dropping it lost a real elevation. Nothing
|
|
836
|
+
* here can know an arbitrary tool's grammar, so `sed -n perl -e p` — a shape no one
|
|
837
|
+
* writes — is still read as a carrier.
|
|
838
|
+
*/
|
|
839
|
+
function isFlagValue(words, i, flags) {
|
|
840
|
+
if (i === 0)
|
|
841
|
+
return false;
|
|
842
|
+
const previous = words[i - 1];
|
|
843
|
+
return previous.length > 1 && previous.startsWith('-') && flags.includes(previous);
|
|
844
|
+
}
|
|
845
|
+
/**
|
|
846
|
+
* The program an interpreter was handed on its command line, or null.
|
|
847
|
+
*
|
|
848
|
+
* Only flags may sit between the interpreter and its flag; anything else means this was
|
|
849
|
+
* not that kind of invocation, which is what keeps `python3 script.py` — a program this
|
|
850
|
+
* cannot read either, but one every deployment runs — out of the gate.
|
|
851
|
+
*/
|
|
852
|
+
function programAfterFlag(words, from, flags) {
|
|
853
|
+
for (let j = from + 1; j < words.length; j++) {
|
|
854
|
+
const word = words[j];
|
|
855
|
+
if (flags.includes(word))
|
|
856
|
+
return words[j + 1] ?? null;
|
|
857
|
+
const attached = flags.find((f) => word.startsWith(f) && word.length > f.length);
|
|
858
|
+
if (attached !== undefined) {
|
|
859
|
+
const rest = word.slice(attached.length);
|
|
860
|
+
// `sh -c'sudo id'` tokenises to `-csudo id`, so the program is attached. `bash -cx`
|
|
861
|
+
// is a flag cluster and the program is the next word. A space, or the `=` of
|
|
862
|
+
// `--eval=…`, is what tells them apart: a cluster is letters only.
|
|
863
|
+
if (/\s/.test(rest) || rest.startsWith('='))
|
|
864
|
+
return rest.replace(/^=/, '');
|
|
865
|
+
return words[j + 1] ?? null;
|
|
866
|
+
}
|
|
867
|
+
// The program flag need not lead the cluster: `bash -xc 'sudo id'` runs exactly what
|
|
868
|
+
// `bash -cx 'sudo id'` runs, and a prefix test saw the second and missed the first.
|
|
869
|
+
if (clusterCarriesFlag(word, flags))
|
|
870
|
+
return words[j + 1] ?? null;
|
|
871
|
+
if (!word.startsWith('-'))
|
|
872
|
+
return null;
|
|
573
873
|
}
|
|
574
874
|
return null;
|
|
575
875
|
}
|
|
876
|
+
/**
|
|
877
|
+
* What follows a short flag inside a single-dash cluster, or null if it holds none.
|
|
878
|
+
*
|
|
879
|
+
* Clusters only — a run of single letters after one dash. Long flags and attached values
|
|
880
|
+
* are handled before this is reached.
|
|
881
|
+
*/
|
|
882
|
+
function clusterCarriesFlag(word, flags) {
|
|
883
|
+
// Three letters at most. `/^-[A-Za-z]+$/` alone also matches every single-dash long
|
|
884
|
+
// option, and `find`'s predicates are full of them: `-type` contains perl's `-e`, so
|
|
885
|
+
// `find . -name perl -type f` read as a carrier and asked for approval.
|
|
886
|
+
if (!/^-[A-Za-z]{2,3}$/.test(word))
|
|
887
|
+
return false;
|
|
888
|
+
const body = word.slice(1);
|
|
889
|
+
// A cluster is letters only, so whatever follows the program flag is more flags — the
|
|
890
|
+
// program itself is always the next word.
|
|
891
|
+
return flags.some((flag) => flag.length === 2 && body.includes(flag[1]));
|
|
892
|
+
}
|
|
893
|
+
/** Spellings of "the program is on standard input" that look like a file operand. */
|
|
894
|
+
const STDIN_PATHS = new Set(['-', '/dev/stdin', '/dev/fd/0', '/proc/self/fd/0']);
|
|
895
|
+
/**
|
|
896
|
+
* Whether a segment's command word is an interpreter with no program of its own.
|
|
897
|
+
*
|
|
898
|
+
* `echo "sudo id" | bash` hands the program over on stdin, where there is nothing to
|
|
899
|
+
* read. This holds for the shells too — `readable` is about a `-c` argument, and there is
|
|
900
|
+
* no `-c` here. Requiring that no operand follow is what keeps `cat data | python3
|
|
901
|
+
* app.py` out of it: there the pipe carries data, not a program.
|
|
902
|
+
*/
|
|
903
|
+
function readsProgramFromStdin(words) {
|
|
904
|
+
const idx = effectiveCommandIndex(words);
|
|
905
|
+
if (idx === -1)
|
|
906
|
+
return false;
|
|
907
|
+
const bin = stripPath(words[idx]);
|
|
908
|
+
// Interpreters only. awk's one program-from-stdin form is `awk -f -`, already gated as a
|
|
909
|
+
// file flag; every other piped awk reads data, not a program.
|
|
910
|
+
const spec = INTERPRETERS[bin];
|
|
911
|
+
if (spec === undefined)
|
|
912
|
+
return false;
|
|
913
|
+
const flags = spec.flags;
|
|
914
|
+
let sawStdinFlag = false;
|
|
915
|
+
for (let j = idx + 1; j < words.length; j++) {
|
|
916
|
+
const word = words[j];
|
|
917
|
+
if (flags.some((f) => word === f || word.startsWith(f)))
|
|
918
|
+
return false;
|
|
919
|
+
// `--` ends the interpreter's own arguments; what follows is `$1..$n`, so a program
|
|
920
|
+
// still has to come from somewhere and `bash -s -- arg` reads stdin.
|
|
921
|
+
// `-s` says outright that the script comes from stdin, so everything after it is
|
|
922
|
+
// `$1..$n` whatever it looks like.
|
|
923
|
+
if (word === '-s') {
|
|
924
|
+
sawStdinFlag = true;
|
|
925
|
+
continue;
|
|
926
|
+
}
|
|
927
|
+
// `--` ends the interpreter's own arguments. Without `-s` the next word is the script
|
|
928
|
+
// itself, so `bash -- process.sh` reads a file while `bash -s -- arg` reads stdin.
|
|
929
|
+
if (word === '--') {
|
|
930
|
+
const next = words[j + 1];
|
|
931
|
+
return sawStdinFlag || next === undefined || STDIN_PATHS.has(next);
|
|
932
|
+
}
|
|
933
|
+
if (STDIN_PATHS.has(word))
|
|
934
|
+
return true;
|
|
935
|
+
if (!word.startsWith('-'))
|
|
936
|
+
return false;
|
|
937
|
+
}
|
|
938
|
+
return true;
|
|
939
|
+
}
|
|
940
|
+
/**
|
|
941
|
+
* Whether any segment hands a program to something this file cannot read.
|
|
942
|
+
*
|
|
943
|
+
* Keyed on the segment's command word, never on any word that happens to name an
|
|
944
|
+
* interpreter: matching a mention rather than an invocation is the defect this file
|
|
945
|
+
* records fixing as #91, and `cat /usr/bin/python3` names an interpreter without running
|
|
946
|
+
* one. A program-bearing flag must actually be present, so naming one is not enough.
|
|
947
|
+
*/
|
|
948
|
+
function hasUnreadableProgram(command) {
|
|
949
|
+
const segments = tokenizeSegmentsDetailed(command);
|
|
950
|
+
for (let i = 0; i < segments.length; i++) {
|
|
951
|
+
const { words, sep } = segments[i];
|
|
952
|
+
if (sep === '|' && readsProgramFromStdin(words))
|
|
953
|
+
return true;
|
|
954
|
+
if (!operandsAreData(words)) {
|
|
955
|
+
for (let j = 0; j < words.length; j++) {
|
|
956
|
+
const spec = INTERPRETERS[stripPath(unquote(words[j]))];
|
|
957
|
+
if (spec === undefined || spec.readable)
|
|
958
|
+
continue;
|
|
959
|
+
if (isFlagValue(words, j, spec.flags))
|
|
960
|
+
continue;
|
|
961
|
+
if (programAfterFlag(words, j, spec.flags) !== null)
|
|
962
|
+
return true;
|
|
963
|
+
}
|
|
964
|
+
}
|
|
965
|
+
}
|
|
966
|
+
return false;
|
|
967
|
+
}
|
|
576
968
|
/**
|
|
577
969
|
* Whether any segment runs a command this process cannot name.
|
|
578
970
|
*
|
|
@@ -591,49 +983,79 @@ function effectiveCommandWord(words) {
|
|
|
591
983
|
* promoting that would put a prompt on most ordinary shell usage.
|
|
592
984
|
*/
|
|
593
985
|
function hasUnnameableCommand(command) {
|
|
594
|
-
for (const
|
|
595
|
-
const words = raw.trim().split(/\s+/).filter(Boolean);
|
|
986
|
+
for (const words of tokenizeSegments(command)) {
|
|
596
987
|
const head = effectiveCommandWord(words);
|
|
597
988
|
if (head !== null && /[$`]/.test(head))
|
|
598
989
|
return true;
|
|
599
990
|
}
|
|
600
991
|
return false;
|
|
601
992
|
}
|
|
993
|
+
/**
|
|
994
|
+
* Commands this server synthesises rather than a user typing them.
|
|
995
|
+
*
|
|
996
|
+
* These reach the classifier as text like `sftp:upload /etc/passwd`, and no rule named
|
|
997
|
+
* them, so every one fell through to the default `safe` — a class the default rules grant
|
|
998
|
+
* to `operator` on `prod`. Writing an arbitrary file to the target is not `safe`: it is
|
|
999
|
+
* the same authority as `rm`, spelled through a different tool, and it reaches
|
|
1000
|
+
* `~/.ssh/authorized_keys` without touching a shell. Opening a session is the same
|
|
1001
|
+
* argument, since it hands over an interactive shell.
|
|
1002
|
+
*
|
|
1003
|
+
* `sftp:download` and `session:close` are deliberately absent. Both would move *down*
|
|
1004
|
+
* from `safe` — download to `read-only`, matching its `readOnlyHint`, and close being a
|
|
1005
|
+
* release rather than an acquisition. Lowering a class is a widening, and a security
|
|
1006
|
+
* release is the wrong place for one; they keep the class they have today.
|
|
1007
|
+
*/
|
|
1008
|
+
const SYNTHETIC_CLASSES = Object.assign(Object.create(null), {
|
|
1009
|
+
'sftp:upload': 'destructive',
|
|
1010
|
+
'session:open': 'destructive',
|
|
1011
|
+
});
|
|
1012
|
+
/** The first word, tokenised — the synthetic verb when there is one. */
|
|
1013
|
+
function syntheticVerb(command) {
|
|
1014
|
+
return tokenizeSegments(command)[0]?.[0] ?? '';
|
|
1015
|
+
}
|
|
1016
|
+
/**
|
|
1017
|
+
* The class of a command, and of everything it carries.
|
|
1018
|
+
*
|
|
1019
|
+
* A command that carries another decides nothing on its own: the remote shell expands
|
|
1020
|
+
* `$(...)`, backticks, `<(...)` and `sh -c` and runs what is inside, so the class is the
|
|
1021
|
+
* higher of the two. The scan for carriers already existed, but it *replaced* the outer
|
|
1022
|
+
* class instead of raising it, and only when the inner class was above `safe` — so
|
|
1023
|
+
* `sudo sh -c 'rm -rf /etc'` reported the inner `destructive` and lost the outer
|
|
1024
|
+
* `privileged`, which on `prod` is the difference between a prompt and a refusal.
|
|
1025
|
+
* Taking the maximum is what makes the scan unable to lower anything.
|
|
1026
|
+
*/
|
|
602
1027
|
export function classifyCommand(command, depth = 0) {
|
|
603
1028
|
const trimmed = command.trim();
|
|
604
|
-
const
|
|
605
|
-
|
|
606
|
-
// A command that carries another command decides nothing on its own: the remote
|
|
607
|
-
// shell expands `$(...)`, backticks, `<(...)` and `sh -c` and runs what is inside,
|
|
608
|
-
// so the class has to be the higher of the two. Before this, the outer command
|
|
609
|
-
// decided alone and `echo $(sudo id)` was `safe` — a class the default rules grant
|
|
610
|
-
// on `prod` to `operator` and `admin`, while granting `privileged` there to nobody
|
|
611
|
-
// at all (GHSA-v8jh-gv7v-3gvq). It also skipped the approval prompt, since only
|
|
612
|
-
// `destructive` and `privileged` raise one, and the audit record said `safe`.
|
|
613
|
-
//
|
|
614
|
-
// Runs before the checks below rather than after, so the class it produces is not
|
|
615
|
-
// something a later branch can lower.
|
|
616
|
-
if (depth < MAX_NESTING_DEPTH) {
|
|
617
|
-
let highest = null;
|
|
618
|
-
for (const inner of nestedCommands(trimmed)) {
|
|
619
|
-
const parsed = classifyCommand(inner, depth + 1);
|
|
620
|
-
if (highest === null || CLASS_RANK[parsed.class] > CLASS_RANK[highest.class]) {
|
|
621
|
-
highest = parsed;
|
|
622
|
-
}
|
|
623
|
-
}
|
|
624
|
-
// `binary` comes from the inner command deliberately: it is what the audit
|
|
625
|
-
// record and the refusal message name, and naming the outer `echo` would
|
|
626
|
-
// describe the wrong process as the one that ran as root.
|
|
627
|
-
if (highest !== null && CLASS_RANK[highest.class] > CLASS_RANK['safe']) {
|
|
628
|
-
return { binary: highest.binary, fullCommand, class: highest.class };
|
|
629
|
-
}
|
|
630
|
-
}
|
|
631
|
-
else {
|
|
1029
|
+
const outer = classifyOuter(trimmed);
|
|
1030
|
+
if (depth >= MAX_NESTING_DEPTH) {
|
|
632
1031
|
// Nesting this deep is not something an operator writes, and we have stopped
|
|
633
|
-
// reading. Refusing to guess is the only answer consistent with the rest of
|
|
634
|
-
//
|
|
635
|
-
return { binary, fullCommand, class: 'privileged' };
|
|
1032
|
+
// reading. Refusing to guess is the only answer consistent with the rest of this
|
|
1033
|
+
// file.
|
|
1034
|
+
return { binary: outer.binary, fullCommand: trimmed, class: 'privileged' };
|
|
1035
|
+
}
|
|
1036
|
+
let highest = outer;
|
|
1037
|
+
// A floor, not a verdict. Returning the synthetic class outright would put it above
|
|
1038
|
+
// the elevation and never-allowed checks, so `sftp:upload /tmp/x; sudo id` would
|
|
1039
|
+
// record `destructive` where the command is `privileged`.
|
|
1040
|
+
const verb = syntheticVerb(trimmed);
|
|
1041
|
+
const floor = SYNTHETIC_CLASSES[verb];
|
|
1042
|
+
if (floor !== undefined && CLASS_RANK[floor] > CLASS_RANK[highest.class]) {
|
|
1043
|
+
highest = { binary: verb, fullCommand: trimmed, class: floor };
|
|
1044
|
+
}
|
|
1045
|
+
for (const inner of nestedCommands(trimmed)) {
|
|
1046
|
+
const parsed = classifyCommand(inner, depth + 1);
|
|
1047
|
+
// `binary` follows the winning side deliberately: it is what the audit record and
|
|
1048
|
+
// the refusal message name, and naming the outer `echo` would describe the wrong
|
|
1049
|
+
// process as the one that ran as root.
|
|
1050
|
+
if (CLASS_RANK[parsed.class] > CLASS_RANK[highest.class])
|
|
1051
|
+
highest = parsed;
|
|
636
1052
|
}
|
|
1053
|
+
return { binary: highest.binary, fullCommand: trimmed, class: highest.class };
|
|
1054
|
+
}
|
|
1055
|
+
/** The class of the command itself, reading none of what it carries. */
|
|
1056
|
+
function classifyOuter(trimmed) {
|
|
1057
|
+
const binary = extractBinary(trimmed);
|
|
1058
|
+
const fullCommand = trimmed;
|
|
637
1059
|
// `binary` names the subject of the class. For everything below it is the
|
|
638
1060
|
// leading command; here it is the one that runs as root, which are the same
|
|
639
1061
|
// thing only when the prefix leads.
|
|
@@ -641,7 +1063,7 @@ export function classifyCommand(command, depth = 0) {
|
|
|
641
1063
|
if (elevated !== null) {
|
|
642
1064
|
return { binary: elevated, fullCommand, class: 'privileged' };
|
|
643
1065
|
}
|
|
644
|
-
if (isDestructive(trimmed) || hasDisqualifyingArgs(trimmed)) {
|
|
1066
|
+
if (hasUnreadableProgram(trimmed) || isDestructive(trimmed) || hasDisqualifyingArgs(trimmed)) {
|
|
645
1067
|
return { binary, fullCommand, class: 'destructive' };
|
|
646
1068
|
}
|
|
647
1069
|
// Below this point every branch assumes the command word names something we
|
|
@@ -655,7 +1077,7 @@ export function classifyCommand(command, depth = 0) {
|
|
|
655
1077
|
if (hasUnnameableCommand(trimmed)) {
|
|
656
1078
|
return { binary, fullCommand, class: 'destructive' };
|
|
657
1079
|
}
|
|
658
|
-
const twoWordPrefix = fullCommand
|
|
1080
|
+
const twoWordPrefix = (tokenizeSegments(fullCommand)[0] ?? []).slice(0, 2).join(' ');
|
|
659
1081
|
if (READ_ONLY_ALLOWLIST.has(binary) || READ_ONLY_ALLOWLIST.has(twoWordPrefix)) {
|
|
660
1082
|
if (SHELL_CONTROL_CHARS.test(trimmed)) {
|
|
661
1083
|
return { binary, fullCommand, class: 'safe' };
|