ssh-mcp 2.2.4 → 2.2.6
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.
|
@@ -100,8 +100,20 @@ export async function resolveCredentials(profile) {
|
|
|
100
100
|
}
|
|
101
101
|
}
|
|
102
102
|
if (!creds.password && !creds.privateKey && !creds.agentSocket) {
|
|
103
|
-
|
|
104
|
-
|
|
103
|
+
// Naming the method the profile asked for, rather than listing all four,
|
|
104
|
+
// is the difference between "fix the one you chose" and "pick whichever is
|
|
105
|
+
// easiest" — and the easiest is a plaintext password in the environment.
|
|
106
|
+
const asked = profile.auth;
|
|
107
|
+
const why = {
|
|
108
|
+
agent: 'SSH_AUTH_SOCK is unset or the agent holds no usable key',
|
|
109
|
+
key: `no key was read${profile.keyRef ? ` from ${profile.keyRef}` : ' — keyRef is not set on the profile'}`,
|
|
110
|
+
keychain: `the OS keychain returned nothing${profile.keychainEntry ? ` for "${profile.keychainEntry}"` : ' — keychainEntry is not set on the profile'}`,
|
|
111
|
+
password: `SSH_MCP_PASSWORD and SSH_MCP_${profile.name.toUpperCase().replace(/[^A-Z0-9]/g, '_')}_PASSWORD are both unset`,
|
|
112
|
+
};
|
|
113
|
+
throw new Error(`No credentials resolved for profile "${profile.name}", which asks for auth = "${asked}": ${why[asked] ?? 'nothing was found'}.\n` +
|
|
114
|
+
'Fix that method rather than switching to another if you can — the fallbacks are ordered by how much ' +
|
|
115
|
+
'they expose. In order: an SSH agent (nothing on disk), an encrypted key with SSH_MCP_KEY, the OS ' +
|
|
116
|
+
`keychain, and last a password in SSH_MCP_PASSWORD, which is readable by every process running as you.`);
|
|
105
117
|
}
|
|
106
118
|
return creds;
|
|
107
119
|
}
|
|
@@ -107,7 +107,27 @@ const FORBIDDEN_PATTERNS = [
|
|
|
107
107
|
const FORBIDDEN_INVOCATIONS = new Set(['shutdown', 'reboot', 'halt', 'poweroff', 'eval']);
|
|
108
108
|
/** Binaries that take the dangerous action as an argument: `systemctl reboot`. */
|
|
109
109
|
const ACTION_MULTIPLEXERS = new Set(['systemctl', 'init', 'telinit']);
|
|
110
|
-
|
|
110
|
+
/**
|
|
111
|
+
* Binaries that run the rest of the command as another user.
|
|
112
|
+
*
|
|
113
|
+
* The list is explicit rather than a pattern, because a pattern cannot tell
|
|
114
|
+
* `sudoedit` — which edits a file and is not elevation — from `sudo-rs`, which
|
|
115
|
+
* is sudo. It has to be maintained by hand as new implementations appear; the
|
|
116
|
+
* cost of missing one is that its commands classify `safe`.
|
|
117
|
+
*
|
|
118
|
+
* The last five were added in 2.2.5 (#132). Until 2.2.4 the check was
|
|
119
|
+
* `/^\s*su\b/` and friends, and `\b` matched between `su` and the hyphen, so
|
|
120
|
+
* `su-exec` and `sudo-rs` were caught — by accident of the regex, not by
|
|
121
|
+
* intent. Moving to exact membership dropped them, which narrowed a security
|
|
122
|
+
* control inside a security release. `gosu` and `run0` were never caught by
|
|
123
|
+
* either form.
|
|
124
|
+
*/
|
|
125
|
+
const PRIVILEGE_PREFIXES = new Set([
|
|
126
|
+
'sudo', 'su', 'doas', 'pkexec',
|
|
127
|
+
// BusyBox/Alpine, Docker entrypoints, the Rust sudo now default on some
|
|
128
|
+
// distributions, systemd's replacement, and the Solaris/illumos spelling.
|
|
129
|
+
'su-exec', 'gosu', 'sudo-rs', 'run0', 'pfexec',
|
|
130
|
+
]);
|
|
111
131
|
/**
|
|
112
132
|
* Binaries whose job is to run another command.
|
|
113
133
|
*
|
|
@@ -202,22 +222,57 @@ function parseSegments(command) {
|
|
|
202
222
|
* anything else is not, which is what keeps `grep sudo /var/log/auth.log` a
|
|
203
223
|
* mention rather than an invocation.
|
|
204
224
|
*/
|
|
205
|
-
function
|
|
206
|
-
|
|
225
|
+
function elevatedBinary(words) {
|
|
226
|
+
let i = 0;
|
|
227
|
+
let prefix = null;
|
|
228
|
+
while (i < words.length) {
|
|
229
|
+
const raw = words[i];
|
|
207
230
|
const word = stripPath(unquote(raw));
|
|
208
|
-
if (PRIVILEGE_PREFIXES.has(word))
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
231
|
+
if (PRIVILEGE_PREFIXES.has(word)) {
|
|
232
|
+
prefix ??= word;
|
|
233
|
+
i++;
|
|
234
|
+
// The prefix's own options, some of which swallow the next word.
|
|
235
|
+
while (i < words.length && words[i].startsWith('-')) {
|
|
236
|
+
const consumesValue = PREFIX_VALUE_FLAGS.has(words[i]);
|
|
237
|
+
i++;
|
|
238
|
+
if (consumesValue)
|
|
239
|
+
i++;
|
|
240
|
+
}
|
|
215
241
|
continue;
|
|
216
|
-
|
|
242
|
+
}
|
|
243
|
+
// Words that are not yet the command: an assignment, a wrapper, an option,
|
|
244
|
+
// or the bare number `timeout`/`nice` take before theirs.
|
|
245
|
+
if (ASSIGNMENT.test(raw) || EXEC_WRAPPERS.has(word)
|
|
246
|
+
|| raw.startsWith('-') || BARE_NUMERIC.test(raw)) {
|
|
247
|
+
i++;
|
|
217
248
|
continue;
|
|
218
|
-
|
|
249
|
+
}
|
|
250
|
+
// A real command. Before any prefix it means the segment does not elevate —
|
|
251
|
+
// which is what keeps `grep sudo /var/log/auth.log` a mention. After one, it
|
|
252
|
+
// is what actually runs as root.
|
|
253
|
+
return prefix === null ? null : word;
|
|
254
|
+
}
|
|
255
|
+
// `sudo`, or `sudo -u root`, with nothing after it: the prefix is all there is
|
|
256
|
+
// to name.
|
|
257
|
+
return prefix;
|
|
258
|
+
}
|
|
259
|
+
/**
|
|
260
|
+
* The binary this command runs under elevation, or null if it runs none.
|
|
261
|
+
*
|
|
262
|
+
* Returning the name rather than a boolean is what lets `ParsedCommand.binary`
|
|
263
|
+
* describe the same command the class does. Until 2.2.4 a `privileged` class
|
|
264
|
+
* implied a leading prefix, so the anchored `extractBinary` always named the
|
|
265
|
+
* elevated binary; once elevation could be found in any segment that stopped
|
|
266
|
+
* holding, and `echo hi; sudo id` recorded `binary: "echo"` against a
|
|
267
|
+
* privileged decision — in the audit log, the OTel span and OPA's input (#134).
|
|
268
|
+
*/
|
|
269
|
+
function elevatedBinaryOf(command) {
|
|
270
|
+
for (const segment of command.split(/[;&|\n]/)) {
|
|
271
|
+
const found = elevatedBinary(segment.trim().split(/\s+/).filter(Boolean));
|
|
272
|
+
if (found !== null)
|
|
273
|
+
return found;
|
|
219
274
|
}
|
|
220
|
-
return
|
|
275
|
+
return null;
|
|
221
276
|
}
|
|
222
277
|
/**
|
|
223
278
|
* Does this command elevate anywhere a shell would act on it?
|
|
@@ -226,11 +281,6 @@ function segmentElevates(words) {
|
|
|
226
281
|
* behind a wrapper, behind an assignment, or after a separator all reached root
|
|
227
282
|
* with the command classified `safe` or `read-only`.
|
|
228
283
|
*/
|
|
229
|
-
function elevates(command) {
|
|
230
|
-
return command
|
|
231
|
-
.split(/[;&|\n]/)
|
|
232
|
-
.some((segment) => segmentElevates(segment.trim().split(/\s+/).filter(Boolean)));
|
|
233
|
-
}
|
|
234
284
|
/** An allowlisted binary carrying a flag that makes it write or execute. */
|
|
235
285
|
function hasDisqualifyingArgs(command) {
|
|
236
286
|
return parseSegments(command).some(({ head, args }) => {
|
|
@@ -355,8 +405,12 @@ export function classifyCommand(command) {
|
|
|
355
405
|
const trimmed = command.trim();
|
|
356
406
|
const binary = extractBinary(trimmed);
|
|
357
407
|
const fullCommand = trimmed;
|
|
358
|
-
|
|
359
|
-
|
|
408
|
+
// `binary` names the subject of the class. For everything below it is the
|
|
409
|
+
// leading command; here it is the one that runs as root, which are the same
|
|
410
|
+
// thing only when the prefix leads.
|
|
411
|
+
const elevated = elevatedBinaryOf(trimmed);
|
|
412
|
+
if (elevated !== null) {
|
|
413
|
+
return { binary: elevated, fullCommand, class: 'privileged' };
|
|
360
414
|
}
|
|
361
415
|
if (isDestructive(trimmed) || hasDisqualifyingArgs(trimmed)) {
|
|
362
416
|
return { binary, fullCommand, class: 'destructive' };
|
package/build/ssh/host-key.js
CHANGED
|
@@ -14,7 +14,19 @@ export function verifyHostKey(host, port, fingerprint, knownHosts, mode) {
|
|
|
14
14
|
const stored = knownHosts.get(key);
|
|
15
15
|
if (stored) {
|
|
16
16
|
if (stored !== fingerprint) {
|
|
17
|
-
|
|
17
|
+
// Two very different things produce this, and the message has to let the
|
|
18
|
+
// reader tell them apart — otherwise the reflex is --insecureHostKey,
|
|
19
|
+
// which turns the check off for every host rather than for this one.
|
|
20
|
+
throw new Error(`HOST_KEY_MISMATCH: the key presented by ${host}:${port} is not the one seen before.\n` +
|
|
21
|
+
` expected ${stored}\n` +
|
|
22
|
+
` received ${fingerprint}\n` +
|
|
23
|
+
'Either the server was rebuilt, reinstalled or had its address reused — in which case the new key is ' +
|
|
24
|
+
'genuine — or something is intercepting this connection, which is what this check exists to catch. ' +
|
|
25
|
+
'Nothing here can tell those apart; confirm the fingerprint out of band, from the host itself ' +
|
|
26
|
+
'(`ssh-keygen -lf /etc/ssh/ssh_host_ed25519_key.pub` on the server) or from whoever rebuilt it.\n' +
|
|
27
|
+
'If it matches, pin it with trustedHostKey on the profile. Do not reach for --insecureHostKey to get ' +
|
|
28
|
+
'past this: it disables verification for every host and every future connection, including the case ' +
|
|
29
|
+
'this message is warning you about.');
|
|
18
30
|
}
|
|
19
31
|
return true;
|
|
20
32
|
}
|
package/package.json
CHANGED