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.
@@ -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
- /:\(\)\s*\{\s*:\|:\&\s*\}\s*;\s*:/, // fork bomb
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
- const SHELL_BINARIES = new Set([
139
- 'sh', 'bash', 'dash', 'zsh', 'ksh', 'ash', 'busybox',
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 on whitespace the way a shell would, keeping quoted runs together.
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
- * `parseSegments` splits on `/\s+/`, which turns `sh -c "sudo id"` into
153
- * `["sh", "-c", "\"sudo", "id\""]` — the argument is no longer a unit, so nothing
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 splitRespectingQuotes(command) {
157
- const words = [];
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 === '"' || 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
- if (current)
174
- words.push(current);
175
- current = '';
271
+ endWord();
176
272
  continue;
177
273
  }
178
274
  current += ch;
179
275
  }
180
- if (current)
181
- words.push(current);
182
- return words;
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
- // `sh -c <command>`. Carries no substitution at all, so the scan above does not
234
- // see it; found while mapping the reported bypass rather than in the report.
235
- const words = splitRespectingQuotes(command);
236
- for (let i = 0; i < words.length; i++) {
237
- if (!SHELL_BINARIES.has(stripPath(unquote(words[i]))))
238
- continue;
239
- for (let j = i + 1; j < words.length; j++) {
240
- if (words[j] === '-c') {
241
- if (words[j + 1] !== undefined)
242
- found.push(words[j + 1]);
243
- break;
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
- // Only flags may sit between the shell and its `-c`; anything else means
246
- // this was not a `-c` invocation.
247
- if (!words[j].startsWith('-'))
248
- break;
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
- const DISQUALIFYING_ARGS = {
287
- find: /^-(exec|execdir|ok|okdir|delete|fprintf?|fls)$/,
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 raw of command.split(/[;&|\n]/)) {
321
- const words = raw.trim().split(/\s+/).filter(Boolean);
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
- segments.push({ head: stripPath(words[i]), args: words.slice(i + 1) });
543
+ return { head: stripPath(words[i]), args: words.slice(i + 1) };
334
544
  }
335
545
  }
336
- return segments;
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 segment of command.split(/[;&|\n]/)) {
399
- const found = elevatedBinary(segment.trim().split(/\s+/).filter(Boolean));
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
- const heads = command.split('|').map((part) => parseSegments(part)[0]?.head);
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) => ({ label: String(re), test: (c) => re.test(c) })),
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) || DESTRUCTIVE_PATTERNS.some((re) => re.test(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
- let cmd = command.trim();
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 raw;
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 raw of command.split(/[;&|\n]/)) {
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 binary = extractBinary(trimmed);
605
- const fullCommand = trimmed;
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
- // this function.
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.split(/\s+/).slice(0, 2).join(' ');
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' };