ssh-mcp 2.5.0 → 2.6.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 +19 -8
- package/build/policy/classifier.js +490 -80
- package/build/policy/engine.js +25 -3
- package/build/tools/descriptions.js +2 -2
- package/package.json +3 -3
package/README.md
CHANGED
|
@@ -152,8 +152,8 @@ claude mcp add --transport stdio ssh-mcp -- ssh-mcp
|
|
|
152
152
|
| `close-session` | Close a session. A background session's command is signalled (INT/TERM/KILL) before its channel is dropped | — | ✅ |
|
|
153
153
|
| `read-session-output` | Read output from background sessions (e.g., `tail -f`) | ✅ | — |
|
|
154
154
|
| `read-command` | Execute allowlisted read-only commands (`ls`, `cat`, `grep`, ...) | ✅ | — |
|
|
155
|
-
| `run-command` | Execute arbitrary commands (destructive
|
|
156
|
-
| `privileged-command` | Execute with sudo (
|
|
155
|
+
| `run-command` | Execute arbitrary commands (destructive/privileged need approval, unless `approvalPolicy = "auto"`) | — | — |
|
|
156
|
+
| `privileged-command` | Execute with sudo (needs approval, unless `approvalPolicy = "auto"`) | — | ✅ |
|
|
157
157
|
| `sftp-upload` | Upload a file via SFTP | — | ✅ |
|
|
158
158
|
| `sftp-download` | Download a file via SFTP | ✅ | — |
|
|
159
159
|
| `signal-process` | Send INT/TERM/KILL to a remote PID | — | ✅ |
|
|
@@ -422,7 +422,13 @@ denylist; an invalid pattern fails at startup rather than degrading silently.
|
|
|
422
422
|
### Approval Modes
|
|
423
423
|
|
|
424
424
|
- `auto` — no prompts (dev only!)
|
|
425
|
-
- `ask-destructive` — prompt for destructive/privileged (default)
|
|
425
|
+
- `ask-destructive` — prompt for destructive/privileged (default). Narrower than it sounds:
|
|
426
|
+
outside the never-allowed list, `destructive` is one `rm -rf /path` pattern, `find` with a
|
|
427
|
+
write/exec flag, an unresolvable command word, a program handed to an interpreter this
|
|
428
|
+
server cannot read (`python3 -c`, `perl -e`, `node -e`, a program arriving on a pipe —
|
|
429
|
+
but not `awk`, whose program is not read), and `sftp-upload`/interactive `open-session` — elevation classifies `privileged`, which also
|
|
430
|
+
prompts. Ordinary writes, service control and signals do not. See
|
|
431
|
+
[SECURITY.md](./SECURITY.md) before relying on this in production.
|
|
426
432
|
- `ask-all` — prompt for every command
|
|
427
433
|
- `deny` — reject destructive/privileged commands outright (no prompt)
|
|
428
434
|
|
|
@@ -566,7 +572,7 @@ level number.
|
|
|
566
572
|
### Safe Defaults
|
|
567
573
|
|
|
568
574
|
- **Non-root** user in all examples
|
|
569
|
-
- **TOFU** host key verification (accept on first connect, verify after)
|
|
575
|
+
- **TOFU** host key verification (accept on first connect, verify after — within one process; see [SECURITY.md](./SECURITY.md#host-key-trust-does-not-survive-a-restart))
|
|
570
576
|
- **RFC 9142** algorithm allow-list (no SHA-1, no CBC, no ssh-rsa)
|
|
571
577
|
- **exec()-only** (no persistent su shells — fixes PTY leak)
|
|
572
578
|
- **Sudo via stdin** (not argv — fixes process list leak)
|
|
@@ -673,6 +679,7 @@ Secrets are **never** passed as CLI arguments.
|
|
|
673
679
|
| `--bearerToken` | — | Bearer token for HTTP transport auth (required for `--transport=http`) |
|
|
674
680
|
| `--rateLimit` | 0 | HTTP requests per minute on the MCP route (0 = unlimited) |
|
|
675
681
|
| `--allowedHosts` | bind address + localhost | Comma-separated Host headers accepted by the DNS-rebinding guard |
|
|
682
|
+
| `--hostKeyMode` | `tofu` | `tofu \| strict \| insecure`. See [SECURITY.md](./SECURITY.md#host-key-trust-does-not-survive-a-restart) — `strict` currently refuses every host |
|
|
676
683
|
| `--insecureHostKey` | false | Disable host key verification (test only!) |
|
|
677
684
|
| `--allowUncheckedConfigAcl` | false | Windows: report every ACL finding and refuse none |
|
|
678
685
|
| `--strictConfigAcl` | false | Windows: refuse on every ACL finding, including a read-only over-grant |
|
|
@@ -698,8 +705,8 @@ replacement, rather than failing later as a confusing auth error.
|
|
|
698
705
|
| v1 | v2 | Notes |
|
|
699
706
|
|----|----|-------|
|
|
700
707
|
| `exec` | `read-command` | Allowlisted read-only commands. Prefer this for reads. |
|
|
701
|
-
| `exec` | `run-command` | Arbitrary commands. Destructive ones go through the approval gate
|
|
702
|
-
| `sudo-exec` | `privileged-command` |
|
|
708
|
+
| `exec` | `run-command` | Arbitrary commands. Destructive and privileged ones go through the approval gate, unless `approvalPolicy = "auto"`. |
|
|
709
|
+
| `sudo-exec` | `privileged-command` | Requires approval unless `approvalPolicy = "auto"`. Password is piped via stdin. |
|
|
703
710
|
| `description` parameter | — | Removed. It was an injection vector (#44) and never reached the host. |
|
|
704
711
|
|
|
705
712
|
**Command results now carry status.** In v1 a failed command rejected with
|
|
@@ -739,8 +746,12 @@ and pass `--config <path>`; profiles carry per-host roles and approval policy.
|
|
|
739
746
|
### Host key verification
|
|
740
747
|
|
|
741
748
|
v1 did not verify host keys. v2 defaults to trust-on-first-use and records the
|
|
742
|
-
key
|
|
743
|
-
|
|
749
|
+
key **in memory, for the life of the process**; a later mismatch in that same
|
|
750
|
+
process fails the connection. Nothing is written to disk and `~/.ssh/known_hosts`
|
|
751
|
+
is not consulted, so a restart accepts afresh — see
|
|
752
|
+
[SECURITY.md](./SECURITY.md#host-key-trust-does-not-survive-a-restart). Pin
|
|
753
|
+
explicitly with `trustedHostKey` in a profile, which is the only control here that
|
|
754
|
+
survives a restart, or pass `--insecureHostKey` to opt out (test environments only).
|
|
744
755
|
|
|
745
756
|
---
|
|
746
757
|
|
|
@@ -135,9 +135,45 @@ const CLASS_RANK = {
|
|
|
135
135
|
privileged: 3,
|
|
136
136
|
};
|
|
137
137
|
/** Shells that run their next argument as a command when given `-c`. */
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
138
|
+
/**
|
|
139
|
+
* Interpreters, the flags that hand them a program, and whether this file can read it.
|
|
140
|
+
*
|
|
141
|
+
* One table rather than three overlapping name lists: adding an interpreter here is the
|
|
142
|
+
* whole change, and a shell list that drifts out of step with a readability list is how a
|
|
143
|
+
* new shell ends up treated as an unreadable interpreter.
|
|
144
|
+
*
|
|
145
|
+
* `readable` means the program is shell, so classifying it is this same job done again
|
|
146
|
+
* and the class it earns is real. Python, Perl, Ruby, Node and PHP are not: their program
|
|
147
|
+
* is a different language, and `os.system('sudo id')` carries an elevation the shell
|
|
148
|
+
* classifier cannot see. Pretending to parse them would be a worse answer than admitting
|
|
149
|
+
* we cannot, so an unreadable program makes the command `destructive` — "we cannot tell",
|
|
150
|
+
* not "this is root", the same answer `hasUnnameableCommand` gives.
|
|
151
|
+
*
|
|
152
|
+
* `busybox` is not here: it takes an applet name, not `-c`, so `busybox sh -c …` is an
|
|
153
|
+
* `sh` invocation behind a wrapper. It is in EXEC_WRAPPERS instead, which is what it is.
|
|
154
|
+
*/
|
|
155
|
+
// Null-prototype for the reason `mergePolicyRules` spells out (#172): this is indexed by
|
|
156
|
+
// a command word, which is a free string, so on a plain object `INTERPRETERS['toString']`
|
|
157
|
+
// resolves to a function and reading `.flags` off it throws inside the policy gate.
|
|
158
|
+
const INTERPRETERS = Object.assign(Object.create(null), {
|
|
159
|
+
sh: { flags: ['-c'], readable: true },
|
|
160
|
+
bash: { flags: ['-c'], readable: true },
|
|
161
|
+
dash: { flags: ['-c'], readable: true },
|
|
162
|
+
zsh: { flags: ['-c'], readable: true },
|
|
163
|
+
ksh: { flags: ['-c'], readable: true },
|
|
164
|
+
ash: { flags: ['-c'], readable: true },
|
|
165
|
+
python: { flags: ['-c'], readable: false },
|
|
166
|
+
python2: { flags: ['-c'], readable: false },
|
|
167
|
+
python3: { flags: ['-c'], readable: false },
|
|
168
|
+
perl: { flags: ['-e', '-E'], readable: false },
|
|
169
|
+
ruby: { flags: ['-e'], readable: false },
|
|
170
|
+
// `-p`/`--print` evaluate exactly as `-e` does and then print the result.
|
|
171
|
+
node: { flags: ['-e', '--eval', '-p', '--print'], readable: false },
|
|
172
|
+
php: { flags: ['-r'], readable: false },
|
|
173
|
+
});
|
|
174
|
+
/** Flags whose value is a separate word, which is not the program. `awk -F ':' '{…}'`. */
|
|
175
|
+
/** `find … -exec <cmd> +` runs cmd. */
|
|
176
|
+
const FIND_EXEC_FLAGS = new Set(['-exec', '-execdir', '-ok', '-okdir']);
|
|
141
177
|
/**
|
|
142
178
|
* How deep a substitution may nest before we stop reading and refuse to guess.
|
|
143
179
|
*
|
|
@@ -147,39 +183,156 @@ const SHELL_BINARIES = new Set([
|
|
|
147
183
|
*/
|
|
148
184
|
const MAX_NESTING_DEPTH = 8;
|
|
149
185
|
/**
|
|
150
|
-
* Split
|
|
186
|
+
* Split a command into segments of words, resolving quoting as a shell would.
|
|
151
187
|
*
|
|
152
|
-
*
|
|
153
|
-
*
|
|
154
|
-
*
|
|
188
|
+
* The classifier's regexes were written against the command as sent, but the transport
|
|
189
|
+
* removes quoting before the command runs, so `rm -rf "/etc"` matched no destructive
|
|
190
|
+
* pattern and `s"u"do id` named no privilege prefix. Every consumer that used to split on
|
|
191
|
+
* `/[;&|\n]/` and `/\s+/` reads this instead, so quote removal happens once, in one
|
|
192
|
+
* place, rather than being re-derived per rule.
|
|
193
|
+
*
|
|
194
|
+
* Not a shell parser. Variable expansion, arithmetic and here-documents are out of scope —
|
|
195
|
+
* `hasUnnameableCommand` is what refuses a command word this cannot resolve.
|
|
155
196
|
*/
|
|
156
|
-
function
|
|
157
|
-
|
|
197
|
+
function tokenizeSegments(command) {
|
|
198
|
+
return tokenizeSegmentsDetailed(command).map((segment) => segment.words);
|
|
199
|
+
}
|
|
200
|
+
/**
|
|
201
|
+
* The same split, keeping the separator that introduced each segment.
|
|
202
|
+
*
|
|
203
|
+
* Only the pipe rules need it: `|` feeds one command's output into the next, while `;`
|
|
204
|
+
* and `&` merely sequence, and a rule about pipes must not fire on a rule about sequences.
|
|
205
|
+
* Deriving both views from one tokeniser is what stops the two from drifting apart.
|
|
206
|
+
*/
|
|
207
|
+
function tokenizeSegmentsDetailed(command, honorQuotes = true) {
|
|
208
|
+
const segments = [];
|
|
209
|
+
let pending = '';
|
|
210
|
+
let words = [];
|
|
158
211
|
let current = '';
|
|
159
212
|
let quote = null;
|
|
213
|
+
let escaped = false;
|
|
214
|
+
const endWord = () => {
|
|
215
|
+
if (current)
|
|
216
|
+
words.push(current);
|
|
217
|
+
current = '';
|
|
218
|
+
};
|
|
219
|
+
const endSegment = (sep) => {
|
|
220
|
+
endWord();
|
|
221
|
+
if (words.length > 0)
|
|
222
|
+
segments.push({ words, sep: pending });
|
|
223
|
+
words = [];
|
|
224
|
+
pending = sep;
|
|
225
|
+
};
|
|
160
226
|
for (const ch of command) {
|
|
227
|
+
if (escaped) {
|
|
228
|
+
// A backslash quotes the next character, so `\reboot` runs reboot. Keeping the
|
|
229
|
+
// character and dropping the backslash is what the shell does.
|
|
230
|
+
current += ch;
|
|
231
|
+
escaped = false;
|
|
232
|
+
continue;
|
|
233
|
+
}
|
|
161
234
|
if (quote) {
|
|
235
|
+
// Backslash is literal inside single quotes; inside double quotes it escapes.
|
|
236
|
+
if (ch === '\\' && quote === '"') {
|
|
237
|
+
escaped = true;
|
|
238
|
+
continue;
|
|
239
|
+
}
|
|
162
240
|
if (ch === quote)
|
|
163
241
|
quote = null;
|
|
164
242
|
else
|
|
165
243
|
current += ch;
|
|
166
244
|
continue;
|
|
167
245
|
}
|
|
168
|
-
if (ch === '
|
|
246
|
+
if (ch === '\\') {
|
|
247
|
+
escaped = true;
|
|
248
|
+
continue;
|
|
249
|
+
}
|
|
250
|
+
if (honorQuotes && (ch === '"' || ch === "'")) {
|
|
169
251
|
quote = ch;
|
|
170
252
|
continue;
|
|
171
253
|
}
|
|
254
|
+
if (ch === ';' || ch === '&' || ch === '|' || ch === '\n') {
|
|
255
|
+
endSegment(ch);
|
|
256
|
+
continue;
|
|
257
|
+
}
|
|
172
258
|
if (/\s/.test(ch)) {
|
|
173
|
-
|
|
174
|
-
words.push(current);
|
|
175
|
-
current = '';
|
|
259
|
+
endWord();
|
|
176
260
|
continue;
|
|
177
261
|
}
|
|
178
262
|
current += ch;
|
|
179
263
|
}
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
264
|
+
endSegment('');
|
|
265
|
+
// A quote left open at the end is not a command a shell would run — it is a syntax
|
|
266
|
+
// error. Reading it as one long quoted argument is the dangerous reading: `echo "hi;
|
|
267
|
+
// sudo id` then never splits on the `;` and the elevation disappears, which measured as
|
|
268
|
+
// `privileged` before this tokeniser and `safe` after. So fall back to the scan that
|
|
269
|
+
// treats the quote character as ordinary text, which splits and still sees `sudo id`.
|
|
270
|
+
if (quote !== null)
|
|
271
|
+
return tokenizeSegmentsDetailed(command, false);
|
|
272
|
+
return segments;
|
|
273
|
+
}
|
|
274
|
+
/**
|
|
275
|
+
* The command rebuilt from its tokens, for the regex rules to match against.
|
|
276
|
+
*
|
|
277
|
+
* Segments are rejoined with `; ` so a pattern cannot match across two commands that the
|
|
278
|
+
* shell would run separately.
|
|
279
|
+
*/
|
|
280
|
+
let lastNormalizedInput = null;
|
|
281
|
+
let lastNormalizedOutput = '';
|
|
282
|
+
function normalizeCommand(command) {
|
|
283
|
+
// One evaluate asks for this about nineteen times, once per regex rule, and each ask
|
|
284
|
+
// re-ran the tokeniser over the whole command. The asks arrive in a row on the same
|
|
285
|
+
// input, so a one-entry cache removes almost all of it.
|
|
286
|
+
if (command === lastNormalizedInput)
|
|
287
|
+
return lastNormalizedOutput;
|
|
288
|
+
lastNormalizedInput = command;
|
|
289
|
+
lastNormalizedOutput = normalizeUncached(command);
|
|
290
|
+
return lastNormalizedOutput;
|
|
291
|
+
}
|
|
292
|
+
function normalizeUncached(command) {
|
|
293
|
+
return tokenizeSegments(command)
|
|
294
|
+
.map((words) => words.map((w) => (QUOTED_CONTENT.test(w) ? PLACEHOLDER : w)).join(' '))
|
|
295
|
+
.join('; ');
|
|
296
|
+
}
|
|
297
|
+
/**
|
|
298
|
+
* A character the tokeniser splits on or at, which a token can only hold if it was quoted.
|
|
299
|
+
*
|
|
300
|
+
* The tokeniser splits on unquoted separators and at unquoted whitespace, so a token
|
|
301
|
+
* holding either came from inside quotes — the shell will not split there, and its contents are data. Rejoining it
|
|
302
|
+
* into the pattern text is what makes an argument read as a command: `echo "hello; rm -rf
|
|
303
|
+
* /"` prints a string, and normalising it produced `echo hello; rm -rf /`, which matched
|
|
304
|
+
* the never-allowed list. Dropping those tokens keeps quote removal doing the job it was
|
|
305
|
+
* added for — `rm -rf "/etc"`, whose tokens hold no separator — without letting a quoted
|
|
306
|
+
* argument impersonate a command. A carrier that really does hand over a quoted command
|
|
307
|
+
* (`sh -c "ls; rm -rf /etc"`) is read by `nestedCommands`, not by this.
|
|
308
|
+
*/
|
|
309
|
+
const QUOTED_CONTENT = /[\s;&|\n]/;
|
|
310
|
+
/**
|
|
311
|
+
* What a quoted token becomes in the pattern text.
|
|
312
|
+
*
|
|
313
|
+
* Deleting it instead spliced its neighbours together and created an adjacency that appears
|
|
314
|
+
* nowhere in the command: `echo rm 'a|b' -rf /` normalised to `echo rm -rf /` and landed on
|
|
315
|
+
* the never-allowed list. A character no pattern contains and `\s` does not match stands in
|
|
316
|
+
* its place, so the two sides can no longer be read as one phrase.
|
|
317
|
+
*/
|
|
318
|
+
const PLACEHOLDER = '\u0000';
|
|
319
|
+
/**
|
|
320
|
+
* Try a regex against the command as written and against the tokenised form.
|
|
321
|
+
*
|
|
322
|
+
* Normalising alone is not enough. It rebuilds the command from tokens, so the shell
|
|
323
|
+
* metacharacters between them are gone, and a pattern that matches across a separator no
|
|
324
|
+
* longer fires — the fork bomb, whose `:|:&` needs the literal `|` and `&`, is the case
|
|
325
|
+
* that shows this. Normalising is still what defeats `rm -rf "/etc"`. Neither form is a
|
|
326
|
+
* superset of the other, so both are tried: for a pattern whose whole job is to notice
|
|
327
|
+
* something, the union is the only direction that cannot lose a match.
|
|
328
|
+
*
|
|
329
|
+
* Deliberately scoped to regexes. The word-based rules in `FORBIDDEN_RULES` already
|
|
330
|
+
* tokenise, and handing them the normalised string turns a separator that was safely
|
|
331
|
+
* inside a quoted argument into a real one — `grep -E "warn|reboot" syslog` became an
|
|
332
|
+
* unconditional refusal, which is the mention-vs-invocation bug of #91 all over again.
|
|
333
|
+
*/
|
|
334
|
+
function matchesEitherForm(command, test) {
|
|
335
|
+
return test(command) || test(normalizeCommand(command));
|
|
183
336
|
}
|
|
184
337
|
/**
|
|
185
338
|
* The commands hiding inside a command.
|
|
@@ -230,23 +383,57 @@ export function nestedCommands(command) {
|
|
|
230
383
|
for (const raw of backticks)
|
|
231
384
|
found.push(raw.slice(1, -1));
|
|
232
385
|
}
|
|
233
|
-
//
|
|
234
|
-
// see it
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
386
|
+
// A carrier hands a program to something that runs it. Carries no substitution, so the
|
|
387
|
+
// scan above does not see it. Read each segment's command word rather than scanning
|
|
388
|
+
// every word: matching a mention rather than an invocation is #91, and
|
|
389
|
+
// `cat /usr/bin/python3` names an interpreter without running one.
|
|
390
|
+
const segments = tokenizeSegmentsDetailed(command);
|
|
391
|
+
for (const { words } of segments) {
|
|
392
|
+
// Every word, not just the command word. Stopping at the head lost the carrier behind
|
|
393
|
+
// any wrapper this file does not list: `xargs -I {} sh -c 'sudo id'` stops on `{}`, and
|
|
394
|
+
// `flock`, `chroot`, `nsenter` and `systemd-run` are not wrappers it knows — all six
|
|
395
|
+
// went from `privileged` to `safe`. What keeps this from matching a *mention* is not
|
|
396
|
+
// where it looks but what it requires: `programAfterFlag` returns null unless a
|
|
397
|
+
// program-bearing flag actually follows, so `cat /usr/bin/python3` carries nothing.
|
|
398
|
+
//
|
|
399
|
+
// awk is deliberately absent. Its program is a positional operand rather than the
|
|
400
|
+
// value of a flag, so nothing distinguishes running awk from naming it, and its four
|
|
401
|
+
// implementations disagree about which flags consume a value — modelling that wrongly
|
|
402
|
+
// opened five separate holes across two review rounds. `awk` keeps the class it has on
|
|
403
|
+
// 2.5.1 and is tracked separately.
|
|
404
|
+
if (!operandsAreData(words)) {
|
|
405
|
+
for (let i = 0; i < words.length; i++) {
|
|
406
|
+
const spec = INTERPRETERS[stripPath(unquote(words[i]))];
|
|
407
|
+
if (spec === undefined || isFlagValue(words, i, spec.flags))
|
|
408
|
+
continue;
|
|
409
|
+
const program = programAfterFlag(words, i, spec.flags);
|
|
410
|
+
if (program !== null)
|
|
411
|
+
found.push(program);
|
|
244
412
|
}
|
|
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;
|
|
249
413
|
}
|
|
414
|
+
// `-exec` is a flag rather than a command word, so this one is still a scan.
|
|
415
|
+
for (let i = 0; i < words.length; i++) {
|
|
416
|
+
if (!FIND_EXEC_FLAGS.has(words[i]) || words[i + 1] === undefined)
|
|
417
|
+
continue;
|
|
418
|
+
const rest = words.slice(i + 1);
|
|
419
|
+
const stop = rest.findIndex((w) => w === '+' || w === ';');
|
|
420
|
+
found.push((stop === -1 ? rest : rest.slice(0, stop)).join(' '));
|
|
421
|
+
// Step past what was consumed. Leaving `i` where it was emitted one child per
|
|
422
|
+
// `-exec` token, each an overlapping suffix of the last and each still holding the
|
|
423
|
+
// rest of them, so the recursion re-expanded the same tail once per token: twenty
|
|
424
|
+
// `-ok` tokens in 81 bytes cost 17 seconds of a single-threaded event loop.
|
|
425
|
+
i = stop === -1 ? words.length : i + 1 + stop;
|
|
426
|
+
}
|
|
427
|
+
}
|
|
428
|
+
// A pipe stage whose command word is an interpreter with no program of its own runs
|
|
429
|
+
// whatever the previous stage printed. Starts at 1: a leading `|` records its separator
|
|
430
|
+
// on the first segment, and reading `segments[-1]` threw.
|
|
431
|
+
for (let i = 1; i < segments.length; i++) {
|
|
432
|
+
if (segments[i].sep !== '|')
|
|
433
|
+
continue;
|
|
434
|
+
if (!readsProgramFromStdin(segments[i].words))
|
|
435
|
+
continue;
|
|
436
|
+
found.push(segments[i - 1].words.join(' '));
|
|
250
437
|
}
|
|
251
438
|
return found.filter((c) => c.trim().length > 0);
|
|
252
439
|
}
|
|
@@ -255,6 +442,8 @@ const PRIVILEGE_PREFIXES = new Set([
|
|
|
255
442
|
// BusyBox/Alpine, Docker entrypoints, the Rust sudo now default on some
|
|
256
443
|
// distributions, systemd's replacement, and the Solaris/illumos spelling.
|
|
257
444
|
'su-exec', 'gosu', 'sudo-rs', 'run0', 'pfexec',
|
|
445
|
+
// Both reach root with the command otherwise classified `safe`.
|
|
446
|
+
'runuser', 'setpriv',
|
|
258
447
|
]);
|
|
259
448
|
/**
|
|
260
449
|
* Binaries whose job is to run another command.
|
|
@@ -273,6 +462,8 @@ const PRIVILEGE_PREFIXES = new Set([
|
|
|
273
462
|
const EXEC_WRAPPERS = new Set([
|
|
274
463
|
'env', 'nohup', 'nice', 'ionice', 'command', 'exec', 'setsid', 'stdbuf',
|
|
275
464
|
'timeout', 'chrt', 'taskset', 'xargs', 'watch',
|
|
465
|
+
// `busybox <applet>` runs the applet, so it wraps rather than interprets.
|
|
466
|
+
'busybox',
|
|
276
467
|
]);
|
|
277
468
|
/**
|
|
278
469
|
* Arguments that turn an allowlisted binary into one that writes or executes.
|
|
@@ -283,9 +474,8 @@ const EXEC_WRAPPERS = new Set([
|
|
|
283
474
|
* \;` form only escaped because `;` happens to be a shell metacharacter, while
|
|
284
475
|
* the `+` terminator carries none.
|
|
285
476
|
*/
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
};
|
|
477
|
+
// Null-prototype for the same reason as INTERPRETERS: indexed by the command word.
|
|
478
|
+
const DISQUALIFYING_ARGS = Object.assign(Object.create(null), { find: /^-(exec|execdir|ok|okdir|delete|fprintf?|fls)$/ });
|
|
289
479
|
/** A leading `NAME=value`, which a shell treats as an assignment, not a command. */
|
|
290
480
|
const ASSIGNMENT = /^[A-Za-z_][A-Za-z0-9_]*=/;
|
|
291
481
|
/** `timeout 5s`, `nice 10` — a bare argument some wrappers take before the command. */
|
|
@@ -317,8 +507,16 @@ function stripPath(word) {
|
|
|
317
507
|
}
|
|
318
508
|
function parseSegments(command) {
|
|
319
509
|
const segments = [];
|
|
320
|
-
for (const
|
|
321
|
-
const
|
|
510
|
+
for (const words of tokenizeSegments(command)) {
|
|
511
|
+
const segment = parseWords(words);
|
|
512
|
+
if (segment !== null)
|
|
513
|
+
segments.push(segment);
|
|
514
|
+
}
|
|
515
|
+
return segments;
|
|
516
|
+
}
|
|
517
|
+
/** The same, for callers that already hold the tokenised words. */
|
|
518
|
+
function parseWords(words) {
|
|
519
|
+
{
|
|
322
520
|
let i = 0;
|
|
323
521
|
while (i < words.length && PRIVILEGE_PREFIXES.has(stripPath(words[i]))) {
|
|
324
522
|
i++;
|
|
@@ -330,10 +528,10 @@ function parseSegments(command) {
|
|
|
330
528
|
}
|
|
331
529
|
}
|
|
332
530
|
if (i < words.length) {
|
|
333
|
-
|
|
531
|
+
return { head: stripPath(words[i]), args: words.slice(i + 1) };
|
|
334
532
|
}
|
|
335
533
|
}
|
|
336
|
-
return
|
|
534
|
+
return null;
|
|
337
535
|
}
|
|
338
536
|
/**
|
|
339
537
|
* Whether a segment asks for elevation, reading past anything that is not yet
|
|
@@ -395,8 +593,8 @@ function elevatedBinary(words) {
|
|
|
395
593
|
* privileged decision — in the audit log, the OTel span and OPA's input (#134).
|
|
396
594
|
*/
|
|
397
595
|
function elevatedBinaryOf(command) {
|
|
398
|
-
for (const
|
|
399
|
-
const found = elevatedBinary(
|
|
596
|
+
for (const words of tokenizeSegments(command)) {
|
|
597
|
+
const found = elevatedBinary(words);
|
|
400
598
|
if (found !== null)
|
|
401
599
|
return found;
|
|
402
600
|
}
|
|
@@ -441,7 +639,13 @@ const DOWNLOADERS = new Set(['curl', 'wget']);
|
|
|
441
639
|
* running one when it succeeds.
|
|
442
640
|
*/
|
|
443
641
|
function pipesDownloadIntoShell(command) {
|
|
444
|
-
|
|
642
|
+
// One head per pipe stage. Splitting on every separator would make `curl -O x;
|
|
643
|
+
// bash build.sh` — download, then run a local script — match a rule whose label says
|
|
644
|
+
// "piping a download into a shell", on a list that cannot be switched off.
|
|
645
|
+
const segments = tokenizeSegmentsDetailed(command);
|
|
646
|
+
const heads = segments
|
|
647
|
+
.filter((segment, i) => i === 0 || segment.sep === '|')
|
|
648
|
+
.map((segment) => parseWords(segment.words)?.head);
|
|
445
649
|
const firstDownload = heads.findIndex((h) => h !== undefined && DOWNLOADERS.has(h));
|
|
446
650
|
if (firstDownload === -1)
|
|
447
651
|
return false;
|
|
@@ -466,7 +670,10 @@ function chownsRoot(command) {
|
|
|
466
670
|
});
|
|
467
671
|
}
|
|
468
672
|
const FORBIDDEN_RULES = [
|
|
469
|
-
...FORBIDDEN_PATTERNS.map((re) => ({
|
|
673
|
+
...FORBIDDEN_PATTERNS.map((re) => ({
|
|
674
|
+
label: String(re),
|
|
675
|
+
test: (c) => matchesEitherForm(c, (form) => re.test(form)),
|
|
676
|
+
})),
|
|
470
677
|
{
|
|
471
678
|
label: 'invoking a power-state command (shutdown, reboot, halt, poweroff) or eval',
|
|
472
679
|
test: (command) => invokedWords(command).some((w) => FORBIDDEN_INVOCATIONS.has(w)),
|
|
@@ -524,7 +731,8 @@ const DESTRUCTIVE_PATTERNS = [
|
|
|
524
731
|
* either.
|
|
525
732
|
*/
|
|
526
733
|
function isDestructive(command) {
|
|
527
|
-
return isForbidden(command) ||
|
|
734
|
+
return (isForbidden(command) ||
|
|
735
|
+
matchesEitherForm(command, (form) => DESTRUCTIVE_PATTERNS.some((re) => re.test(form))));
|
|
528
736
|
}
|
|
529
737
|
const LEADING_PRIVILEGE_PREFIXES = [
|
|
530
738
|
/^\s*sudo\b/,
|
|
@@ -533,7 +741,12 @@ const LEADING_PRIVILEGE_PREFIXES = [
|
|
|
533
741
|
/^\s*pkexec\b/,
|
|
534
742
|
];
|
|
535
743
|
export function extractBinary(command) {
|
|
536
|
-
|
|
744
|
+
// The first segment's words, so quoting is resolved — `s"u"do id` used to report
|
|
745
|
+
// `s"u"do` in the audit record and the refusal message. Reading the normalised whole
|
|
746
|
+
// command instead would be wrong in the other direction: it rejoins segments with
|
|
747
|
+
// `; `, so `ls | grep x` would report `ls;`, and this name reaches the audit record,
|
|
748
|
+
// the OTel span and OPA's input (#134). A separator is not a binary.
|
|
749
|
+
let cmd = (tokenizeSegments(command)[0] ?? []).join(' ').trim();
|
|
537
750
|
for (const prefix of LEADING_PRIVILEGE_PREFIXES) {
|
|
538
751
|
cmd = cmd.replace(prefix, '').trim();
|
|
539
752
|
}
|
|
@@ -551,6 +764,16 @@ export function extractBinary(command) {
|
|
|
551
764
|
* whether that name is knowable at all.
|
|
552
765
|
*/
|
|
553
766
|
function effectiveCommandWord(words) {
|
|
767
|
+
const i = effectiveCommandIndex(words);
|
|
768
|
+
return i === -1 ? null : words[i];
|
|
769
|
+
}
|
|
770
|
+
/**
|
|
771
|
+
* The position of that word, for callers that need to read its arguments.
|
|
772
|
+
*
|
|
773
|
+
* Deriving the word from the index rather than searching for it afterwards is what keeps
|
|
774
|
+
* `sh x sh -c 'sudo id'` from finding the wrong `sh`.
|
|
775
|
+
*/
|
|
776
|
+
function effectiveCommandIndex(words) {
|
|
554
777
|
let i = 0;
|
|
555
778
|
while (i < words.length) {
|
|
556
779
|
const raw = words[i];
|
|
@@ -569,10 +792,167 @@ function effectiveCommandWord(words) {
|
|
|
569
792
|
i++;
|
|
570
793
|
continue;
|
|
571
794
|
}
|
|
572
|
-
return
|
|
795
|
+
return i;
|
|
796
|
+
}
|
|
797
|
+
return -1;
|
|
798
|
+
}
|
|
799
|
+
/**
|
|
800
|
+
* Whether this segment's command word is one whose operands are data.
|
|
801
|
+
*
|
|
802
|
+
* The carrier scan reads every word, because a wrapper this file does not list would
|
|
803
|
+
* otherwise hide `sh -c`. The cost is that an outer tool's own flag can be mistaken for an
|
|
804
|
+
* interpreter's: `grep python3 -c /var/log/x` counts lines, and `-c` is also python's
|
|
805
|
+
* program flag. An allowlisted command is one this file already vouches for as reading
|
|
806
|
+
* rather than running, so its arguments are subjects, not commands — the positive form of
|
|
807
|
+
* the mention-vs-invocation rule at #91. `find` keeps its `-exec` scan, which is separate.
|
|
808
|
+
*/
|
|
809
|
+
function operandsAreData(words) {
|
|
810
|
+
const idx = effectiveCommandIndex(words);
|
|
811
|
+
return idx !== -1 && READ_ONLY_ALLOWLIST.has(stripPath(unquote(words[idx])));
|
|
812
|
+
}
|
|
813
|
+
/**
|
|
814
|
+
* Whether an interpreter name is the value of the flag before it rather than a command.
|
|
815
|
+
*
|
|
816
|
+
* The scan looks at every word so that a carrier behind an unlisted wrapper is not lost,
|
|
817
|
+
* and requires a program-bearing flag to follow before it believes one. That is not quite
|
|
818
|
+
* enough: `grep -e perl -e python` puts `perl` after a flag and a second `-e` after it,
|
|
819
|
+
* which reads as perl being handed a program. A search term is not a command.
|
|
820
|
+
*
|
|
821
|
+
* The test is deliberately narrow — the preceding flag must be one of *this interpreter's*
|
|
822
|
+
* program flags. Any flag at all was too much: `nsenter -t 1 -m sh -c 'sudo id'` puts the
|
|
823
|
+
* shell after `-m`, which takes no value, and dropping it lost a real elevation. Nothing
|
|
824
|
+
* here can know an arbitrary tool's grammar, so `sed -n perl -e p` — a shape no one
|
|
825
|
+
* writes — is still read as a carrier.
|
|
826
|
+
*/
|
|
827
|
+
function isFlagValue(words, i, flags) {
|
|
828
|
+
if (i === 0)
|
|
829
|
+
return false;
|
|
830
|
+
const previous = words[i - 1];
|
|
831
|
+
return previous.length > 1 && previous.startsWith('-') && flags.includes(previous);
|
|
832
|
+
}
|
|
833
|
+
/**
|
|
834
|
+
* The program an interpreter was handed on its command line, or null.
|
|
835
|
+
*
|
|
836
|
+
* Only flags may sit between the interpreter and its flag; anything else means this was
|
|
837
|
+
* not that kind of invocation, which is what keeps `python3 script.py` — a program this
|
|
838
|
+
* cannot read either, but one every deployment runs — out of the gate.
|
|
839
|
+
*/
|
|
840
|
+
function programAfterFlag(words, from, flags) {
|
|
841
|
+
for (let j = from + 1; j < words.length; j++) {
|
|
842
|
+
const word = words[j];
|
|
843
|
+
if (flags.includes(word))
|
|
844
|
+
return words[j + 1] ?? null;
|
|
845
|
+
const attached = flags.find((f) => word.startsWith(f) && word.length > f.length);
|
|
846
|
+
if (attached !== undefined) {
|
|
847
|
+
const rest = word.slice(attached.length);
|
|
848
|
+
// `sh -c'sudo id'` tokenises to `-csudo id`, so the program is attached. `bash -cx`
|
|
849
|
+
// is a flag cluster and the program is the next word. A space, or the `=` of
|
|
850
|
+
// `--eval=…`, is what tells them apart: a cluster is letters only.
|
|
851
|
+
if (/\s/.test(rest) || rest.startsWith('='))
|
|
852
|
+
return rest.replace(/^=/, '');
|
|
853
|
+
return words[j + 1] ?? null;
|
|
854
|
+
}
|
|
855
|
+
// The program flag need not lead the cluster: `bash -xc 'sudo id'` runs exactly what
|
|
856
|
+
// `bash -cx 'sudo id'` runs, and a prefix test saw the second and missed the first.
|
|
857
|
+
if (clusterCarriesFlag(word, flags))
|
|
858
|
+
return words[j + 1] ?? null;
|
|
859
|
+
if (!word.startsWith('-'))
|
|
860
|
+
return null;
|
|
573
861
|
}
|
|
574
862
|
return null;
|
|
575
863
|
}
|
|
864
|
+
/**
|
|
865
|
+
* What follows a short flag inside a single-dash cluster, or null if it holds none.
|
|
866
|
+
*
|
|
867
|
+
* Clusters only — a run of single letters after one dash. Long flags and attached values
|
|
868
|
+
* are handled before this is reached.
|
|
869
|
+
*/
|
|
870
|
+
function clusterCarriesFlag(word, flags) {
|
|
871
|
+
// Three letters at most. `/^-[A-Za-z]+$/` alone also matches every single-dash long
|
|
872
|
+
// option, and `find`'s predicates are full of them: `-type` contains perl's `-e`, so
|
|
873
|
+
// `find . -name perl -type f` read as a carrier and asked for approval.
|
|
874
|
+
if (!/^-[A-Za-z]{2,3}$/.test(word))
|
|
875
|
+
return false;
|
|
876
|
+
const body = word.slice(1);
|
|
877
|
+
// A cluster is letters only, so whatever follows the program flag is more flags — the
|
|
878
|
+
// program itself is always the next word.
|
|
879
|
+
return flags.some((flag) => flag.length === 2 && body.includes(flag[1]));
|
|
880
|
+
}
|
|
881
|
+
/** Spellings of "the program is on standard input" that look like a file operand. */
|
|
882
|
+
const STDIN_PATHS = new Set(['-', '/dev/stdin', '/dev/fd/0', '/proc/self/fd/0']);
|
|
883
|
+
/**
|
|
884
|
+
* Whether a segment's command word is an interpreter with no program of its own.
|
|
885
|
+
*
|
|
886
|
+
* `echo "sudo id" | bash` hands the program over on stdin, where there is nothing to
|
|
887
|
+
* read. This holds for the shells too — `readable` is about a `-c` argument, and there is
|
|
888
|
+
* no `-c` here. Requiring that no operand follow is what keeps `cat data | python3
|
|
889
|
+
* app.py` out of it: there the pipe carries data, not a program.
|
|
890
|
+
*/
|
|
891
|
+
function readsProgramFromStdin(words) {
|
|
892
|
+
const idx = effectiveCommandIndex(words);
|
|
893
|
+
if (idx === -1)
|
|
894
|
+
return false;
|
|
895
|
+
const bin = stripPath(words[idx]);
|
|
896
|
+
// Interpreters only. awk's one program-from-stdin form is `awk -f -`, already gated as a
|
|
897
|
+
// file flag; every other piped awk reads data, not a program.
|
|
898
|
+
const spec = INTERPRETERS[bin];
|
|
899
|
+
if (spec === undefined)
|
|
900
|
+
return false;
|
|
901
|
+
const flags = spec.flags;
|
|
902
|
+
let sawStdinFlag = false;
|
|
903
|
+
for (let j = idx + 1; j < words.length; j++) {
|
|
904
|
+
const word = words[j];
|
|
905
|
+
if (flags.some((f) => word === f || word.startsWith(f)))
|
|
906
|
+
return false;
|
|
907
|
+
// `--` ends the interpreter's own arguments; what follows is `$1..$n`, so a program
|
|
908
|
+
// still has to come from somewhere and `bash -s -- arg` reads stdin.
|
|
909
|
+
// `-s` says outright that the script comes from stdin, so everything after it is
|
|
910
|
+
// `$1..$n` whatever it looks like.
|
|
911
|
+
if (word === '-s') {
|
|
912
|
+
sawStdinFlag = true;
|
|
913
|
+
continue;
|
|
914
|
+
}
|
|
915
|
+
// `--` ends the interpreter's own arguments. Without `-s` the next word is the script
|
|
916
|
+
// itself, so `bash -- process.sh` reads a file while `bash -s -- arg` reads stdin.
|
|
917
|
+
if (word === '--') {
|
|
918
|
+
const next = words[j + 1];
|
|
919
|
+
return sawStdinFlag || next === undefined || STDIN_PATHS.has(next);
|
|
920
|
+
}
|
|
921
|
+
if (STDIN_PATHS.has(word))
|
|
922
|
+
return true;
|
|
923
|
+
if (!word.startsWith('-'))
|
|
924
|
+
return false;
|
|
925
|
+
}
|
|
926
|
+
return true;
|
|
927
|
+
}
|
|
928
|
+
/**
|
|
929
|
+
* Whether any segment hands a program to something this file cannot read.
|
|
930
|
+
*
|
|
931
|
+
* Keyed on the segment's command word, never on any word that happens to name an
|
|
932
|
+
* interpreter: matching a mention rather than an invocation is the defect this file
|
|
933
|
+
* records fixing as #91, and `cat /usr/bin/python3` names an interpreter without running
|
|
934
|
+
* one. A program-bearing flag must actually be present, so naming one is not enough.
|
|
935
|
+
*/
|
|
936
|
+
function hasUnreadableProgram(command) {
|
|
937
|
+
const segments = tokenizeSegmentsDetailed(command);
|
|
938
|
+
for (let i = 0; i < segments.length; i++) {
|
|
939
|
+
const { words, sep } = segments[i];
|
|
940
|
+
if (sep === '|' && readsProgramFromStdin(words))
|
|
941
|
+
return true;
|
|
942
|
+
if (!operandsAreData(words)) {
|
|
943
|
+
for (let j = 0; j < words.length; j++) {
|
|
944
|
+
const spec = INTERPRETERS[stripPath(unquote(words[j]))];
|
|
945
|
+
if (spec === undefined || spec.readable)
|
|
946
|
+
continue;
|
|
947
|
+
if (isFlagValue(words, j, spec.flags))
|
|
948
|
+
continue;
|
|
949
|
+
if (programAfterFlag(words, j, spec.flags) !== null)
|
|
950
|
+
return true;
|
|
951
|
+
}
|
|
952
|
+
}
|
|
953
|
+
}
|
|
954
|
+
return false;
|
|
955
|
+
}
|
|
576
956
|
/**
|
|
577
957
|
* Whether any segment runs a command this process cannot name.
|
|
578
958
|
*
|
|
@@ -591,49 +971,79 @@ function effectiveCommandWord(words) {
|
|
|
591
971
|
* promoting that would put a prompt on most ordinary shell usage.
|
|
592
972
|
*/
|
|
593
973
|
function hasUnnameableCommand(command) {
|
|
594
|
-
for (const
|
|
595
|
-
const words = raw.trim().split(/\s+/).filter(Boolean);
|
|
974
|
+
for (const words of tokenizeSegments(command)) {
|
|
596
975
|
const head = effectiveCommandWord(words);
|
|
597
976
|
if (head !== null && /[$`]/.test(head))
|
|
598
977
|
return true;
|
|
599
978
|
}
|
|
600
979
|
return false;
|
|
601
980
|
}
|
|
981
|
+
/**
|
|
982
|
+
* Commands this server synthesises rather than a user typing them.
|
|
983
|
+
*
|
|
984
|
+
* These reach the classifier as text like `sftp:upload /etc/passwd`, and no rule named
|
|
985
|
+
* them, so every one fell through to the default `safe` — a class the default rules grant
|
|
986
|
+
* to `operator` on `prod`. Writing an arbitrary file to the target is not `safe`: it is
|
|
987
|
+
* the same authority as `rm`, spelled through a different tool, and it reaches
|
|
988
|
+
* `~/.ssh/authorized_keys` without touching a shell. Opening a session is the same
|
|
989
|
+
* argument, since it hands over an interactive shell.
|
|
990
|
+
*
|
|
991
|
+
* `sftp:download` and `session:close` are deliberately absent. Both would move *down*
|
|
992
|
+
* from `safe` — download to `read-only`, matching its `readOnlyHint`, and close being a
|
|
993
|
+
* release rather than an acquisition. Lowering a class is a widening, and a security
|
|
994
|
+
* release is the wrong place for one; they keep the class they have today.
|
|
995
|
+
*/
|
|
996
|
+
const SYNTHETIC_CLASSES = Object.assign(Object.create(null), {
|
|
997
|
+
'sftp:upload': 'destructive',
|
|
998
|
+
'session:open': 'destructive',
|
|
999
|
+
});
|
|
1000
|
+
/** The first word, tokenised — the synthetic verb when there is one. */
|
|
1001
|
+
function syntheticVerb(command) {
|
|
1002
|
+
return tokenizeSegments(command)[0]?.[0] ?? '';
|
|
1003
|
+
}
|
|
1004
|
+
/**
|
|
1005
|
+
* The class of a command, and of everything it carries.
|
|
1006
|
+
*
|
|
1007
|
+
* A command that carries another decides nothing on its own: the remote shell expands
|
|
1008
|
+
* `$(...)`, backticks, `<(...)` and `sh -c` and runs what is inside, so the class is the
|
|
1009
|
+
* higher of the two. The scan for carriers already existed, but it *replaced* the outer
|
|
1010
|
+
* class instead of raising it, and only when the inner class was above `safe` — so
|
|
1011
|
+
* `sudo sh -c 'rm -rf /etc'` reported the inner `destructive` and lost the outer
|
|
1012
|
+
* `privileged`, which on `prod` is the difference between a prompt and a refusal.
|
|
1013
|
+
* Taking the maximum is what makes the scan unable to lower anything.
|
|
1014
|
+
*/
|
|
602
1015
|
export function classifyCommand(command, depth = 0) {
|
|
603
1016
|
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 {
|
|
1017
|
+
const outer = classifyOuter(trimmed);
|
|
1018
|
+
if (depth >= MAX_NESTING_DEPTH) {
|
|
632
1019
|
// 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' };
|
|
1020
|
+
// reading. Refusing to guess is the only answer consistent with the rest of this
|
|
1021
|
+
// file.
|
|
1022
|
+
return { binary: outer.binary, fullCommand: trimmed, class: 'privileged' };
|
|
1023
|
+
}
|
|
1024
|
+
let highest = outer;
|
|
1025
|
+
// A floor, not a verdict. Returning the synthetic class outright would put it above
|
|
1026
|
+
// the elevation and never-allowed checks, so `sftp:upload /tmp/x; sudo id` would
|
|
1027
|
+
// record `destructive` where the command is `privileged`.
|
|
1028
|
+
const verb = syntheticVerb(trimmed);
|
|
1029
|
+
const floor = SYNTHETIC_CLASSES[verb];
|
|
1030
|
+
if (floor !== undefined && CLASS_RANK[floor] > CLASS_RANK[highest.class]) {
|
|
1031
|
+
highest = { binary: verb, fullCommand: trimmed, class: floor };
|
|
1032
|
+
}
|
|
1033
|
+
for (const inner of nestedCommands(trimmed)) {
|
|
1034
|
+
const parsed = classifyCommand(inner, depth + 1);
|
|
1035
|
+
// `binary` follows the winning side deliberately: it is what the audit record and
|
|
1036
|
+
// the refusal message name, and naming the outer `echo` would describe the wrong
|
|
1037
|
+
// process as the one that ran as root.
|
|
1038
|
+
if (CLASS_RANK[parsed.class] > CLASS_RANK[highest.class])
|
|
1039
|
+
highest = parsed;
|
|
636
1040
|
}
|
|
1041
|
+
return { binary: highest.binary, fullCommand: trimmed, class: highest.class };
|
|
1042
|
+
}
|
|
1043
|
+
/** The class of the command itself, reading none of what it carries. */
|
|
1044
|
+
function classifyOuter(trimmed) {
|
|
1045
|
+
const binary = extractBinary(trimmed);
|
|
1046
|
+
const fullCommand = trimmed;
|
|
637
1047
|
// `binary` names the subject of the class. For everything below it is the
|
|
638
1048
|
// leading command; here it is the one that runs as root, which are the same
|
|
639
1049
|
// thing only when the prefix leads.
|
|
@@ -641,7 +1051,7 @@ export function classifyCommand(command, depth = 0) {
|
|
|
641
1051
|
if (elevated !== null) {
|
|
642
1052
|
return { binary: elevated, fullCommand, class: 'privileged' };
|
|
643
1053
|
}
|
|
644
|
-
if (isDestructive(trimmed) || hasDisqualifyingArgs(trimmed)) {
|
|
1054
|
+
if (hasUnreadableProgram(trimmed) || isDestructive(trimmed) || hasDisqualifyingArgs(trimmed)) {
|
|
645
1055
|
return { binary, fullCommand, class: 'destructive' };
|
|
646
1056
|
}
|
|
647
1057
|
// Below this point every branch assumes the command word names something we
|
|
@@ -655,7 +1065,7 @@ export function classifyCommand(command, depth = 0) {
|
|
|
655
1065
|
if (hasUnnameableCommand(trimmed)) {
|
|
656
1066
|
return { binary, fullCommand, class: 'destructive' };
|
|
657
1067
|
}
|
|
658
|
-
const twoWordPrefix = fullCommand
|
|
1068
|
+
const twoWordPrefix = (tokenizeSegments(fullCommand)[0] ?? []).slice(0, 2).join(' ');
|
|
659
1069
|
if (READ_ONLY_ALLOWLIST.has(binary) || READ_ONLY_ALLOWLIST.has(twoWordPrefix)) {
|
|
660
1070
|
if (SHELL_CONTROL_CHARS.test(trimmed)) {
|
|
661
1071
|
return { binary, fullCommand, class: 'safe' };
|
package/build/policy/engine.js
CHANGED
|
@@ -3,7 +3,11 @@ import { OperatorError } from '../errors.js';
|
|
|
3
3
|
/** Tiers, most restrictive first. Unknown/unset tiers resolve to the first. */
|
|
4
4
|
export const HOST_GROUPS = ['prod', 'staging', 'dev'];
|
|
5
5
|
export const DEFAULT_RULES = {
|
|
6
|
-
|
|
6
|
+
// Null-prototype for the reason spelled out at `mergePolicyRules`: a role name is
|
|
7
|
+
// a free string, and this object is indexed by it. `mergePolicyRules` returns this
|
|
8
|
+
// one unchanged when there is no override, so fixing only the merged copy would
|
|
9
|
+
// leave the commonest path — no `[policy]` section at all — on a plain object.
|
|
10
|
+
roleBindings: Object.assign(Object.create(null), {
|
|
7
11
|
viewer: {
|
|
8
12
|
prod: ['read-only'],
|
|
9
13
|
staging: ['read-only'],
|
|
@@ -19,7 +23,7 @@ export const DEFAULT_RULES = {
|
|
|
19
23
|
staging: ['read-only', 'safe', 'destructive', 'privileged'],
|
|
20
24
|
dev: ['read-only', 'safe', 'destructive', 'privileged'],
|
|
21
25
|
},
|
|
22
|
-
},
|
|
26
|
+
}),
|
|
23
27
|
};
|
|
24
28
|
/**
|
|
25
29
|
* Layer an operator's `[policy]` section over the compiled-in defaults.
|
|
@@ -46,7 +50,25 @@ export function mergePolicyRules(base, override) {
|
|
|
46
50
|
return base;
|
|
47
51
|
// Copy every tier map: assigning base's objects straight through would let a
|
|
48
52
|
// later merge mutate DEFAULT_RULES, which is a module-level singleton.
|
|
49
|
-
|
|
53
|
+
//
|
|
54
|
+
// `Object.create(null)`, not `{}`, because the loop below assigns a key the
|
|
55
|
+
// operator chose: `roleBindings[role] = ...` with `role === '__proto__'` invokes
|
|
56
|
+
// the prototype setter on a plain object rather than creating a key. Measured, the
|
|
57
|
+
// effect is not global — `Object.prototype` is untouched — but this object's own
|
|
58
|
+
// prototype is replaced by the operator's tier map, after which `roleBindings.prod`
|
|
59
|
+
// resolves through the chain as though `prod` were a role. Reported as
|
|
60
|
+
// https://github.com/tufantunc/ssh-mcp/issues/172.
|
|
61
|
+
//
|
|
62
|
+
// Reaching it needs the schema guard in config/schema.ts to be bypassed first, and
|
|
63
|
+
// that guard is exactly what zod 4 silently disabled once already (2.5.0 rebuilt
|
|
64
|
+
// it). Insurance in the authorization engine is worth one function call.
|
|
65
|
+
//
|
|
66
|
+
// It also removes a second case with no config involved: on a plain object,
|
|
67
|
+
// `roleBindings['toString']` returns a function, so a profile whose role is named
|
|
68
|
+
// after an `Object.prototype` member found a truthy "binding". That failed closed —
|
|
69
|
+
// `findPolicyProblems` still refused it and evaluation floored at read-only — but it
|
|
70
|
+
// took a different branch than an unknown role, for no reason a reader could see.
|
|
71
|
+
const roleBindings = Object.create(null);
|
|
50
72
|
for (const [role, groups] of Object.entries(base.roleBindings)) {
|
|
51
73
|
roleBindings[role] = { ...groups };
|
|
52
74
|
}
|
|
@@ -6,8 +6,8 @@ export const TOOL_DESCRIPTIONS = {
|
|
|
6
6
|
'close-session': 'Close a named session. A background session\'s command is signalled on the host (INT, then TERM, then KILL) before its channel is dropped; an interactive session\'s shell is ended. The response says so if the command could not be signalled or had not stopped in time.',
|
|
7
7
|
'read-session-output': 'Read recent output from a background session (e.g., tail -f logs).',
|
|
8
8
|
'read-command': 'Execute a READ-ONLY command from an allowlist (ls, cat, grep, find, stat, df, etc.). This tool does NOT modify the system. Prefer this tool for all read operations.',
|
|
9
|
-
'run-command': 'Execute an arbitrary shell command on the remote server. May modify the system.
|
|
10
|
-
'privileged-command': 'Execute a command with sudo elevation.
|
|
9
|
+
'run-command': 'Execute an arbitrary shell command on the remote server. May modify the system. Commands classified destructive or privileged go through the approval gate; approvalPolicy on the profile decides whether that is a prompt, an automatic allow, or a refusal.',
|
|
10
|
+
'privileged-command': 'Execute a command with sudo elevation. Goes through the approval gate; approvalPolicy on the profile decides whether that is a prompt, an automatic allow, or a refusal. The sudo password is piped via stdin (never visible in process list).',
|
|
11
11
|
'sftp-upload': 'Upload a file to the remote server via SFTP (secure file transfer, not shell-based).',
|
|
12
12
|
'sftp-download': 'Download a file from the remote server via SFTP.',
|
|
13
13
|
'signal-process': 'Send a signal (INT, TERM, KILL) to a remote process by PID.',
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "ssh-mcp",
|
|
3
3
|
"license": "MIT",
|
|
4
|
-
"version": "2.
|
|
4
|
+
"version": "2.6.0",
|
|
5
5
|
"description": "MCP server exposing policy-gated, audited SSH access for Linux and Windows hosts via the Model Context Protocol.",
|
|
6
6
|
"mcpName": "io.github.tufantunc/ssh-mcp",
|
|
7
7
|
"type": "module",
|
|
@@ -29,9 +29,9 @@
|
|
|
29
29
|
"dependencies": {
|
|
30
30
|
"@modelcontextprotocol/sdk": "^1.30.0",
|
|
31
31
|
"@opentelemetry/api": "^1.9.1",
|
|
32
|
-
"@opentelemetry/exporter-trace-otlp-http": "^0.
|
|
32
|
+
"@opentelemetry/exporter-trace-otlp-http": "^0.221.0",
|
|
33
33
|
"@opentelemetry/resources": "^2.9.0",
|
|
34
|
-
"@opentelemetry/sdk-node": "^0.
|
|
34
|
+
"@opentelemetry/sdk-node": "^0.221.0",
|
|
35
35
|
"@opentelemetry/semantic-conventions": "^1.43.0",
|
|
36
36
|
"smol-toml": "^1.7.0",
|
|
37
37
|
"ssh2": "^1.17.0",
|