ssh-mcp 2.4.0 → 2.4.1

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 CHANGED
@@ -3,6 +3,7 @@
3
3
  [![NPM Version](https://img.shields.io/npm/v/ssh-mcp)](https://www.npmjs.com/package/ssh-mcp)
4
4
  [![Downloads](https://img.shields.io/npm/dm/ssh-mcp)](https://www.npmjs.com/package/ssh-mcp)
5
5
  [![CI](https://github.com/tufantunc/ssh-mcp/actions/workflows/ci.yml/badge.svg?branch=main)](https://github.com/tufantunc/ssh-mcp/actions/workflows/ci.yml)
6
+ [![OpenSSF Scorecard](https://api.securityscorecards.dev/projects/github.com/tufantunc/ssh-mcp/badge)](https://scorecard.dev/viewer/?uri=github.com/tufantunc/ssh-mcp)
6
7
  [![codecov](https://codecov.io/gh/tufantunc/ssh-mcp/graph/badge.svg?branch=main)](https://codecov.io/gh/tufantunc/ssh-mcp)
7
8
  [![License](https://img.shields.io/github/license/tufantunc/ssh-mcp)](./LICENSE)
8
9
  [![GitHub issues](https://img.shields.io/github/issues/tufantunc/ssh-mcp)](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
+ [![SSH MCP Server on Glama](https://glama.ai/mcp/servers/tufantunc/ssh-mcp/badge)](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,41 @@ export function extractBinary(command) {
401
543
  const parts = cmd.split(/\s+/);
402
544
  return parts[0] || '';
403
545
  }
404
- export function classifyCommand(command) {
546
+ export function classifyCommand(command, depth = 0) {
405
547
  const trimmed = command.trim();
406
548
  const binary = extractBinary(trimmed);
407
549
  const fullCommand = trimmed;
550
+ // A command that carries another command decides nothing on its own: the remote
551
+ // shell expands `$(...)`, backticks, `<(...)` and `sh -c` and runs what is inside,
552
+ // so the class has to be the higher of the two. Before this, the outer command
553
+ // decided alone and `echo $(sudo id)` was `safe` — a class the default rules grant
554
+ // on `prod` to `operator` and `admin`, while granting `privileged` there to nobody
555
+ // at all (GHSA-v8jh-gv7v-3gvq). It also skipped the approval prompt, since only
556
+ // `destructive` and `privileged` raise one, and the audit record said `safe`.
557
+ //
558
+ // Runs before the checks below rather than after, so the class it produces is not
559
+ // something a later branch can lower.
560
+ if (depth < MAX_NESTING_DEPTH) {
561
+ let highest = null;
562
+ for (const inner of nestedCommands(trimmed)) {
563
+ const parsed = classifyCommand(inner, depth + 1);
564
+ if (highest === null || CLASS_RANK[parsed.class] > CLASS_RANK[highest.class]) {
565
+ highest = parsed;
566
+ }
567
+ }
568
+ // `binary` comes from the inner command deliberately: it is what the audit
569
+ // record and the refusal message name, and naming the outer `echo` would
570
+ // describe the wrong process as the one that ran as root.
571
+ if (highest !== null && CLASS_RANK[highest.class] > CLASS_RANK['safe']) {
572
+ return { binary: highest.binary, fullCommand, class: highest.class };
573
+ }
574
+ }
575
+ else {
576
+ // Nesting this deep is not something an operator writes, and we have stopped
577
+ // reading. Refusing to guess is the only answer consistent with the rest of
578
+ // this function.
579
+ return { binary, fullCommand, class: 'privileged' };
580
+ }
408
581
  // `binary` names the subject of the class. For everything below it is the
409
582
  // leading command; here it is the one that runs as root, which are the same
410
583
  // thing only when the prefix leads.
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "ssh-mcp",
3
3
  "license": "MIT",
4
- "version": "2.4.0",
4
+ "version": "2.4.1",
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",