ssh-mcp 2.0.0 → 2.0.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 +2 -0
- package/build/config/loader.js +10 -0
- package/build/policy/classifier.js +13 -4
- package/build/ssh/host-key.js +5 -1
- package/build/ssh/session.js +39 -2
- package/package.json +2 -1
package/README.md
CHANGED
|
@@ -2,6 +2,8 @@
|
|
|
2
2
|
|
|
3
3
|
[](https://www.npmjs.com/package/ssh-mcp)
|
|
4
4
|
[](https://www.npmjs.com/package/ssh-mcp)
|
|
5
|
+
[](https://github.com/tufantunc/ssh-mcp/actions/workflows/ci.yml)
|
|
6
|
+
[](https://codecov.io/gh/tufantunc/ssh-mcp)
|
|
5
7
|
[](./LICENSE)
|
|
6
8
|
[](https://github.com/tufantunc/ssh-mcp/issues)
|
|
7
9
|
|
package/build/config/loader.js
CHANGED
|
@@ -91,6 +91,16 @@ function normalizeConfig(raw) {
|
|
|
91
91
|
export function getProfile(config, name) {
|
|
92
92
|
const targetName = name || config.defaults.defaultProfile;
|
|
93
93
|
if (!targetName) {
|
|
94
|
+
// Falling back to profiles[0] meant that with several hosts configured and
|
|
95
|
+
// no default chosen, a command with no profile argument ran against
|
|
96
|
+
// whichever host happened to be listed first — silently, and typically the
|
|
97
|
+
// one written down first, which tends to be production. One profile is
|
|
98
|
+
// unambiguous; more than one is a question the caller has to answer.
|
|
99
|
+
if (config.profiles.length > 1) {
|
|
100
|
+
const names = config.profiles.map((p) => p.name).join(', ');
|
|
101
|
+
throw new Error(`No profile selected and no default configured, but ${config.profiles.length} profiles exist: ${names}. ` +
|
|
102
|
+
`Pass a "profile" argument, or set defaults.defaultProfile in the config.`);
|
|
103
|
+
}
|
|
94
104
|
return config.profiles[0];
|
|
95
105
|
}
|
|
96
106
|
const profile = config.profiles.find((p) => p.name === targetName);
|
|
@@ -28,25 +28,34 @@ const READ_ONLY_ALLOWLIST = new Set([
|
|
|
28
28
|
* pattern was narrower). Anything here is also destructive for classification
|
|
29
29
|
* purposes; see DESTRUCTIVE_DENYLIST below.
|
|
30
30
|
*/
|
|
31
|
+
/*
|
|
32
|
+
* Every `\s+` here is followed by a literal. Where a `.*` or `[^|]*` comes
|
|
33
|
+
* next, the quantifier before it is a single `\s`: `\s+.*` lets both halves
|
|
34
|
+
* claim the same run of spaces, so a non-matching command is retried from every
|
|
35
|
+
* split and the match goes quadratic. sanitizeCommand caps commands at
|
|
36
|
+
* profile.maxChars (5000 by default) long before this runs, which is what keeps
|
|
37
|
+
* that cheap — but a policy check should not depend on a limit set three layers
|
|
38
|
+
* away and configurable to any value.
|
|
39
|
+
*/
|
|
31
40
|
const FORBIDDEN_PATTERNS = [
|
|
32
41
|
/rm\s+-rf?\s+\/(\s|$)/, // rm -rf / — the filesystem root itself
|
|
33
42
|
/mkfs\./,
|
|
34
|
-
/dd\s
|
|
43
|
+
/dd\s.*\bof=\/dev\//,
|
|
35
44
|
/>\s*\/dev\/sd/,
|
|
36
45
|
/\bshutdown\b/,
|
|
37
46
|
/\breboot\b/,
|
|
38
47
|
/\bhalt\b/,
|
|
39
48
|
/\bpoweroff\b/,
|
|
40
49
|
/:\(\)\s*\{\s*:\|:\&\s*\}\s*;\s*:/, // fork bomb
|
|
41
|
-
/curl\s
|
|
42
|
-
/wget\s
|
|
50
|
+
/curl\s[^|]*\|\s*(sh|bash|zsh)/,
|
|
51
|
+
/wget\s[^|]*\|\s*(sh|bash|zsh)/,
|
|
43
52
|
/\beval\b/,
|
|
44
53
|
/>\s*\/etc\/cron/,
|
|
45
54
|
/>\s*\/etc\/systemd/,
|
|
46
55
|
/>\s*~\/.ssh\/authorized_keys/,
|
|
47
56
|
/\biptables\s+-F\b/,
|
|
48
57
|
/\bchmod\s+-R\s+777\s+\//,
|
|
49
|
-
/\bchown\s+-R\s
|
|
58
|
+
/\bchown\s+-R\s.*\s\/\s*$/,
|
|
50
59
|
];
|
|
51
60
|
/**
|
|
52
61
|
* Commands that are destructive but legitimate under approval — e.g.
|
package/build/ssh/host-key.js
CHANGED
|
@@ -1,7 +1,11 @@
|
|
|
1
1
|
import { createHash } from 'crypto';
|
|
2
2
|
export function fingerprintPublicKey(key) {
|
|
3
3
|
const hash = createHash('sha256').update(key).digest('base64');
|
|
4
|
-
|
|
4
|
+
// `={0,2}` rather than `=+`: base64 padding is never longer than two
|
|
5
|
+
// characters, and the bounded form cannot backtrack. Not reachable here — the
|
|
6
|
+
// input is always a 44-character digest — but an unbounded trailing
|
|
7
|
+
// quantifier is the shape worth not copying elsewhere.
|
|
8
|
+
return `SHA256:${hash.replace(/={0,2}$/, '')}`;
|
|
5
9
|
}
|
|
6
10
|
export function verifyHostKey(host, port, fingerprint, knownHosts, mode) {
|
|
7
11
|
if (mode === 'insecure')
|
package/build/ssh/session.js
CHANGED
|
@@ -1,8 +1,45 @@
|
|
|
1
|
+
import { randomBytes } from 'crypto';
|
|
1
2
|
import { tracer } from '../observability/tracer.js';
|
|
2
3
|
const ANSI_REGEX = /\x1b\[[0-9;?]*[a-zA-Z]|\x1b\][^\x07]*\x07|\r/g;
|
|
3
4
|
export function stripAnsi(text) {
|
|
4
5
|
return text.replace(ANSI_REGEX, '');
|
|
5
6
|
}
|
|
7
|
+
/**
|
|
8
|
+
* Trim leading and trailing newlines by index rather than with /^\n+/ and
|
|
9
|
+
* /\n+$/.
|
|
10
|
+
*
|
|
11
|
+
* The trailing pattern is unanchored at the start, so on output that is mostly
|
|
12
|
+
* newlines but does not end in one the engine retries `\n+` from every offset:
|
|
13
|
+
* quadratic in the length of the output. That output is whatever the remote
|
|
14
|
+
* command printed and the session buffer holds up to 2 MB of it, which measured
|
|
15
|
+
* at roughly 25 minutes of blocked event loop — and the loop is shared by every
|
|
16
|
+
* session and connection this server has open.
|
|
17
|
+
*/
|
|
18
|
+
/**
|
|
19
|
+
* A per-command marker separating the command's own output from the trailer
|
|
20
|
+
* carrying `$?` and `$PWD`.
|
|
21
|
+
*
|
|
22
|
+
* Guessing one is enough to forge an exit code or a working directory — a
|
|
23
|
+
* failed command reported as successful, in a server whose whole claim is an
|
|
24
|
+
* auditable record of what ran. Every marker is written to the remote host in
|
|
25
|
+
* the clear, so anything watching that session collects a stream of them, and
|
|
26
|
+
* Math.random() is reconstructible from such a stream.
|
|
27
|
+
*
|
|
28
|
+
* base64url keeps the value safe both inside the single-quoted printf that
|
|
29
|
+
* emits it and inside the RegExp built from it: no quotes, no metacharacters.
|
|
30
|
+
*/
|
|
31
|
+
export function generateSessionMarker() {
|
|
32
|
+
return randomBytes(12).toString('base64url');
|
|
33
|
+
}
|
|
34
|
+
export function trimNewlines(text) {
|
|
35
|
+
let start = 0;
|
|
36
|
+
let end = text.length;
|
|
37
|
+
while (start < end && text.charCodeAt(start) === 10)
|
|
38
|
+
start++;
|
|
39
|
+
while (end > start && text.charCodeAt(end - 1) === 10)
|
|
40
|
+
end--;
|
|
41
|
+
return start === 0 && end === text.length ? text : text.slice(start, end);
|
|
42
|
+
}
|
|
6
43
|
export class Session {
|
|
7
44
|
id;
|
|
8
45
|
name;
|
|
@@ -143,7 +180,7 @@ export class InteractiveSession extends Session {
|
|
|
143
180
|
: cleaned;
|
|
144
181
|
const endIdx = afterBegin.indexOf(endMarker);
|
|
145
182
|
const between = endIdx >= 0 ? afterBegin.slice(0, endIdx) : afterBegin;
|
|
146
|
-
const output = between
|
|
183
|
+
const output = trimNewlines(between);
|
|
147
184
|
if (reportedCwd)
|
|
148
185
|
this.cwd = reportedCwd;
|
|
149
186
|
this.touch();
|
|
@@ -205,7 +242,7 @@ export class InteractiveSession extends Session {
|
|
|
205
242
|
});
|
|
206
243
|
}
|
|
207
244
|
generateMarker() {
|
|
208
|
-
return
|
|
245
|
+
return generateSessionMarker();
|
|
209
246
|
}
|
|
210
247
|
async close() {
|
|
211
248
|
try {
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "ssh-mcp",
|
|
3
3
|
"license": "MIT",
|
|
4
|
-
"version": "2.0.
|
|
4
|
+
"version": "2.0.2",
|
|
5
5
|
"description": "MCP server exposing policy-gated, audited SSH access for Linux and Windows hosts via the Model Context Protocol.",
|
|
6
6
|
"type": "module",
|
|
7
7
|
"bin": {
|
|
@@ -43,6 +43,7 @@
|
|
|
43
43
|
"@changesets/cli": "^2.31.0",
|
|
44
44
|
"@types/node": "^24.5.2",
|
|
45
45
|
"@types/ssh2": "^1.15.5",
|
|
46
|
+
"@vitest/coverage-v8": "^3.2.7",
|
|
46
47
|
"cross-env": "^7.0.3",
|
|
47
48
|
"fast-check": "^3.23.2",
|
|
48
49
|
"shx": "^0.4.0",
|