ssh-mcp 2.2.2 → 2.2.4
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 +3 -2
- package/build/config/loader.js +27 -1
- package/build/config/schema.js +6 -2
- package/build/policy/classifier.js +186 -33
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -150,7 +150,7 @@ sessionMaxPerConnection = 5
|
|
|
150
150
|
sessionIdleTimeoutMs = 600000 # 10min
|
|
151
151
|
sessionBackgroundMaxMs = 3600000 # 1hr
|
|
152
152
|
commandTimeoutMs = 60000
|
|
153
|
-
commandMaxChars = 5000
|
|
153
|
+
commandMaxChars = 5000 # 0 = unlimited, the config spelling of --maxChars=none
|
|
154
154
|
commandMaxOutputBytes = 1048576 # 1MB
|
|
155
155
|
connectionIdleReapMs = 900000 # 15min
|
|
156
156
|
commandQuotaPerDay = 0 # 0 = unlimited; circuit breaker for runaway agents
|
|
@@ -177,6 +177,7 @@ cert = false # SSH CA cert auth — auto-detects keyRef-c
|
|
|
177
177
|
sessionMaxPerConnection = 3 # per-profile override
|
|
178
178
|
sessionIdleTimeoutMs = 300000 # stricter for prod
|
|
179
179
|
commandQuotaPerDay = 200 # per-profile override
|
|
180
|
+
maxChars = 2000 # per-profile override; stricter for prod
|
|
180
181
|
|
|
181
182
|
# Optional. Merged over the built-in role matrix; see "Policy Engine" below.
|
|
182
183
|
# roleBindings is keyed by role and then by tier, so the block below changes
|
|
@@ -554,7 +555,7 @@ Secrets are **never** passed as CLI arguments.
|
|
|
554
555
|
| `--workdir` | — | Quick start: Working directory for commands and sessions |
|
|
555
556
|
| `--group` | prod | Quick start: Policy tier — `prod`, `staging` or `dev` |
|
|
556
557
|
| `--timeout` | 60000 | Command timeout in ms |
|
|
557
|
-
| `--maxChars` | 5000 | Max command length (`none` or `0` disables the limit) |
|
|
558
|
+
| `--maxChars` | 5000 | Max command length (`none` or `0` disables the limit; in a config file the same setting is `commandMaxChars = 0`) |
|
|
558
559
|
| `--sessionMax` | 5 | Max concurrent sessions per connection |
|
|
559
560
|
| `--sessionTtl` | 600000 | Session idle timeout in ms |
|
|
560
561
|
| `--transport` | stdio | `stdio` or `http` |
|
package/build/config/loader.js
CHANGED
|
@@ -67,6 +67,22 @@ export async function loadConfig(customPath) {
|
|
|
67
67
|
}
|
|
68
68
|
return normalizeConfig(result.data);
|
|
69
69
|
}
|
|
70
|
+
/**
|
|
71
|
+
* `0` means unlimited, the convention `commandQuotaPerDay` and
|
|
72
|
+
* `approvalGrantTtlMs` already use in this config file.
|
|
73
|
+
*
|
|
74
|
+
* It has to be *mapped* rather than merely permitted. `sanitizeCommand` tests
|
|
75
|
+
* `cleaned.length > maxChars`, so a literal `0` arriving there rejects every
|
|
76
|
+
* non-empty command with "Command is too long (max 0 characters)" — a worse
|
|
77
|
+
* failure than the one this spelling exists to fix.
|
|
78
|
+
*
|
|
79
|
+
* MAX_SAFE_INTEGER is the value `parseMaxChars` produces for `--maxChars=none`,
|
|
80
|
+
* so the flag and the config file hand the rest of the code an identical
|
|
81
|
+
* Profile rather than two spellings of uncapped (#123).
|
|
82
|
+
*/
|
|
83
|
+
function uncapZero(maxChars) {
|
|
84
|
+
return maxChars === 0 ? Number.MAX_SAFE_INTEGER : maxChars;
|
|
85
|
+
}
|
|
70
86
|
/**
|
|
71
87
|
* Resolve every profile against [defaults]. Each of these keys is documented as
|
|
72
88
|
* a default that profiles inherit, so all of them must cascade — a key that is
|
|
@@ -75,13 +91,23 @@ export async function loadConfig(customPath) {
|
|
|
75
91
|
* downgrade, not just a papercut).
|
|
76
92
|
*/
|
|
77
93
|
function normalizeConfig(raw) {
|
|
94
|
+
// commandMaxChars is mapped here as well as on the profile below, so no `0`
|
|
95
|
+
// survives anywhere in AppConfig. Leaving the raw value on `defaults` left one
|
|
96
|
+
// object carrying two encodings of "unlimited" — `profile.maxChars` as
|
|
97
|
+
// MAX_SAFE_INTEGER, `defaults.commandMaxChars` as 0 — with nothing in the
|
|
98
|
+
// types to tell them apart. index.ts:161 already copies straight across
|
|
99
|
+
// (`maxChars: defaults.commandMaxChars`), and was correct only because that
|
|
100
|
+
// path's value comes from parseMaxChars, which never returns 0. Correct by
|
|
101
|
+
// coincidence of another surface is not a property to leave standing when the
|
|
102
|
+
// wrong encoding rejects every non-empty command.
|
|
78
103
|
const defaults = {
|
|
79
104
|
...raw.defaults,
|
|
105
|
+
commandMaxChars: uncapZero(raw.defaults.commandMaxChars),
|
|
80
106
|
};
|
|
81
107
|
const profiles = raw.profiles.map((p) => ({
|
|
82
108
|
...p,
|
|
83
109
|
timeout: p.timeout ?? defaults.commandTimeoutMs,
|
|
84
|
-
maxChars: p.maxChars ?? defaults.commandMaxChars,
|
|
110
|
+
maxChars: uncapZero(p.maxChars ?? defaults.commandMaxChars),
|
|
85
111
|
maxOutputBytes: p.maxOutputBytes ?? defaults.commandMaxOutputBytes,
|
|
86
112
|
approvalPolicy: p.approvalPolicy ?? defaults.approvalMode,
|
|
87
113
|
sessionMaxPerConnection: p.sessionMaxPerConnection ?? defaults.sessionMaxPerConnection,
|
package/build/config/schema.js
CHANGED
|
@@ -18,7 +18,10 @@ export const defaultsSchema = z.object({
|
|
|
18
18
|
sessionIdleTimeoutMs: z.number().int().positive().default(600_000),
|
|
19
19
|
sessionBackgroundMaxMs: z.number().int().positive().default(3_600_000),
|
|
20
20
|
commandTimeoutMs: z.number().int().positive().default(60_000),
|
|
21
|
-
|
|
21
|
+
// 0 = unlimited, matching `--maxChars=none` on the CLI. normalizeConfig()
|
|
22
|
+
// maps it to Number.MAX_SAFE_INTEGER so both surfaces hand the rest of the
|
|
23
|
+
// code an identical Profile.
|
|
24
|
+
commandMaxChars: z.number().int().nonnegative().default(5000),
|
|
22
25
|
commandMaxOutputBytes: z.number().int().positive().default(1_048_576),
|
|
23
26
|
connectionIdleReapMs: z.number().int().positive().default(900_000),
|
|
24
27
|
// 0 = unlimited. A circuit breaker for runaway agents, not a rate limit.
|
|
@@ -50,7 +53,8 @@ export const profileSchema = z.object({
|
|
|
50
53
|
// a schema-level .default() would make "user omitted it" indistinguishable
|
|
51
54
|
// from "user set the default value", silently shadowing [defaults].
|
|
52
55
|
timeout: z.number().int().positive().optional(),
|
|
53
|
-
|
|
56
|
+
// 0 = unlimited, as in [defaults].commandMaxChars above.
|
|
57
|
+
maxChars: z.number().int().nonnegative().optional(),
|
|
54
58
|
maxOutputBytes: z.number().int().positive().optional(),
|
|
55
59
|
approvalPolicy: approvalModeSchema.optional(),
|
|
56
60
|
sessionMaxPerConnection: z.number().int().positive().optional(),
|
|
@@ -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`) —
|
|
@@ -47,28 +56,38 @@ const READ_ONLY_ALLOWLIST = new Set([
|
|
|
47
56
|
* purposes; see DESTRUCTIVE_DENYLIST below.
|
|
48
57
|
*/
|
|
49
58
|
/*
|
|
50
|
-
*
|
|
51
|
-
*
|
|
52
|
-
*
|
|
53
|
-
*
|
|
54
|
-
*
|
|
55
|
-
* that
|
|
56
|
-
*
|
|
59
|
+
* These are the patterns that stayed regexes, and every one of them is linear.
|
|
60
|
+
*
|
|
61
|
+
* The four that were not — `dd\s.*\bof=/dev/`, the two `curl|wget …\|\s*(sh…)`
|
|
62
|
+
* forms, and `chown\s+-R\s.*\s/\s*$` — are now segment checks below. An earlier
|
|
63
|
+
* comment here reasoned that ambiguity *within* a match was the only cost, and
|
|
64
|
+
* that was wrong: the engine also restarts at every offset where the cheap
|
|
65
|
+
* literal head matches, so a command built from `dd curl wget chown -R x `
|
|
66
|
+
* repeated ran at 4x per doubling. Measured on the real chain: 64 KB took
|
|
67
|
+
* 255 ms, 1 MB took 65 seconds of blocked event loop, and the stall lands in
|
|
68
|
+
* classifyCommand — before the approval gate and before the allow/deny
|
|
69
|
+
* decision, so no role, approval mode or readOnly flag protects against it.
|
|
70
|
+
*
|
|
71
|
+
* The old note said sanitizeCommand's maxChars cap kept this cheap, "but a
|
|
72
|
+
* policy check should not depend on a limit set three layers away and
|
|
73
|
+
* configurable to any value". Once a config file could say `commandMaxChars = 0`
|
|
74
|
+
* (#123) that limit went away, and the prediction came true. The rewrite below
|
|
75
|
+
* is what that sentence was asking for: the policy check is now safe on its own
|
|
76
|
+
* terms, whatever maxChars says.
|
|
77
|
+
*
|
|
78
|
+
* The existing cost test did not catch it because its seeds are runs of spaces,
|
|
79
|
+
* which never match the literal heads and so never trigger the restart.
|
|
57
80
|
*/
|
|
58
81
|
const FORBIDDEN_PATTERNS = [
|
|
59
82
|
/rm\s+-rf?\s+\/(\s|$)/, // rm -rf / — the filesystem root itself
|
|
60
83
|
/mkfs\./,
|
|
61
|
-
/dd\s.*\bof=\/dev\//,
|
|
62
84
|
/>\s*\/dev\/sd/,
|
|
63
85
|
/:\(\)\s*\{\s*:\|:\&\s*\}\s*;\s*:/, // fork bomb
|
|
64
|
-
/curl\s[^|]*\|\s*(sh|bash|zsh)/,
|
|
65
|
-
/wget\s[^|]*\|\s*(sh|bash|zsh)/,
|
|
66
86
|
/>\s*\/etc\/cron/,
|
|
67
87
|
/>\s*\/etc\/systemd/,
|
|
68
88
|
/>\s*~\/.ssh\/authorized_keys/,
|
|
69
89
|
/\biptables\s+-F\b/,
|
|
70
90
|
/\bchmod\s+-R\s+777\s+\//,
|
|
71
|
-
/\bchown\s+-R\s.*\s\/\s*$/,
|
|
72
91
|
];
|
|
73
92
|
/**
|
|
74
93
|
* Words that are forbidden when *invoked*, which the patterns above cannot
|
|
@@ -89,6 +108,50 @@ const FORBIDDEN_INVOCATIONS = new Set(['shutdown', 'reboot', 'halt', 'poweroff',
|
|
|
89
108
|
/** Binaries that take the dangerous action as an argument: `systemctl reboot`. */
|
|
90
109
|
const ACTION_MULTIPLEXERS = new Set(['systemctl', 'init', 'telinit']);
|
|
91
110
|
const PRIVILEGE_PREFIXES = new Set(['sudo', 'su', 'doas', 'pkexec']);
|
|
111
|
+
/**
|
|
112
|
+
* Binaries whose job is to run another command.
|
|
113
|
+
*
|
|
114
|
+
* The allowlist and the privilege check both name a binary, so a wrapper hides
|
|
115
|
+
* whatever it wraps: `env sudo id` was classified by the name `env`. These are
|
|
116
|
+
* stepped over when deciding whether a segment elevates, so the check sees the
|
|
117
|
+
* command that will actually run.
|
|
118
|
+
*
|
|
119
|
+
* `env` is the one that was also allowlisted, and it has been removed from
|
|
120
|
+
* READ_ONLY_ALLOWLIST above. The rest were never read-only, so before this they
|
|
121
|
+
* hid elevation rather than granting it — `nohup sudo systemctl stop nginx`
|
|
122
|
+
* classified `safe`, which the default bindings grant to admin and operator on
|
|
123
|
+
* every tier.
|
|
124
|
+
*/
|
|
125
|
+
const EXEC_WRAPPERS = new Set([
|
|
126
|
+
'env', 'nohup', 'nice', 'ionice', 'command', 'exec', 'setsid', 'stdbuf',
|
|
127
|
+
'timeout', 'chrt', 'taskset', 'xargs', 'watch',
|
|
128
|
+
]);
|
|
129
|
+
/**
|
|
130
|
+
* Arguments that turn an allowlisted binary into one that writes or executes.
|
|
131
|
+
*
|
|
132
|
+
* The allowlist vouches for a name; these are the flags that make the name a
|
|
133
|
+
* lie. `find /var/www -delete` removes a directory tree and `find / -exec sudo
|
|
134
|
+
* id +` runs a command as root, and both classified `read-only` — the `-exec …
|
|
135
|
+
* \;` form only escaped because `;` happens to be a shell metacharacter, while
|
|
136
|
+
* the `+` terminator carries none.
|
|
137
|
+
*/
|
|
138
|
+
const DISQUALIFYING_ARGS = {
|
|
139
|
+
find: /^-(exec|execdir|ok|okdir|delete|fprintf?|fls)$/,
|
|
140
|
+
};
|
|
141
|
+
/** A leading `NAME=value`, which a shell treats as an assignment, not a command. */
|
|
142
|
+
const ASSIGNMENT = /^[A-Za-z_][A-Za-z0-9_]*=/;
|
|
143
|
+
/** `timeout 5s`, `nice 10` — a bare argument some wrappers take before the command. */
|
|
144
|
+
const BARE_NUMERIC = /^\d+(\.\d+)?[smhd]?$/;
|
|
145
|
+
/**
|
|
146
|
+
* Remove the quoting a shell would remove before looking up a command.
|
|
147
|
+
*
|
|
148
|
+
* `\sudo`, `'sudo'` and `"sudo"` all execute sudo — the backslash only
|
|
149
|
+
* suppresses alias expansion — but a verbatim string comparison sees three
|
|
150
|
+
* different words. Without this, a one-character edit walks around the check.
|
|
151
|
+
*/
|
|
152
|
+
function unquote(word) {
|
|
153
|
+
return word.replace(/^(['"])(.*)\1$/, '$2').replace(/\\(.)/g, '$1');
|
|
154
|
+
}
|
|
92
155
|
/**
|
|
93
156
|
* Privilege-prefix flags that consume the next argument, so `sudo -u root
|
|
94
157
|
* reboot` is not read as invoking `root`. Enumerable because it is one tool's
|
|
@@ -104,20 +167,12 @@ function stripPath(word) {
|
|
|
104
167
|
const slash = word.lastIndexOf('/');
|
|
105
168
|
return slash === -1 ? word : word.slice(slash + 1);
|
|
106
169
|
}
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
*
|
|
112
|
-
* Pure string work: split, trim, set lookups. No quantifiers, so nothing here
|
|
113
|
-
* can backtrack.
|
|
114
|
-
*/
|
|
115
|
-
function invokedWords(command) {
|
|
116
|
-
const invoked = [];
|
|
117
|
-
for (const segment of command.split(/[;&|\n]/)) {
|
|
118
|
-
const words = segment.trim().split(/\s+/).filter(Boolean).map(stripPath);
|
|
170
|
+
function parseSegments(command) {
|
|
171
|
+
const segments = [];
|
|
172
|
+
for (const raw of command.split(/[;&|\n]/)) {
|
|
173
|
+
const words = raw.trim().split(/\s+/).filter(Boolean);
|
|
119
174
|
let i = 0;
|
|
120
|
-
while (i < words.length && PRIVILEGE_PREFIXES.has(words[i])) {
|
|
175
|
+
while (i < words.length && PRIVILEGE_PREFIXES.has(stripPath(words[i]))) {
|
|
121
176
|
i++;
|
|
122
177
|
while (i < words.length && words[i].startsWith('-')) {
|
|
123
178
|
const consumesValue = PREFIX_VALUE_FLAGS.has(words[i]);
|
|
@@ -126,25 +181,124 @@ function invokedWords(command) {
|
|
|
126
181
|
i++;
|
|
127
182
|
}
|
|
128
183
|
}
|
|
129
|
-
|
|
130
|
-
|
|
184
|
+
if (i < words.length) {
|
|
185
|
+
segments.push({ head: stripPath(words[i]), args: words.slice(i + 1) });
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
return segments;
|
|
189
|
+
}
|
|
190
|
+
/**
|
|
191
|
+
* Whether a segment asks for elevation, reading past anything that is not yet
|
|
192
|
+
* the command.
|
|
193
|
+
*
|
|
194
|
+
* A shell resolves the command word after assignments, and a wrapper runs what
|
|
195
|
+
* follows it, so all of these execute sudo while naming something else first:
|
|
196
|
+
*
|
|
197
|
+
* env sudo id nohup sudo id timeout 5 sudo id
|
|
198
|
+
* FOO=1 sudo id nice -n 10 sudo id command sudo id
|
|
199
|
+
*
|
|
200
|
+
* The scan walks left to right and stops at the first word that is a real
|
|
201
|
+
* command. Reaching a privilege prefix before that is elevation; reaching
|
|
202
|
+
* anything else is not, which is what keeps `grep sudo /var/log/auth.log` a
|
|
203
|
+
* mention rather than an invocation.
|
|
204
|
+
*/
|
|
205
|
+
function segmentElevates(words) {
|
|
206
|
+
for (const raw of words) {
|
|
207
|
+
const word = stripPath(unquote(raw));
|
|
208
|
+
if (PRIVILEGE_PREFIXES.has(word))
|
|
209
|
+
return true;
|
|
210
|
+
if (ASSIGNMENT.test(raw))
|
|
211
|
+
continue;
|
|
212
|
+
if (EXEC_WRAPPERS.has(word))
|
|
131
213
|
continue;
|
|
214
|
+
if (raw.startsWith('-'))
|
|
215
|
+
continue;
|
|
216
|
+
if (BARE_NUMERIC.test(raw))
|
|
217
|
+
continue;
|
|
218
|
+
return false;
|
|
219
|
+
}
|
|
220
|
+
return false;
|
|
221
|
+
}
|
|
222
|
+
/**
|
|
223
|
+
* Does this command elevate anywhere a shell would act on it?
|
|
224
|
+
*
|
|
225
|
+
* Replaces four `^`-anchored regexes that only saw a *leading* prefix. Elevation
|
|
226
|
+
* behind a wrapper, behind an assignment, or after a separator all reached root
|
|
227
|
+
* with the command classified `safe` or `read-only`.
|
|
228
|
+
*/
|
|
229
|
+
function elevates(command) {
|
|
230
|
+
return command
|
|
231
|
+
.split(/[;&|\n]/)
|
|
232
|
+
.some((segment) => segmentElevates(segment.trim().split(/\s+/).filter(Boolean)));
|
|
233
|
+
}
|
|
234
|
+
/** An allowlisted binary carrying a flag that makes it write or execute. */
|
|
235
|
+
function hasDisqualifyingArgs(command) {
|
|
236
|
+
return parseSegments(command).some(({ head, args }) => {
|
|
237
|
+
const rule = DISQUALIFYING_ARGS[head];
|
|
238
|
+
return rule !== undefined && args.some((arg) => rule.test(arg));
|
|
239
|
+
});
|
|
240
|
+
}
|
|
241
|
+
function invokedWords(command) {
|
|
242
|
+
const invoked = [];
|
|
243
|
+
for (const { head, args } of parseSegments(command)) {
|
|
132
244
|
invoked.push(head);
|
|
133
245
|
// `systemctl reboot` restarts the host. Reading a unit that happens to be
|
|
134
246
|
// named after a power action is rare enough that erring towards refusal
|
|
135
247
|
// here costs little.
|
|
136
248
|
if (ACTION_MULTIPLEXERS.has(head)) {
|
|
137
|
-
invoked.push(...
|
|
249
|
+
invoked.push(...args.filter((w) => !w.startsWith('-')).map(stripPath));
|
|
138
250
|
}
|
|
139
251
|
}
|
|
140
252
|
return invoked;
|
|
141
253
|
}
|
|
254
|
+
const SHELLS = new Set(['sh', 'bash', 'zsh']);
|
|
255
|
+
const DOWNLOADERS = new Set(['curl', 'wget']);
|
|
256
|
+
/**
|
|
257
|
+
* A download piped into a shell.
|
|
258
|
+
*
|
|
259
|
+
* Split on `|` rather than reading the whole string, so cost is linear in the
|
|
260
|
+
* command's length no matter how the two halves are spaced. `||` produces an
|
|
261
|
+
* empty part between them, and this deliberately still matches: `curl x || sh`
|
|
262
|
+
* runs a shell when the download fails, which is not meaningfully safer than
|
|
263
|
+
* running one when it succeeds.
|
|
264
|
+
*/
|
|
265
|
+
function pipesDownloadIntoShell(command) {
|
|
266
|
+
const heads = command.split('|').map((part) => parseSegments(part)[0]?.head);
|
|
267
|
+
const firstDownload = heads.findIndex((h) => h !== undefined && DOWNLOADERS.has(h));
|
|
268
|
+
if (firstDownload === -1)
|
|
269
|
+
return false;
|
|
270
|
+
return heads.slice(firstDownload + 1).some((h) => h !== undefined && SHELLS.has(h));
|
|
271
|
+
}
|
|
272
|
+
/** `dd … of=/dev/sda` — writing an image straight onto a block device. */
|
|
273
|
+
function writesToDevice(command) {
|
|
274
|
+
return parseSegments(command).some(({ head, args }) => head === 'dd' && args.some((a) => a.startsWith('of=/dev/')));
|
|
275
|
+
}
|
|
276
|
+
/**
|
|
277
|
+
* `chown -R … /` — a recursive chown whose target is the filesystem root.
|
|
278
|
+
*
|
|
279
|
+
* The last non-flag argument is the target; `chown -R app:app /srv/app` is
|
|
280
|
+
* ordinary and stays allowed.
|
|
281
|
+
*/
|
|
282
|
+
function chownsRoot(command) {
|
|
283
|
+
return parseSegments(command).some(({ head, args }) => {
|
|
284
|
+
if (head !== 'chown' || !args.includes('-R'))
|
|
285
|
+
return false;
|
|
286
|
+
const positional = args.filter((a) => !a.startsWith('-'));
|
|
287
|
+
return positional[positional.length - 1] === '/';
|
|
288
|
+
});
|
|
289
|
+
}
|
|
142
290
|
const FORBIDDEN_RULES = [
|
|
143
291
|
...FORBIDDEN_PATTERNS.map((re) => ({ label: String(re), test: (c) => re.test(c) })),
|
|
144
292
|
{
|
|
145
293
|
label: 'invoking a power-state command (shutdown, reboot, halt, poweroff) or eval',
|
|
146
294
|
test: (command) => invokedWords(command).some((w) => FORBIDDEN_INVOCATIONS.has(w)),
|
|
147
295
|
},
|
|
296
|
+
{
|
|
297
|
+
label: 'piping a download into a shell (curl or wget into sh, bash or zsh)',
|
|
298
|
+
test: pipesDownloadIntoShell,
|
|
299
|
+
},
|
|
300
|
+
{ label: 'dd writing to a block device (of=/dev/…)', test: writesToDevice },
|
|
301
|
+
{ label: 'a recursive chown of the filesystem root', test: chownsRoot },
|
|
148
302
|
];
|
|
149
303
|
/**
|
|
150
304
|
* Which never-allowed rule this command trips, or null. The single entry point:
|
|
@@ -180,7 +334,7 @@ const DESTRUCTIVE_PATTERNS = [
|
|
|
180
334
|
function isDestructive(command) {
|
|
181
335
|
return isForbidden(command) || DESTRUCTIVE_PATTERNS.some((re) => re.test(command));
|
|
182
336
|
}
|
|
183
|
-
const
|
|
337
|
+
const LEADING_PRIVILEGE_PREFIXES = [
|
|
184
338
|
/^\s*sudo\b/,
|
|
185
339
|
/^\s*su\b/,
|
|
186
340
|
/^\s*doas\b/,
|
|
@@ -188,7 +342,7 @@ const PRIVILEGED_INDICATORS = [
|
|
|
188
342
|
];
|
|
189
343
|
export function extractBinary(command) {
|
|
190
344
|
let cmd = command.trim();
|
|
191
|
-
for (const prefix of
|
|
345
|
+
for (const prefix of LEADING_PRIVILEGE_PREFIXES) {
|
|
192
346
|
cmd = cmd.replace(prefix, '').trim();
|
|
193
347
|
}
|
|
194
348
|
if (cmd.startsWith('-c ')) {
|
|
@@ -201,11 +355,10 @@ export function classifyCommand(command) {
|
|
|
201
355
|
const trimmed = command.trim();
|
|
202
356
|
const binary = extractBinary(trimmed);
|
|
203
357
|
const fullCommand = trimmed;
|
|
204
|
-
|
|
205
|
-
if (isPrivileged) {
|
|
358
|
+
if (elevates(trimmed)) {
|
|
206
359
|
return { binary, fullCommand, class: 'privileged' };
|
|
207
360
|
}
|
|
208
|
-
if (isDestructive(trimmed)) {
|
|
361
|
+
if (isDestructive(trimmed) || hasDisqualifyingArgs(trimmed)) {
|
|
209
362
|
return { binary, fullCommand, class: 'destructive' };
|
|
210
363
|
}
|
|
211
364
|
const twoWordPrefix = fullCommand.split(/\s+/).slice(0, 2).join(' ');
|
package/package.json
CHANGED