ssh-mcp 2.4.0 → 2.4.2
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 +46 -0
- package/build/policy/classifier.js +242 -2
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
[](https://www.npmjs.com/package/ssh-mcp)
|
|
4
4
|
[](https://www.npmjs.com/package/ssh-mcp)
|
|
5
5
|
[](https://github.com/tufantunc/ssh-mcp/actions/workflows/ci.yml)
|
|
6
|
+
[](https://scorecard.dev/viewer/?uri=github.com/tufantunc/ssh-mcp)
|
|
6
7
|
[](https://codecov.io/gh/tufantunc/ssh-mcp)
|
|
7
8
|
[](./LICENSE)
|
|
8
9
|
[](https://github.com/tufantunc/ssh-mcp/issues)
|
|
@@ -523,6 +524,45 @@ deny if {
|
|
|
523
524
|
|
|
524
525
|
See [SECURITY.md](./SECURITY.md) for the full threat model, vulnerability reporting policy, and deployment checklist.
|
|
525
526
|
|
|
527
|
+
### Supply chain
|
|
528
|
+
|
|
529
|
+
Releases carry signed attestations, published through [Sigstore](https://www.sigstore.dev/)
|
|
530
|
+
and recorded in its public transparency log. They live in **two different stores**, which is
|
|
531
|
+
what decides how each is verified:
|
|
532
|
+
|
|
533
|
+
| Attestation | Predicate | Stored by | Since |
|
|
534
|
+
|---|---|---|---|
|
|
535
|
+
| Build provenance — [SLSA](https://slsa.dev/) Build **Level 2** | `slsa.dev/provenance/v1` | npm | every release |
|
|
536
|
+
| SBOM — CycloneDX and SPDX | `cyclonedx.org/bom`, `spdx.dev/Document` | GitHub | releases after v2.4.0 |
|
|
537
|
+
|
|
538
|
+
Provenance comes from npm [trusted publishing](https://docs.npmjs.com/trusted-publishers):
|
|
539
|
+
the release workflow authenticates with a short-lived OIDC token and no stored credential,
|
|
540
|
+
so there is no long-lived npm token to leak.
|
|
541
|
+
|
|
542
|
+
```bash
|
|
543
|
+
npm audit signatures # provenance, against an installed tree
|
|
544
|
+
|
|
545
|
+
npm pack ssh-mcp # the SBOM attestation is bound to the tarball, so fetch it
|
|
546
|
+
gh attestation verify ssh-mcp-*.tgz --repo tufantunc/ssh-mcp --predicate-type https://cyclonedx.org/bom
|
|
547
|
+
```
|
|
548
|
+
|
|
549
|
+
Both flags on the last command are load-bearing. `gh attestation verify` defaults to the
|
|
550
|
+
SLSA predicate, so without `--predicate-type` it filters the SBOM out and reports nothing
|
|
551
|
+
found — and the provenance it would look for instead is in npm's store, not the GitHub
|
|
552
|
+
store `--repo` queries. Use `https://spdx.dev/Document` for the SPDX one.
|
|
553
|
+
|
|
554
|
+
Both SBOMs are also attached to each
|
|
555
|
+
[GitHub release](https://github.com/tufantunc/ssh-mcp/releases), for reading rather than
|
|
556
|
+
verifying.
|
|
557
|
+
|
|
558
|
+
**Level 2, not 3.** Provenance is signed by the generic GitHub-hosted runner —
|
|
559
|
+
`builder.id` is `https://github.com/actions/runner/github-hosted` — which the build itself
|
|
560
|
+
can influence; Build L3 requires an isolated builder it cannot. Reaching L3 is not
|
|
561
|
+
currently compatible with trusted publishing: npm turns on its own provenance whenever that
|
|
562
|
+
setting is left at its default, and then ignores any externally generated one. So L3 today
|
|
563
|
+
would mean returning to a long-lived npm token — trading the property described above for a
|
|
564
|
+
level number.
|
|
565
|
+
|
|
526
566
|
### Safe Defaults
|
|
527
567
|
|
|
528
568
|
- **Non-root** user in all examples
|
|
@@ -735,3 +775,9 @@ See [CONTRIBUTING.md](./CONTRIBUTING.md). Please follow the [security checklist]
|
|
|
735
775
|
## Support
|
|
736
776
|
|
|
737
777
|
If you find SSH MCP Server helpful, consider starring the repository or [sponsoring](https://github.com/sponsors/tufantunc)!
|
|
778
|
+
|
|
779
|
+
## Listed on
|
|
780
|
+
|
|
781
|
+
[](https://glama.ai/mcp/servers/tufantunc/ssh-mcp)
|
|
782
|
+
|
|
783
|
+
Also on the [official MCP registry](https://registry.modelcontextprotocol.io) as `io.github.tufantunc/ssh-mcp`.
|
|
@@ -122,6 +122,134 @@ const ACTION_MULTIPLEXERS = new Set(['systemctl', 'init', 'telinit']);
|
|
|
122
122
|
* control inside a security release. `gosu` and `run0` were never caught by
|
|
123
123
|
* either form.
|
|
124
124
|
*/
|
|
125
|
+
/**
|
|
126
|
+
* Class ordering, so a command that contains another can take the higher of the two.
|
|
127
|
+
*
|
|
128
|
+
* Only used by the nesting scan below. Everything else in this file decides a single
|
|
129
|
+
* class outright.
|
|
130
|
+
*/
|
|
131
|
+
const CLASS_RANK = {
|
|
132
|
+
'read-only': 0,
|
|
133
|
+
safe: 1,
|
|
134
|
+
destructive: 2,
|
|
135
|
+
privileged: 3,
|
|
136
|
+
};
|
|
137
|
+
/** 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
|
+
]);
|
|
141
|
+
/**
|
|
142
|
+
* How deep a substitution may nest before we stop reading and refuse to guess.
|
|
143
|
+
*
|
|
144
|
+
* Reached only by input no operator writes — the tests use two hundred levels. The
|
|
145
|
+
* fallback is `privileged` rather than a lower class because the entire point of
|
|
146
|
+
* this scan is that a command we cannot read must not be treated as one we can.
|
|
147
|
+
*/
|
|
148
|
+
const MAX_NESTING_DEPTH = 8;
|
|
149
|
+
/**
|
|
150
|
+
* Split on whitespace the way a shell would, keeping quoted runs together.
|
|
151
|
+
*
|
|
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.
|
|
155
|
+
*/
|
|
156
|
+
function splitRespectingQuotes(command) {
|
|
157
|
+
const words = [];
|
|
158
|
+
let current = '';
|
|
159
|
+
let quote = null;
|
|
160
|
+
for (const ch of command) {
|
|
161
|
+
if (quote) {
|
|
162
|
+
if (ch === quote)
|
|
163
|
+
quote = null;
|
|
164
|
+
else
|
|
165
|
+
current += ch;
|
|
166
|
+
continue;
|
|
167
|
+
}
|
|
168
|
+
if (ch === '"' || ch === "'") {
|
|
169
|
+
quote = ch;
|
|
170
|
+
continue;
|
|
171
|
+
}
|
|
172
|
+
if (/\s/.test(ch)) {
|
|
173
|
+
if (current)
|
|
174
|
+
words.push(current);
|
|
175
|
+
current = '';
|
|
176
|
+
continue;
|
|
177
|
+
}
|
|
178
|
+
current += ch;
|
|
179
|
+
}
|
|
180
|
+
if (current)
|
|
181
|
+
words.push(current);
|
|
182
|
+
return words;
|
|
183
|
+
}
|
|
184
|
+
/**
|
|
185
|
+
* The commands hiding inside a command.
|
|
186
|
+
*
|
|
187
|
+
* The elevation scan reads tokens produced by splitting on `;&|` and whitespace, so
|
|
188
|
+
* it only ever saw the outer command: `echo $(sudo id)` tokenised to
|
|
189
|
+
* `["echo", "$(sudo", "id)"]`, `echo` was taken to be the real command, and no
|
|
190
|
+
* elevation was found (GHSA-v8jh-gv7v-3gvq). The destructive scan never had this
|
|
191
|
+
* problem because it reads the raw text — which is why `echo $(rm -rf /)` was
|
|
192
|
+
* classified correctly the whole time. This closes that asymmetry by pulling the
|
|
193
|
+
* inner commands out so they can be classified in their own right.
|
|
194
|
+
*
|
|
195
|
+
* Four carriers, all of which a remote shell expands and runs:
|
|
196
|
+
* `$(...)`, backticks, process substitution `<(...)` / `>(...)`, and a shell
|
|
197
|
+
* given `-c`.
|
|
198
|
+
*/
|
|
199
|
+
export function nestedCommands(command) {
|
|
200
|
+
const found = [];
|
|
201
|
+
// `$(...)`, `<(...)`, `>(...)` — scanned rather than matched, because a regex
|
|
202
|
+
// cannot balance parentheses and `echo $(echo $(sudo id))` is the case that
|
|
203
|
+
// matters most.
|
|
204
|
+
for (let i = 0; i < command.length; i++) {
|
|
205
|
+
const opensSubstitution = command[i] === '$' && command[i + 1] === '(';
|
|
206
|
+
const opensProcess = (command[i] === '<' || command[i] === '>') && command[i + 1] === '(';
|
|
207
|
+
if (!opensSubstitution && !opensProcess)
|
|
208
|
+
continue;
|
|
209
|
+
// `$((1 + 1))` is arithmetic, not a command. Skipping it keeps the approval
|
|
210
|
+
// prompt off `echo $((1 + 1))`.
|
|
211
|
+
if (opensSubstitution && command[i + 2] === '(')
|
|
212
|
+
continue;
|
|
213
|
+
let depth = 0;
|
|
214
|
+
for (let j = i + 1; j < command.length; j++) {
|
|
215
|
+
if (command[j] === '(')
|
|
216
|
+
depth++;
|
|
217
|
+
else if (command[j] === ')') {
|
|
218
|
+
depth--;
|
|
219
|
+
if (depth === 0) {
|
|
220
|
+
found.push(command.slice(i + 2, j));
|
|
221
|
+
i = j;
|
|
222
|
+
break;
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
// Backticks. No nesting to balance — the shell requires escaping to nest them.
|
|
228
|
+
const backticks = command.match(/`([^`]*)`/g);
|
|
229
|
+
if (backticks) {
|
|
230
|
+
for (const raw of backticks)
|
|
231
|
+
found.push(raw.slice(1, -1));
|
|
232
|
+
}
|
|
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;
|
|
244
|
+
}
|
|
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
|
+
}
|
|
250
|
+
}
|
|
251
|
+
return found.filter((c) => c.trim().length > 0);
|
|
252
|
+
}
|
|
125
253
|
const PRIVILEGE_PREFIXES = new Set([
|
|
126
254
|
'sudo', 'su', 'doas', 'pkexec',
|
|
127
255
|
// BusyBox/Alpine, Docker entrypoints, the Rust sudo now default on some
|
|
@@ -356,11 +484,25 @@ const FORBIDDEN_RULES = [
|
|
|
356
484
|
* lives in FORBIDDEN_RULES and a caller checking only the regexes would quietly
|
|
357
485
|
* permit `sudo reboot`.
|
|
358
486
|
*/
|
|
359
|
-
export function findForbiddenMatch(command) {
|
|
487
|
+
export function findForbiddenMatch(command, depth = 0) {
|
|
360
488
|
for (const rule of FORBIDDEN_RULES) {
|
|
361
489
|
if (rule.test(command))
|
|
362
490
|
return rule.label;
|
|
363
491
|
}
|
|
492
|
+
// The same carriers the class scan reads, for the same reason. This list is the
|
|
493
|
+
// one unconditional rule in the policy — forbidden regardless of role, tier or
|
|
494
|
+
// approval — and it was decided from the outer command alone, so
|
|
495
|
+
// `sh -c "shutdown -h now"` and `echo $(shutdown -h now)` were not forbidden.
|
|
496
|
+
// They still classified `destructive`, which on the `prod` tier degrades an
|
|
497
|
+
// absolute `deny` into `require-approval`: a rule that answers "never" became
|
|
498
|
+
// one a human can click through.
|
|
499
|
+
if (depth >= MAX_NESTING_DEPTH)
|
|
500
|
+
return null;
|
|
501
|
+
for (const inner of nestedCommands(command)) {
|
|
502
|
+
const match = findForbiddenMatch(inner, depth + 1);
|
|
503
|
+
if (match !== null)
|
|
504
|
+
return match;
|
|
505
|
+
}
|
|
364
506
|
return null;
|
|
365
507
|
}
|
|
366
508
|
export function isForbidden(command) {
|
|
@@ -401,10 +543,97 @@ export function extractBinary(command) {
|
|
|
401
543
|
const parts = cmd.split(/\s+/);
|
|
402
544
|
return parts[0] || '';
|
|
403
545
|
}
|
|
404
|
-
|
|
546
|
+
/**
|
|
547
|
+
* The word a segment will actually execute, read past anything that is not it.
|
|
548
|
+
*
|
|
549
|
+
* Distinct from `elevatedBinary`, which answers "does this ask for elevation".
|
|
550
|
+
* This answers "what is the name of the thing that runs", so it can be asked
|
|
551
|
+
* whether that name is knowable at all.
|
|
552
|
+
*/
|
|
553
|
+
function effectiveCommandWord(words) {
|
|
554
|
+
let i = 0;
|
|
555
|
+
while (i < words.length) {
|
|
556
|
+
const raw = words[i];
|
|
557
|
+
const word = stripPath(unquote(raw));
|
|
558
|
+
if (PRIVILEGE_PREFIXES.has(word) || EXEC_WRAPPERS.has(word)) {
|
|
559
|
+
i++;
|
|
560
|
+
while (i < words.length && words[i].startsWith('-')) {
|
|
561
|
+
const consumesValue = PREFIX_VALUE_FLAGS.has(words[i]);
|
|
562
|
+
i++;
|
|
563
|
+
if (consumesValue)
|
|
564
|
+
i++;
|
|
565
|
+
}
|
|
566
|
+
continue;
|
|
567
|
+
}
|
|
568
|
+
if (ASSIGNMENT.test(raw) || raw.startsWith('-') || BARE_NUMERIC.test(raw)) {
|
|
569
|
+
i++;
|
|
570
|
+
continue;
|
|
571
|
+
}
|
|
572
|
+
return raw;
|
|
573
|
+
}
|
|
574
|
+
return null;
|
|
575
|
+
}
|
|
576
|
+
/**
|
|
577
|
+
* Whether any segment runs a command this process cannot name.
|
|
578
|
+
*
|
|
579
|
+
* The class — and with it the approval gate — is decided from the literal text of
|
|
580
|
+
* the command word. A word carrying `$` or a backtick is a name the shell resolves
|
|
581
|
+
* at run time, so `$S id` was classified as though `$S` were a binary, and came
|
|
582
|
+
* out `safe` (GHSA-fj9r-f47j-c73x).
|
|
583
|
+
*
|
|
584
|
+
* Resolving the variable is not the answer and cannot be: a session run keeps the
|
|
585
|
+
* caller's shell state, so `S=sudo` and `$S id` can arrive as two separate calls,
|
|
586
|
+
* and a variable exported in the target's own profile is never visible here at
|
|
587
|
+
* all. What is answerable is whether we know the name — and when we do not, saying
|
|
588
|
+
* so is the only honest class.
|
|
589
|
+
*
|
|
590
|
+
* Only the command word, never the arguments. `echo $HOME` names a command we know;
|
|
591
|
+
* promoting that would put a prompt on most ordinary shell usage.
|
|
592
|
+
*/
|
|
593
|
+
function hasUnnameableCommand(command) {
|
|
594
|
+
for (const raw of command.split(/[;&|\n]/)) {
|
|
595
|
+
const words = raw.trim().split(/\s+/).filter(Boolean);
|
|
596
|
+
const head = effectiveCommandWord(words);
|
|
597
|
+
if (head !== null && /[$`]/.test(head))
|
|
598
|
+
return true;
|
|
599
|
+
}
|
|
600
|
+
return false;
|
|
601
|
+
}
|
|
602
|
+
export function classifyCommand(command, depth = 0) {
|
|
405
603
|
const trimmed = command.trim();
|
|
406
604
|
const binary = extractBinary(trimmed);
|
|
407
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 {
|
|
632
|
+
// 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' };
|
|
636
|
+
}
|
|
408
637
|
// `binary` names the subject of the class. For everything below it is the
|
|
409
638
|
// leading command; here it is the one that runs as root, which are the same
|
|
410
639
|
// thing only when the prefix leads.
|
|
@@ -415,6 +644,17 @@ export function classifyCommand(command) {
|
|
|
415
644
|
if (isDestructive(trimmed) || hasDisqualifyingArgs(trimmed)) {
|
|
416
645
|
return { binary, fullCommand, class: 'destructive' };
|
|
417
646
|
}
|
|
647
|
+
// Below this point every branch assumes the command word names something we
|
|
648
|
+
// recognised. When it is a variable expansion it names nothing we can check, so
|
|
649
|
+
// the allowlist must not be consulted — `$S` is not on it, and falling through to
|
|
650
|
+
// the default made an unknown command `safe`.
|
|
651
|
+
//
|
|
652
|
+
// `destructive` rather than `privileged`: this is "we cannot tell", not "this is
|
|
653
|
+
// root". It gates on approval instead of refusing outright, which keeps
|
|
654
|
+
// `$PREFIX/bin/tool` usable for a role that holds `destructive` on the tier.
|
|
655
|
+
if (hasUnnameableCommand(trimmed)) {
|
|
656
|
+
return { binary, fullCommand, class: 'destructive' };
|
|
657
|
+
}
|
|
418
658
|
const twoWordPrefix = fullCommand.split(/\s+/).slice(0, 2).join(' ');
|
|
419
659
|
if (READ_ONLY_ALLOWLIST.has(binary) || READ_ONLY_ALLOWLIST.has(twoWordPrefix)) {
|
|
420
660
|
if (SHELL_CONTROL_CHARS.test(trimmed)) {
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "ssh-mcp",
|
|
3
3
|
"license": "MIT",
|
|
4
|
-
"version": "2.4.
|
|
4
|
+
"version": "2.4.2",
|
|
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",
|