ssh-mcp 2.19.1 → 2.19.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.
@@ -45,6 +45,11 @@ export function redactText(text, opts = {}) {
45
45
  return match;
46
46
  });
47
47
  }
48
+ for (const secret of opts.literalSecrets ?? []) {
49
+ if (secret && secret.length >= 4) {
50
+ result = result.split(secret).join(`[REDACTED:secret:${secret.length}]`);
51
+ }
52
+ }
48
53
  return result;
49
54
  }
50
55
  export function redactRecord(obj, opts = {}) {
@@ -59,7 +59,16 @@ export function registerCommandTools({ server, registry, policy }, pipeline) {
59
59
  throw new PolicyRefusedError(`POLICY_DENIED: ${rawEval.reason || 'Command not allowed'}`, rawEval);
60
60
  }
61
61
  },
62
- wrap: (cleanCmd) => `sudo -p "" -S sh -c ${shellSingleQuote(cleanCmd)}`,
62
+ // `sudo -S` reads stdin only when it actually needs a password. Under
63
+ // NOPASSWD — or any policy that skips the prompt — the password line
64
+ // written for it stays in the pipe, and sudo exec's this `sh -c` with
65
+ // that same stdin, so a stdin-reading command could hand the password
66
+ // straight back (GHSA-9m75-x2ch-j4jr). Re-pointing fd 0 at /dev/null
67
+ // before the command runs means sudo still gets its password from the
68
+ // channel when it does prompt, and the command can never see it.
69
+ // stdin carries nothing else the command could want: the password
70
+ // line was the only thing ever written.
71
+ wrap: (cleanCmd) => `sudo -p "" -S sh -c ${shellSingleQuote(`exec 0</dev/null; ${cleanCmd}`)}`,
63
72
  }, (rt) => {
64
73
  const sudoPassword = rt.conn.getSudoPassword();
65
74
  return execAndReport({ stdin: sudoPassword ? sudoPassword + '\n' : undefined })(rt);
@@ -7,7 +7,7 @@ export const TOOL_DESCRIPTIONS = {
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. Single-line only: a command containing a line break is refused, so upload a multi-line script with sftp-upload and run it by path.',
9
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. Single-line only: a command containing a line break is refused, so upload a multi-line script with sftp-upload and run it by path.',
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). Single-line only: a command containing a line break is refused, so upload a multi-line script with sftp-upload and run it by path.',
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 for the prompt only (never visible in process list); the command itself runs with stdin at /dev/null, so it can never read the password back. Single-line only: a command containing a line break is refused, so upload a multi-line script with sftp-upload and run it by path.',
11
11
  'sftp-upload': 'Upload a file to the remote server via SFTP (secure file transfer, not shell-based). Replaces an existing file at that path unconditionally — there is no overwrite flag and no way to require a new destination.',
12
12
  'sftp-download': 'Download a file from the remote server via SFTP.',
13
13
  'sftp-list': 'List a remote directory over SFTP, with a bounded number of entries and a bounded response size. Read-only.',
@@ -219,7 +219,12 @@ export function createPipeline({ server, registry, policy, audit, approvalGrantT
219
219
  onProgress: rt.onProgress,
220
220
  abortSignal: rt.abortSignal,
221
221
  });
222
- return { audited, output: commandOutput(audited) };
222
+ // The sudo password is the one secret that can plausibly end up in this
223
+ // output (GHSA-9m75-x2ch-j4jr): it is written onto the very channel the
224
+ // command reads. Scrub it here whatever the exec path was.
225
+ const sudoPassword = rt.conn.getSudoPassword?.();
226
+ const literalSecrets = sudoPassword ? [sudoPassword] : undefined;
227
+ return { audited, output: commandOutput(audited, { literalSecrets }) };
223
228
  };
224
229
  }
225
230
  return { runAudited, execAndReport, defaultProfileName, makeCtx, auditResult, auditFailure, resolveConn, quota, grants };
@@ -10,14 +10,15 @@ export function textResult(text) {
10
10
  * is reported as isError with the redacted stderr and the exit status, so a
11
11
  * failed remote command cannot be mistaken for an empty success.
12
12
  */
13
- export function commandOutput(result) {
13
+ export function commandOutput(result, opts = {}) {
14
14
  // ssh2 reports exitCode null when the process died from a signal.
15
15
  const failed = result.exitCode !== 0 || Boolean(result.signal);
16
16
  // entropyScan matches the SFTP and session-output paths. Without it a plain
17
17
  // `env` dump hands high-entropy secrets straight to the model, since the
18
18
  // regex layer only knows a fixed set of token shapes.
19
- const stdout = redactText(result.stdout, { entropyScan: true });
20
- const stderr = redactText(result.stderr, { entropyScan: true });
19
+ const redactOpts = { entropyScan: true, literalSecrets: opts.literalSecrets };
20
+ const stdout = redactText(result.stdout, redactOpts);
21
+ const stderr = redactText(result.stderr, redactOpts);
21
22
  const parts = [];
22
23
  if (stdout)
23
24
  parts.push(stdout);
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "ssh-mcp",
3
3
  "license": "MIT",
4
- "version": "2.19.1",
4
+ "version": "2.19.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",