ssh-mcp 2.2.3 → 2.2.5
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/build/policy/classifier.js +130 -7
- package/package.json +1 -1
|
@@ -19,7 +19,7 @@ const SHELL_CONTROL_CHARS = /[;&|<>`$(){}\n\r]/;
|
|
|
19
19
|
const READ_ONLY_ALLOWLIST = new Set([
|
|
20
20
|
'ls', 'cat', 'grep', 'find', 'stat', 'df', 'du', 'head', 'tail', 'wc',
|
|
21
21
|
'ps', 'uname', 'uptime', 'hostname', 'id', 'who', 'whoami', 'date',
|
|
22
|
-
'
|
|
22
|
+
'printenv', 'pwd', 'echo', 'printf', 'test', 'true', 'false',
|
|
23
23
|
'which', 'whereis', 'file', 'readlink', 'realpath', 'basename', 'dirname',
|
|
24
24
|
'seq', 'sort', 'uniq', 'cut', 'tr', 'diff', 'comm',
|
|
25
25
|
'systemctl status', 'journalctl', 'docker ps', 'docker logs', 'docker inspect',
|
|
@@ -28,6 +28,15 @@ const READ_ONLY_ALLOWLIST = new Set([
|
|
|
28
28
|
'host', 'ping', 'traceroute', 'git status', 'git log',
|
|
29
29
|
'git diff', 'git branch', 'git show', 'git remote',
|
|
30
30
|
]);
|
|
31
|
+
// Deliberately NOT read-only: `env`, because it is an exec wrapper. `env <cmd>`
|
|
32
|
+
// runs <cmd>, so allowlisting the name `env` vouched for a command the
|
|
33
|
+
// classifier never looked at — `env sudo rm -f /etc/passwd` classified
|
|
34
|
+
// `read-only` and ran on a profile whose whole contract is that it cannot
|
|
35
|
+
// write. It carries no shell metacharacter, so the SHELL_CONTROL_CHARS gate
|
|
36
|
+
// below did not catch it either. Falls through to `safe`, so run-command can
|
|
37
|
+
// still reach it under policy; a bare `env` that only prints the environment
|
|
38
|
+
// loses read-only status with it, which is the price of a name-based allowlist.
|
|
39
|
+
//
|
|
31
40
|
// Deliberately NOT read-only: `curl` and `wget` fetch arbitrary URLs (SSRF to
|
|
32
41
|
// cloud metadata / internal services), post local files to a remote host
|
|
33
42
|
// (`curl -d @/etc/passwd`), and write remote files (`curl -o`, `wget -O`) —
|
|
@@ -98,7 +107,71 @@ const FORBIDDEN_PATTERNS = [
|
|
|
98
107
|
const FORBIDDEN_INVOCATIONS = new Set(['shutdown', 'reboot', 'halt', 'poweroff', 'eval']);
|
|
99
108
|
/** Binaries that take the dangerous action as an argument: `systemctl reboot`. */
|
|
100
109
|
const ACTION_MULTIPLEXERS = new Set(['systemctl', 'init', 'telinit']);
|
|
101
|
-
|
|
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
|
+
]);
|
|
131
|
+
/**
|
|
132
|
+
* Binaries whose job is to run another command.
|
|
133
|
+
*
|
|
134
|
+
* The allowlist and the privilege check both name a binary, so a wrapper hides
|
|
135
|
+
* whatever it wraps: `env sudo id` was classified by the name `env`. These are
|
|
136
|
+
* stepped over when deciding whether a segment elevates, so the check sees the
|
|
137
|
+
* command that will actually run.
|
|
138
|
+
*
|
|
139
|
+
* `env` is the one that was also allowlisted, and it has been removed from
|
|
140
|
+
* READ_ONLY_ALLOWLIST above. The rest were never read-only, so before this they
|
|
141
|
+
* hid elevation rather than granting it — `nohup sudo systemctl stop nginx`
|
|
142
|
+
* classified `safe`, which the default bindings grant to admin and operator on
|
|
143
|
+
* every tier.
|
|
144
|
+
*/
|
|
145
|
+
const EXEC_WRAPPERS = new Set([
|
|
146
|
+
'env', 'nohup', 'nice', 'ionice', 'command', 'exec', 'setsid', 'stdbuf',
|
|
147
|
+
'timeout', 'chrt', 'taskset', 'xargs', 'watch',
|
|
148
|
+
]);
|
|
149
|
+
/**
|
|
150
|
+
* Arguments that turn an allowlisted binary into one that writes or executes.
|
|
151
|
+
*
|
|
152
|
+
* The allowlist vouches for a name; these are the flags that make the name a
|
|
153
|
+
* lie. `find /var/www -delete` removes a directory tree and `find / -exec sudo
|
|
154
|
+
* id +` runs a command as root, and both classified `read-only` — the `-exec …
|
|
155
|
+
* \;` form only escaped because `;` happens to be a shell metacharacter, while
|
|
156
|
+
* the `+` terminator carries none.
|
|
157
|
+
*/
|
|
158
|
+
const DISQUALIFYING_ARGS = {
|
|
159
|
+
find: /^-(exec|execdir|ok|okdir|delete|fprintf?|fls)$/,
|
|
160
|
+
};
|
|
161
|
+
/** A leading `NAME=value`, which a shell treats as an assignment, not a command. */
|
|
162
|
+
const ASSIGNMENT = /^[A-Za-z_][A-Za-z0-9_]*=/;
|
|
163
|
+
/** `timeout 5s`, `nice 10` — a bare argument some wrappers take before the command. */
|
|
164
|
+
const BARE_NUMERIC = /^\d+(\.\d+)?[smhd]?$/;
|
|
165
|
+
/**
|
|
166
|
+
* Remove the quoting a shell would remove before looking up a command.
|
|
167
|
+
*
|
|
168
|
+
* `\sudo`, `'sudo'` and `"sudo"` all execute sudo — the backslash only
|
|
169
|
+
* suppresses alias expansion — but a verbatim string comparison sees three
|
|
170
|
+
* different words. Without this, a one-character edit walks around the check.
|
|
171
|
+
*/
|
|
172
|
+
function unquote(word) {
|
|
173
|
+
return word.replace(/^(['"])(.*)\1$/, '$2').replace(/\\(.)/g, '$1');
|
|
174
|
+
}
|
|
102
175
|
/**
|
|
103
176
|
* Privilege-prefix flags that consume the next argument, so `sudo -u root
|
|
104
177
|
* reboot` is not read as invoking `root`. Enumerable because it is one tool's
|
|
@@ -134,6 +207,57 @@ function parseSegments(command) {
|
|
|
134
207
|
}
|
|
135
208
|
return segments;
|
|
136
209
|
}
|
|
210
|
+
/**
|
|
211
|
+
* Whether a segment asks for elevation, reading past anything that is not yet
|
|
212
|
+
* the command.
|
|
213
|
+
*
|
|
214
|
+
* A shell resolves the command word after assignments, and a wrapper runs what
|
|
215
|
+
* follows it, so all of these execute sudo while naming something else first:
|
|
216
|
+
*
|
|
217
|
+
* env sudo id nohup sudo id timeout 5 sudo id
|
|
218
|
+
* FOO=1 sudo id nice -n 10 sudo id command sudo id
|
|
219
|
+
*
|
|
220
|
+
* The scan walks left to right and stops at the first word that is a real
|
|
221
|
+
* command. Reaching a privilege prefix before that is elevation; reaching
|
|
222
|
+
* anything else is not, which is what keeps `grep sudo /var/log/auth.log` a
|
|
223
|
+
* mention rather than an invocation.
|
|
224
|
+
*/
|
|
225
|
+
function segmentElevates(words) {
|
|
226
|
+
for (const raw of words) {
|
|
227
|
+
const word = stripPath(unquote(raw));
|
|
228
|
+
if (PRIVILEGE_PREFIXES.has(word))
|
|
229
|
+
return true;
|
|
230
|
+
if (ASSIGNMENT.test(raw))
|
|
231
|
+
continue;
|
|
232
|
+
if (EXEC_WRAPPERS.has(word))
|
|
233
|
+
continue;
|
|
234
|
+
if (raw.startsWith('-'))
|
|
235
|
+
continue;
|
|
236
|
+
if (BARE_NUMERIC.test(raw))
|
|
237
|
+
continue;
|
|
238
|
+
return false;
|
|
239
|
+
}
|
|
240
|
+
return false;
|
|
241
|
+
}
|
|
242
|
+
/**
|
|
243
|
+
* Does this command elevate anywhere a shell would act on it?
|
|
244
|
+
*
|
|
245
|
+
* Replaces four `^`-anchored regexes that only saw a *leading* prefix. Elevation
|
|
246
|
+
* behind a wrapper, behind an assignment, or after a separator all reached root
|
|
247
|
+
* with the command classified `safe` or `read-only`.
|
|
248
|
+
*/
|
|
249
|
+
function elevates(command) {
|
|
250
|
+
return command
|
|
251
|
+
.split(/[;&|\n]/)
|
|
252
|
+
.some((segment) => segmentElevates(segment.trim().split(/\s+/).filter(Boolean)));
|
|
253
|
+
}
|
|
254
|
+
/** An allowlisted binary carrying a flag that makes it write or execute. */
|
|
255
|
+
function hasDisqualifyingArgs(command) {
|
|
256
|
+
return parseSegments(command).some(({ head, args }) => {
|
|
257
|
+
const rule = DISQUALIFYING_ARGS[head];
|
|
258
|
+
return rule !== undefined && args.some((arg) => rule.test(arg));
|
|
259
|
+
});
|
|
260
|
+
}
|
|
137
261
|
function invokedWords(command) {
|
|
138
262
|
const invoked = [];
|
|
139
263
|
for (const { head, args } of parseSegments(command)) {
|
|
@@ -230,7 +354,7 @@ const DESTRUCTIVE_PATTERNS = [
|
|
|
230
354
|
function isDestructive(command) {
|
|
231
355
|
return isForbidden(command) || DESTRUCTIVE_PATTERNS.some((re) => re.test(command));
|
|
232
356
|
}
|
|
233
|
-
const
|
|
357
|
+
const LEADING_PRIVILEGE_PREFIXES = [
|
|
234
358
|
/^\s*sudo\b/,
|
|
235
359
|
/^\s*su\b/,
|
|
236
360
|
/^\s*doas\b/,
|
|
@@ -238,7 +362,7 @@ const PRIVILEGED_INDICATORS = [
|
|
|
238
362
|
];
|
|
239
363
|
export function extractBinary(command) {
|
|
240
364
|
let cmd = command.trim();
|
|
241
|
-
for (const prefix of
|
|
365
|
+
for (const prefix of LEADING_PRIVILEGE_PREFIXES) {
|
|
242
366
|
cmd = cmd.replace(prefix, '').trim();
|
|
243
367
|
}
|
|
244
368
|
if (cmd.startsWith('-c ')) {
|
|
@@ -251,11 +375,10 @@ export function classifyCommand(command) {
|
|
|
251
375
|
const trimmed = command.trim();
|
|
252
376
|
const binary = extractBinary(trimmed);
|
|
253
377
|
const fullCommand = trimmed;
|
|
254
|
-
|
|
255
|
-
if (isPrivileged) {
|
|
378
|
+
if (elevates(trimmed)) {
|
|
256
379
|
return { binary, fullCommand, class: 'privileged' };
|
|
257
380
|
}
|
|
258
|
-
if (isDestructive(trimmed)) {
|
|
381
|
+
if (isDestructive(trimmed) || hasDisqualifyingArgs(trimmed)) {
|
|
259
382
|
return { binary, fullCommand, class: 'destructive' };
|
|
260
383
|
}
|
|
261
384
|
const twoWordPrefix = fullCommand.split(/\s+/).slice(0, 2).join(' ');
|
package/package.json
CHANGED