ssh-mcp 2.2.5 → 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
|
}
|
|
@@ -222,22 +222,57 @@ function parseSegments(command) {
|
|
|
222
222
|
* anything else is not, which is what keeps `grep sudo /var/log/auth.log` a
|
|
223
223
|
* mention rather than an invocation.
|
|
224
224
|
*/
|
|
225
|
-
function
|
|
226
|
-
|
|
225
|
+
function elevatedBinary(words) {
|
|
226
|
+
let i = 0;
|
|
227
|
+
let prefix = null;
|
|
228
|
+
while (i < words.length) {
|
|
229
|
+
const raw = words[i];
|
|
227
230
|
const word = stripPath(unquote(raw));
|
|
228
|
-
if (PRIVILEGE_PREFIXES.has(word))
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
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
|
+
}
|
|
235
241
|
continue;
|
|
236
|
-
|
|
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++;
|
|
237
248
|
continue;
|
|
238
|
-
|
|
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;
|
|
239
274
|
}
|
|
240
|
-
return
|
|
275
|
+
return null;
|
|
241
276
|
}
|
|
242
277
|
/**
|
|
243
278
|
* Does this command elevate anywhere a shell would act on it?
|
|
@@ -246,11 +281,6 @@ function segmentElevates(words) {
|
|
|
246
281
|
* behind a wrapper, behind an assignment, or after a separator all reached root
|
|
247
282
|
* with the command classified `safe` or `read-only`.
|
|
248
283
|
*/
|
|
249
|
-
function elevates(command) {
|
|
250
|
-
return command
|
|
251
|
-
.split(/[;&|\n]/)
|
|
252
|
-
.some((segment) => segmentElevates(segment.trim().split(/\s+/).filter(Boolean)));
|
|
253
|
-
}
|
|
254
284
|
/** An allowlisted binary carrying a flag that makes it write or execute. */
|
|
255
285
|
function hasDisqualifyingArgs(command) {
|
|
256
286
|
return parseSegments(command).some(({ head, args }) => {
|
|
@@ -375,8 +405,12 @@ export function classifyCommand(command) {
|
|
|
375
405
|
const trimmed = command.trim();
|
|
376
406
|
const binary = extractBinary(trimmed);
|
|
377
407
|
const fullCommand = trimmed;
|
|
378
|
-
|
|
379
|
-
|
|
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' };
|
|
380
414
|
}
|
|
381
415
|
if (isDestructive(trimmed) || hasDisqualifyingArgs(trimmed)) {
|
|
382
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