ssh-mcp 2.11.0 → 2.13.0
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 +18 -1
- package/build/policy/classifier.js +1022 -82
- package/build/policy/engine.js +42 -20
- package/build/policy/reader-grammar.js +247 -0
- package/build/tools/pipeline.js +10 -1
- package/package.json +1 -1
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { AWK_NAMES, readAwkInvocation } from './awk.js';
|
|
2
|
+
import { matchesGrammar } from './reader-grammar.js';
|
|
2
3
|
/**
|
|
3
4
|
* Anything through which the shell can start a second command.
|
|
4
5
|
*
|
|
@@ -17,18 +18,525 @@ import { AWK_NAMES, readAwkInvocation } from './awk.js';
|
|
|
17
18
|
* write: run-command still accepts it under policy.
|
|
18
19
|
*/
|
|
19
20
|
const SHELL_CONTROL_CHARS = /[;&|<>`$(){}\n\r]/;
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
21
|
+
/**
|
|
22
|
+
* The first word in `command` carrying an unquoted, unescaped shell glob
|
|
23
|
+
* character — `*`, `?` or `[` — or null when no word does.
|
|
24
|
+
*
|
|
25
|
+
* A glob character is not a shell control character (it starts no command),
|
|
26
|
+
* so `SHELL_CONTROL_CHARS` rightly ignores it — but the host shell expands
|
|
27
|
+
* it before the binary sees argv, and the grammar matcher reads dequoted
|
|
28
|
+
* words, where `find -name '*.conf'` and `find -name *.conf` have become the
|
|
29
|
+
* same string. They are not the same command: the quoted word is one operand
|
|
30
|
+
* of data, the unquoted one expands to every matching filename and turns one
|
|
31
|
+
* counted operand into several real ones (`uniq /root/.ssh/id_ed25519*`
|
|
32
|
+
* counted one operand; measured on debian:12, the expansion made the `.pub`
|
|
33
|
+
* uniq's OUTFILE and it was overwritten with the private key). This scan
|
|
34
|
+
* keeps the tokeniser's own quote and escape state, so the judgement happens
|
|
35
|
+
* at the one layer that can still tell the two apart.
|
|
36
|
+
*
|
|
37
|
+
* Deliberately word-scoped and whole-command: a glob inside `$()` or
|
|
38
|
+
* backticks never reaches this helper's caller (those carry
|
|
39
|
+
* SHELL_CONTROL_CHARS and return early), and a glob in the command word
|
|
40
|
+
* itself makes the allowlist lookup miss, so the caller — the allowlist
|
|
41
|
+
* branch, which only runs when the command words matched a READERS key
|
|
42
|
+
* exactly — never sees one either.
|
|
43
|
+
*/
|
|
44
|
+
function firstUnquotedGlobWord(command, honorQuotes = true) {
|
|
45
|
+
let quote = null;
|
|
46
|
+
let escaped = false;
|
|
47
|
+
let word = '';
|
|
48
|
+
let globInWord = false;
|
|
49
|
+
for (const ch of command) {
|
|
50
|
+
if (escaped) {
|
|
51
|
+
// A backslash-quoted glob character is data the shell passes through.
|
|
52
|
+
word += ch;
|
|
53
|
+
escaped = false;
|
|
54
|
+
continue;
|
|
55
|
+
}
|
|
56
|
+
if (quote) {
|
|
57
|
+
// Backslash is literal inside single quotes; inside double quotes it
|
|
58
|
+
// escapes — the same distinction the tokeniser makes.
|
|
59
|
+
if (ch === '\\' && quote === '"') {
|
|
60
|
+
escaped = true;
|
|
61
|
+
continue;
|
|
62
|
+
}
|
|
63
|
+
if (ch === quote)
|
|
64
|
+
quote = null;
|
|
65
|
+
else
|
|
66
|
+
word += ch;
|
|
67
|
+
continue;
|
|
68
|
+
}
|
|
69
|
+
if (ch === '\\') {
|
|
70
|
+
escaped = true;
|
|
71
|
+
continue;
|
|
72
|
+
}
|
|
73
|
+
if (honorQuotes && (ch === '"' || ch === "'")) {
|
|
74
|
+
quote = ch;
|
|
75
|
+
continue;
|
|
76
|
+
}
|
|
77
|
+
if (/\s/.test(ch)) {
|
|
78
|
+
if (globInWord)
|
|
79
|
+
return word;
|
|
80
|
+
word = '';
|
|
81
|
+
globInWord = false;
|
|
82
|
+
continue;
|
|
83
|
+
}
|
|
84
|
+
if (ch === '*' || ch === '?' || ch === '[')
|
|
85
|
+
globInWord = true;
|
|
86
|
+
word += ch;
|
|
87
|
+
}
|
|
88
|
+
// A quote left open at the end is not a command a shell would run, and the
|
|
89
|
+
// tokeniser re-reads such a string with quotes demoted to text (see
|
|
90
|
+
// `tokenizeSegmentsDetailed`). Judge it the same way here: in that reading
|
|
91
|
+
// the glob character is unquoted.
|
|
92
|
+
if (honorQuotes && quote !== null)
|
|
93
|
+
return firstUnquotedGlobWord(command, false);
|
|
94
|
+
return globInWord ? word : null;
|
|
95
|
+
}
|
|
96
|
+
/**
|
|
97
|
+
* The binaries this classifier will vouch for, and what it vouches for about them.
|
|
98
|
+
*
|
|
99
|
+
* Three questions, kept apart because one Set answering all of them is how #217
|
|
100
|
+
* turned the interpreter carrier scan off for two verbs by adding them to a list
|
|
101
|
+
* about classes:
|
|
102
|
+
*
|
|
103
|
+
* readOnly does this binary only read? -> decides the class
|
|
104
|
+
* operandsAreData can its operands hide a command? -> decides whether the
|
|
105
|
+
* carrier scan runs
|
|
106
|
+
* grammar which of its argv words does `matchesGrammar` accept? ->
|
|
107
|
+
* decides `read-only` versus `safe`
|
|
108
|
+
*
|
|
109
|
+
* `readOnly` and `operandsAreData` are `true` for every entry today. The value is
|
|
110
|
+
* not the contents but that the type will not let the next person add a name
|
|
111
|
+
* without answering all three.
|
|
112
|
+
*
|
|
113
|
+
* `grammar` decides only that one boundary, not the class outright: a command
|
|
114
|
+
* whose grammar rejects falls no lower than `safe`, and `DISQUALIFYING_ARGS`
|
|
115
|
+
* still runs before the allowlist branch, so it can still raise a rejected — or
|
|
116
|
+
* an accepted — reader past `safe` (`sort -o /tmp/x /etc/passwd` is
|
|
117
|
+
* `destructive` regardless of what `sort`'s grammar says). An `'any'` grammar
|
|
118
|
+
* accepts every word and carries an `audit` string saying why that trust is
|
|
119
|
+
* believed sound; every other entry lists the flags and operand shape its
|
|
120
|
+
* grammar accepts, and starts with none of either — see `reader-grammar.ts`.
|
|
121
|
+
*
|
|
122
|
+
* Two mechanisms answer the second question outside this table and are not
|
|
123
|
+
* affected by it — `DISQUALIFYING_ARGS` and `FIND_EXEC_FLAGS` — which is why
|
|
124
|
+
* `find` can carry `operandsAreData: true` while `find … -exec sudo id +` is
|
|
125
|
+
* still `privileged`.
|
|
126
|
+
*
|
|
127
|
+
* The two-word entries are looked up only for the class: `operandsAreData` reads
|
|
128
|
+
* a single word, so those rows never reach the second question. Their `grammar`
|
|
129
|
+
* reads the words after the two-word prefix (`classifyOuter` applies the offset).
|
|
130
|
+
*/
|
|
131
|
+
const READERS = {
|
|
132
|
+
// arp: `-a`/`-e`/`-n`/`-v` only display; `-H`/`-t` (hardware type) and `-i`
|
|
133
|
+
// (interface) filter the display, not set anything. Excludes `-d` (deletes an
|
|
134
|
+
// entry), `-s`/`-S` (adds one — BSD man synopsis) and `-f` (loads entries from
|
|
135
|
+
// a file — net-tools arp(8)). Measured: busybox --help + `arp -a`; macOS man
|
|
136
|
+
// arp read; net-tools arp(8) documented only (die.net). Windows 11 build
|
|
137
|
+
// 26200 measurement: `arp.exe` accepts the slash form (`/a` measured),
|
|
138
|
+
// `/d`/`/s` inferred (not run — a state-changing form is never executed to
|
|
139
|
+
// confirm it) as delete/add. A `/X` switch is an operand under this POSIX
|
|
140
|
+
// grammar and cannot be told apart from a path like `/etc`; `each` refuses
|
|
141
|
+
// any single-segment `/word` operand, with or without a trailing slash
|
|
142
|
+
// (see the design's Windows section) — the trailing-slash tolerance of
|
|
143
|
+
// `arp /d/`-style forms cannot be measured without running a state-changing
|
|
144
|
+
// form, so it is refused fail-closed. The cost is that a legitimate
|
|
145
|
+
// slash-form display like `arp /a` also falls to `safe`.
|
|
146
|
+
"arp": { readOnly: true, operandsAreData: true, grammar: { args: 'getopt',
|
|
147
|
+
flags: ['-a', '-e', '-n', '-v'],
|
|
148
|
+
valueFlags: ['-H', '-t', '-i'],
|
|
149
|
+
operands: { max: 1, each: /^(?!\/[^/]*\/?$)/ } } },
|
|
150
|
+
"basename": { readOnly: true, operandsAreData: true, grammar: { args: 'any', audit: 'measured GNU/BSD: pure string manipulation of its argument; no filesystem access at all' } },
|
|
151
|
+
"cat": { readOnly: true, operandsAreData: true, grammar: { args: 'any', audit: "measured GNU/busybox/BSD: writes file contents to stdout; BSD's -l is a transient advisory lock on cat's own stdout fd, not a lasting write, and clears at exit" } },
|
|
152
|
+
"comm": { readOnly: true, operandsAreData: true, grammar: { args: 'any', audit: 'measured GNU/BSD: compares two sorted files and prints the differences; no output-file operand or flag' } },
|
|
153
|
+
"cut": { readOnly: true, operandsAreData: true, grammar: { args: 'any', audit: "measured GNU/BSD: extracts byte/field ranges to stdout; --output-delimiter only changes what's printed, not where" } },
|
|
154
|
+
// date: `-u`/`--utc`/`--universal` and `-R`/`--rfc-2822`/`--rfc-email` only
|
|
155
|
+
// change display format; `-r`/`--reference` reads a file's mtime (GNU) or an
|
|
156
|
+
// epoch value (BSD); `--rfc-3339` picks a display format; `-v` (BSD) only
|
|
157
|
+
// shows the adjusted time ("not actually set", man). `-I`/`--iso-8601` is
|
|
158
|
+
// optional-value (getopt's `::`): it IS listed, in `optionalValueFlags`, so a
|
|
159
|
+
// bare `-I`/`--iso-8601` or an attached form (`-Iseconds`, `--iso-8601=date`)
|
|
160
|
+
// stays read-only — the matcher just never consumes a following SEPARATE
|
|
161
|
+
// word as its value. A bare `+FMT` operand is accepted (`max: 1`, `each:
|
|
162
|
+
// /^\+/`); excludes `-s`/`--set` (sets the clock),
|
|
163
|
+
// `-d`/`--date` (read the same way on GNU/busybox but historically set the
|
|
164
|
+
// kernel DST flag on BSD — man HISTORY / FreeBSD "added and then removed
|
|
165
|
+
// again" — dropped because implementations disagree) and BSD-only
|
|
166
|
+
// `-D`/`-f`/`-j`/`-n`. Measured: GNU d12+trixie --help, busybox (`date -d
|
|
167
|
+
// 2020-01-01` displayed), macOS man; FreeBSD date(1) documented only.
|
|
168
|
+
"date": { readOnly: true, operandsAreData: true, grammar: { args: 'getopt',
|
|
169
|
+
flags: ['-u', '--utc', '--universal', '-R', '--rfc-2822', '--rfc-email'],
|
|
170
|
+
valueFlags: ['-r', '--reference', '--rfc-3339', '-v'],
|
|
171
|
+
optionalValueFlags: ['-I', '--iso-8601'],
|
|
172
|
+
operands: { max: 1, each: /^\+/ } } },
|
|
173
|
+
"df": { readOnly: true, operandsAreData: true, grammar: { args: 'any', audit: 'measured GNU/busybox/BSD: reports space usage read from mounted filesystems; --output= selects columns to print, not a file' } },
|
|
174
|
+
// diff: comparison/output-format flags only, plus value flags that read or
|
|
175
|
+
// match (`-D`/`--ifdef` merges to stdout, not a file). `-C`/`-U` ARE
|
|
176
|
+
// required-value (`valueFlags`) — GNU, BSD and busybox all take the short
|
|
177
|
+
// form with a count, attached or as a separate word; only the long
|
|
178
|
+
// spellings `--context`/`--unified`/`--color` are optional-value
|
|
179
|
+
// (`optionalValueFlags`: bare or `=value`, never a separate word). Moved
|
|
180
|
+
// here from the design round's 'any' guess: `-l`/`--paginate` pipes output
|
|
181
|
+
// through
|
|
182
|
+
// `/usr/bin/pr` — measured on debian:12, removing `/usr/bin/pr` turns it into
|
|
183
|
+
// "subsidiary program '/usr/bin/pr' not found", and it runs the absolute path
|
|
184
|
+
// regardless of PATH, so it is excluded rather than listed. Measured: GNU
|
|
185
|
+
// --help (d12 + trixie 3.10) and the -l behaviour; BSD man (also has -l);
|
|
186
|
+
// busybox --help (no -l).
|
|
187
|
+
"diff": { readOnly: true, operandsAreData: true, grammar: { args: 'getopt',
|
|
188
|
+
flags: ['-q', '--brief', '-s', '--report-identical-files', '-c', '-u', '-e', '--ed', '-n', '--rcs',
|
|
189
|
+
'-y', '--side-by-side', '--left-column', '--suppress-common-lines', '-p', '--show-c-function',
|
|
190
|
+
'-t', '--expand-tabs', '-T', '--initial-tab', '--suppress-blank-empty', '-r', '--recursive',
|
|
191
|
+
'--no-dereference', '-N', '--new-file', '--unidirectional-new-file', '--ignore-file-name-case',
|
|
192
|
+
'--no-ignore-file-name-case', '-i', '--ignore-case', '-E', '--ignore-tab-expansion', '-b',
|
|
193
|
+
'--ignore-space-change', '-w', '--ignore-all-space', '-B', '--ignore-blank-lines', '-a', '--text',
|
|
194
|
+
'--strip-trailing-cr', '-d', '--minimal'],
|
|
195
|
+
valueFlags: ['-C', '-U', '-W', '--width', '--tabsize', '-F', '--show-function-line', '-I',
|
|
196
|
+
'--ignore-matching-lines', '-D', '--ifdef', '--label', '-x', '--exclude', '-X', '--exclude-from',
|
|
197
|
+
'-S', '--starting-file', '--from-file', '--to-file', '--line-format'],
|
|
198
|
+
optionalValueFlags: ['--context', '--unified', '--color'],
|
|
199
|
+
operands: { max: 2 } } },
|
|
200
|
+
"dig": { readOnly: true, operandsAreData: true, grammar: { args: 'any', audit: 'measured BIND 9.10.6 (BSD): sends a DNS query and prints the answer; writes nothing locally (DNS egress tracked separately)' } },
|
|
201
|
+
"dirname": { readOnly: true, operandsAreData: true, grammar: { args: 'any', audit: 'measured GNU/BSD: pure string manipulation of its argument; no filesystem access at all' } },
|
|
202
|
+
"docker images": { readOnly: true, operandsAreData: true, grammar: { args: 'any', audit: 'measured docker CLI 29.7.2 --help: lists local images from the daemon; every option only changes what is listed, no state change' } }, // two-word: class only
|
|
203
|
+
"docker inspect": { readOnly: true, operandsAreData: true, grammar: { args: 'any', audit: "measured docker CLI 29.7.2 --help: prints low-level JSON about an object; no state change" } }, // two-word: class only
|
|
204
|
+
"docker logs": { readOnly: true, operandsAreData: true, grammar: { args: 'any', audit: "measured docker CLI 29.7.2 --help: prints a container's captured output; no state change" } }, // two-word: class only
|
|
205
|
+
"docker ps": { readOnly: true, operandsAreData: true, grammar: { args: 'any', audit: 'measured docker CLI 29.7.2 --help: lists containers; no state change' } }, // two-word: class only
|
|
206
|
+
"docker stats": { readOnly: true, operandsAreData: true, grammar: { args: 'any', audit: 'measured docker CLI 29.7.2 --help: streams live resource-usage numbers; no state change' } }, // two-word: class only
|
|
207
|
+
"du": { readOnly: true, operandsAreData: true, grammar: { args: 'any', audit: 'measured GNU/busybox/BSD: walks a directory tree and reports disk usage; no write mode' } },
|
|
208
|
+
"echo": { readOnly: true, operandsAreData: true, grammar: { args: 'any', audit: 'measured GNU/BSD: writes its arguments to stdout only; a file write needs shell redirection, which the outer gate already refuses' } },
|
|
209
|
+
"false": { readOnly: true, operandsAreData: true, grammar: { args: 'any', audit: 'measured GNU: exits 1 immediately; no I/O of any kind' } },
|
|
210
|
+
// file: display/format flags, plus value flags that name what to read (a list
|
|
211
|
+
// of files, a magic database, an exclusion). Excludes `-C`/`--compile`, which
|
|
212
|
+
// was **measured to write**: on the local macOS file 5.41, `file -C -m
|
|
213
|
+
// /usr/share/file/magic/magic` created `magic.mgc` (2256 bytes) in a
|
|
214
|
+
// throwaway directory. GNU is documented only (file(1), die.net 5.04: "-C …
|
|
215
|
+
// is the only option that creates a file") — GNU file is absent from every
|
|
216
|
+
// measured image (see spec Question 3). Also excludes `-z`/`-Z`
|
|
217
|
+
// (uncompress/uncompress-noreport — audit.md lists them as no-value):
|
|
218
|
+
// libmagic may run an external decompressor program when it isn't built
|
|
219
|
+
// against the matching decompression library (documented, not measured;
|
|
220
|
+
// the contract forbids executing another program), so they stay off this
|
|
221
|
+
// list even though audit.md's own per-entry table has them. No
|
|
222
|
+
// optional-value option in this set; `-p`/`--preserve-date` is excluded as
|
|
223
|
+
// unnecessary rather than unsafe.
|
|
224
|
+
"file": { readOnly: true, operandsAreData: true, grammar: { args: 'getopt',
|
|
225
|
+
flags: ['-b', '--brief', '-c', '--checking-printout', '-i', '--mime', '--mime-type', '--mime-encoding',
|
|
226
|
+
'-k', '--keep-going', '-L', '--dereference', '-h', '--no-dereference', '-n', '--no-buffer',
|
|
227
|
+
'-r', '--raw', '-s', '--special-files', '-0', '--print0'],
|
|
228
|
+
valueFlags: ['-f', '--files-from', '-m', '--magic-file', '-M', '-e', '--exclude', '-F', '--separator'] } },
|
|
229
|
+
// find: `exact` because find parses its own predicates rather than through
|
|
230
|
+
// getopt and recognises no clustering or abbreviation — measured, `find .
|
|
231
|
+
// -exe` is "unknown predicate `-exe`". No-value predicates/operators plus
|
|
232
|
+
// value predicates that read metadata to test against; `-printf` writes to
|
|
233
|
+
// stdout, not a file (its file-writing sibling `-fprintf` is excluded, see
|
|
234
|
+
// below). Excludes the writing/executing family — `-delete`, `-exec`,
|
|
235
|
+
// `-execdir`, `-ok`, `-okdir` (also caught by DISQUALIFYING_ARGS first),
|
|
236
|
+
// `-fprint`, `-fprint0`, `-fprintf`, `-fls` (write to a file) — and a handful
|
|
237
|
+
// of read-only-but-unnecessary predicates left out rather than
|
|
238
|
+
// exhaustively listed (`-files0-from`, `-quit`, `-prune`, `-fstype`,
|
|
239
|
+
// `-newerXY`, `-regextype`, `-D`). No operand rule: start paths and `(`/`)`
|
|
240
|
+
// pass through as operands, and the Windows `find.exe` measurement in the
|
|
241
|
+
// design round found it read-only, so no `each` rule is needed here.
|
|
242
|
+
// Measured: GNU d12 --help; busybox (`find /tmp/t -name '*.conf' -type f`
|
|
243
|
+
// ran; `-ls`/`-printf` are absent from busybox find, which refuses them
|
|
244
|
+
// itself, fail-closed); BSD man.
|
|
245
|
+
"find": { readOnly: true, operandsAreData: true, grammar: { args: 'exact',
|
|
246
|
+
flags: ['-H', '-L', '-P', '-print', '-print0', '-ls', '-not', '-a', '-and', '-o', '-or', '-true', '-false',
|
|
247
|
+
'-empty', '-xdev', '-mount', '-depth', '-daystart', '-executable', '-readable', '-writable'],
|
|
248
|
+
valueFlags: ['-name', '-iname', '-path', '-ipath', '-wholename', '-iwholename', '-regex', '-iregex',
|
|
249
|
+
'-type', '-perm', '-user', '-group', '-uid', '-gid', '-size', '-mtime', '-mmin', '-atime', '-amin',
|
|
250
|
+
'-ctime', '-cmin', '-newer', '-anewer', '-cnewer', '-links', '-inum', '-maxdepth', '-mindepth',
|
|
251
|
+
'-printf'] } },
|
|
252
|
+
"free": { readOnly: true, operandsAreData: true, grammar: { args: 'any', audit: 'measured busybox; documented (procps free(1)): reports memory usage parsed from /proc; every option only changes units or format' } },
|
|
253
|
+
// git branch: listing/display flags plus optional-value flags kept out of
|
|
254
|
+
// valueFlags (rule 1; `--contains HEAD`'s operand is refused by `max: 0`
|
|
255
|
+
// anyway — an accepted cost). `--sort=<key>` is the one required-value flag.
|
|
256
|
+
// `max: 0` excludes the operand form: a bare `git branch NAME` CREATES a
|
|
257
|
+
// branch (measured, `refs/heads/side` was created), so no operand budget is
|
|
258
|
+
// safe to grant here. Excludes `-d`/`-D` (deletes; `git branch -D main`
|
|
259
|
+
// measured elsewhere in this advisory round), `-m`/`-M` (moves/renames —
|
|
260
|
+
// already blocked by `max: 0` since both need an operand) and
|
|
261
|
+
// `--edit-description` (opens an editor). Measured: git 2.55.0 on macOS
|
|
262
|
+
// (`git branch side` + `git for-each-ref`; `--help`).
|
|
263
|
+
"git branch": { readOnly: true, operandsAreData: true, grammar: { args: 'getopt',
|
|
264
|
+
flags: ['-a', '--all', '-r', '--remotes', '-v', '--verbose', '-q', '--quiet', '--list', '--show-current'],
|
|
265
|
+
valueFlags: ['--sort'],
|
|
266
|
+
optionalValueFlags: ['--color', '--contains', '--merged', '--no-merged'],
|
|
267
|
+
operands: { max: 0 } } },
|
|
268
|
+
// git diff: display/format flags, plus `-M`/`--find-renames`,
|
|
269
|
+
// `-C`/`--find-copies`, `-U`/`--unified`, `--color`, `--word-diff` and
|
|
270
|
+
// `--stat` as optional-value (`optionalValueFlags`): `-U5` and `--unified=5` both stay
|
|
271
|
+
// read-only (the attached value is fine); only a following SEPARATE word is
|
|
272
|
+
// never consumed as the value, which is why the plain `-u` still exists as
|
|
273
|
+
// the no-value form. `--diff-filter`/`-S`/`-G`/`-O` all read or search, never
|
|
274
|
+
// write. No operand
|
|
275
|
+
// rule (revision/path). Excludes `--output` — **measured to write**: on a
|
|
276
|
+
// scratch checkout, `git diff --output=diffout.txt` created a 93-byte file
|
|
277
|
+
// (the design round had this only as documented). Measured: git 2.55.0.
|
|
278
|
+
"git diff": { readOnly: true, operandsAreData: true, grammar: { args: 'getopt',
|
|
279
|
+
flags: ['-p', '-u', '--patch', '--name-only', '--name-status', '--cached', '--staged', '--raw', '-w',
|
|
280
|
+
'--ignore-all-space', '-b', '--ignore-space-change', '--summary', '--shortstat', '--exit-code',
|
|
281
|
+
'--quiet', '-R', '--no-color', '--first-parent'],
|
|
282
|
+
valueFlags: ['--diff-filter', '-S', '-G', '-O'],
|
|
283
|
+
optionalValueFlags: ['-M', '--find-renames', '-C', '--find-copies', '-U', '--unified', '--color',
|
|
284
|
+
'--word-diff', '--stat'] } },
|
|
285
|
+
// git log: display/format flags, plus optional-value flags kept out of
|
|
286
|
+
// valueFlags (rule 1). No operand rule (revision/path, including anything
|
|
287
|
+
// after `--`). Excludes `--output` — **measured to write**: `git log
|
|
288
|
+
// --output=out.txt HEAD` created a 113-byte file on a scratch checkout
|
|
289
|
+
// (`.git/index` unchanged). `numericShort: true` is required because the
|
|
290
|
+
// corpus needs `git log --oneline -20`: git's own `-<n>` revision-limit
|
|
291
|
+
// shortcut is scanned character by character by real getopt, so no
|
|
292
|
+
// `flags`/`valueFlags` entry can name the word `-20` itself — this is the
|
|
293
|
+
// matcher capability the design round's audit asked for, harmless for `git
|
|
294
|
+
// show` too. Measured: git 2.55.0 (`git log --oneline` called; index
|
|
295
|
+
// unchanged).
|
|
296
|
+
"git log": { readOnly: true, operandsAreData: true, grammar: { args: 'getopt',
|
|
297
|
+
flags: ['--oneline', '--graph', '--all', '-p', '-u', '--patch', '--name-only', '--name-status',
|
|
298
|
+
'--abbrev-commit', '--relative-date', '--reverse', '--topo-order', '--date-order', '--parents',
|
|
299
|
+
'--merges', '--no-merges'],
|
|
300
|
+
valueFlags: ['-n', '--max-count', '--since', '--until', '--author', '--grep', '--date', '-S', '-G',
|
|
301
|
+
'--skip'],
|
|
302
|
+
optionalValueFlags: ['--pretty', '--format', '--decorate', '--branches', '--tags', '--remotes',
|
|
303
|
+
'--stat', '-L'],
|
|
304
|
+
numericShort: true } },
|
|
305
|
+
// git remote: `-v`/`--verbose` and the two verbose-adjacent no-value flags
|
|
306
|
+
// only display. `first: ['get-url']`, `max: 2` — a bare `git remote`
|
|
307
|
+
// and `git remote -v` are both valid (0 operands). `show` was removed from
|
|
308
|
+
// `first` by the final review: `git remote show <URL>` accepts a URL
|
|
309
|
+
// (measured, git 2.55.0) and runs ssh / `git-remote-<helper>` against the
|
|
310
|
+
// caller-chosen target, so the subcommand is excluded and a viewer loses
|
|
311
|
+
// `git remote show origin` (priced cost). Excludes `add` (writes
|
|
312
|
+
// config — measured, `git remote add` wrote `[remote "origin"]` into
|
|
313
|
+
// `.git/config`), `rename`, `remove`/`rm`, `set-url`, `set-head` (all write),
|
|
314
|
+
// `prune` (deletes remote-tracking refs) and `update` (fetches — changes
|
|
315
|
+
// refs). Measured: git 2.55.0.
|
|
316
|
+
"git remote": { readOnly: true, operandsAreData: true, grammar: { args: 'getopt',
|
|
317
|
+
flags: ['-v', '--verbose', '-n', '--push'],
|
|
318
|
+
operands: { max: 2, first: ['get-url'] } } },
|
|
319
|
+
// git show: display/format flags, plus `--pretty`, `--format`, `--decorate`
|
|
320
|
+
// and `--stat` as optional-value (`optionalValueFlags`: bare or `=value`, a
|
|
321
|
+
// following separate word is never consumed) and `--date` as the one
|
|
322
|
+
// required-value flag. No operand rule (HEAD, commit, tag, …). Excludes
|
|
323
|
+
// `--output` — same write measured for `git log`/`git diff`, same option
|
|
324
|
+
// family. `numericShort: true` for the same `-<n>` shortcut `git log` needs
|
|
325
|
+
// (`git show -3`). Measured: git 2.55.0 (the sibling `--output` write was
|
|
326
|
+
// re-measured this round).
|
|
327
|
+
"git show": { readOnly: true, operandsAreData: true, grammar: { args: 'getopt',
|
|
328
|
+
flags: ['-s', '--no-patch', '--name-only', '--name-status', '--oneline', '--abbrev-commit', '--raw'],
|
|
329
|
+
valueFlags: ['--date'],
|
|
330
|
+
optionalValueFlags: ['--pretty', '--format', '--decorate', '--stat'],
|
|
331
|
+
numericShort: true } },
|
|
332
|
+
"git status": { readOnly: true, operandsAreData: true, grammar: { args: 'any', audit: 'measured git 2.55.0: opportunistically refreshes cached stat data in .git/index when stale; writes no content, ref or config; host can disable via GIT_OPTIONAL_LOCKS=0' } }, // two-word: class only
|
|
333
|
+
"grep": { readOnly: true, operandsAreData: true, grammar: { args: 'any', audit: 'measured GNU/busybox/BSD: searches input for a pattern and prints matches; no option writes a file or runs a program' } },
|
|
334
|
+
"head": { readOnly: true, operandsAreData: true, grammar: { args: 'any', audit: 'measured GNU/busybox/BSD: prints the first lines of a file; no write mode' } },
|
|
335
|
+
"host": { readOnly: true, operandsAreData: true, grammar: { args: 'any', audit: 'measured BIND 9.10.6 (BSD): sends a DNS query and prints the answer; writes nothing locally (DNS egress tracked separately)' } },
|
|
336
|
+
// hostname: display flags only, no value flags. `operands: { max: 0 }` — a
|
|
337
|
+
// bare `hostname NAME` SETS the hostname on every implementation (GNU --help
|
|
338
|
+
// usage line, busybox `[-sidf] [HOSTNAME | -F FILE]`, BSD man), so no operand
|
|
339
|
+
// budget above zero is safe. Excludes `-F`/`--file` (sets from a file),
|
|
340
|
+
// `-b`/`--boot` (sets a default hostname when none is configured — GNU
|
|
341
|
+
// --help) and `-y`/`--yp`/`--nis` (sets the NIS domain via an operand `max:
|
|
342
|
+
// 0` already refuses — left out as unnecessary rather than unsafe). Measured:
|
|
343
|
+
// GNU hostname 3.21/3.25 --help; busybox (`hostname -s`); macOS man (`[-f]
|
|
344
|
+
// [-s | -d]`).
|
|
345
|
+
"hostname": { readOnly: true, operandsAreData: true, grammar: { args: 'getopt',
|
|
346
|
+
flags: ['-a', '--alias', '-A', '--all-fqdns', '-d', '--domain', '-f', '--fqdn', '--long', '-i',
|
|
347
|
+
'--ip-address', '-I', '--all-ip-addresses', '-s', '--short'],
|
|
348
|
+
operands: { max: 0 } } },
|
|
349
|
+
"htop": { readOnly: true, operandsAreData: true, grammar: { args: 'any', audit: "documented (htop(1) + FILES section, 2.x/3.x): no CLI option writes or executes; kill/renice are interactive keys (F7/F8/F9), unreachable by a read-only caller; ~/.config/htop/htoprc is written only after an interactive Setup change on clean exit; not measured — htop is absent from every image and from macOS" } },
|
|
350
|
+
"id": { readOnly: true, operandsAreData: true, grammar: { args: 'any', audit: "measured GNU/BSD: prints the caller's uid/gid/groups; no write mode" } },
|
|
351
|
+
// ifconfig: display flags/filters only (BSD's `-l -m -d -u -v -L -r -C`
|
|
352
|
+
// format the listing), plus a BSD output-format value flag. `operands: { max:
|
|
353
|
+
// 1 }` — a bare `ifconfig eth0` only SHOWS that interface's status; a second
|
|
354
|
+
// operand (address, `up`/`down`, `netmask`, `hw`, `mtu`, `add`/`del`,
|
|
355
|
+
// `destroy`, …) configures it and is refused. `optionsBeforeOperands:
|
|
356
|
+
// true` (final-review fix): BSD ifconfig reads options only before the
|
|
357
|
+
// interface operand, and macOS accepts the same letters after it as
|
|
358
|
+
// interface settings — `ifconfig en0 -av`/`-dad` change settings from
|
|
359
|
+
// letters this grammar lists — so an option-looking word after the operand
|
|
360
|
+
// is refused rather than read as a listed flag. Measured: busybox --help +
|
|
361
|
+
// `ifconfig -a` (usage line is `[-a] [IFACE] [ADDRESS]` — everything from
|
|
362
|
+
// ADDRESS on is a write); BSD man synopsis; net-tools ifconfig(8) documented
|
|
363
|
+
// only ("Otherwise, it configures the interface").
|
|
364
|
+
"ifconfig": { readOnly: true, operandsAreData: true, grammar: { args: 'getopt',
|
|
365
|
+
flags: ['-a', '-s', '-l', '-m', '-d', '-u', '-v', '-L', '-r', '-C'],
|
|
366
|
+
valueFlags: ['-f'],
|
|
367
|
+
optionsBeforeOperands: true,
|
|
368
|
+
operands: { max: 1 } } },
|
|
369
|
+
"iostat": { readOnly: true, operandsAreData: true, grammar: { args: 'any', audit: 'measured busybox; documented (sysstat iostat(1)): reports kernel I/O statistics; no write mode' } },
|
|
370
|
+
// ip addr: no options at all — a global flag like `-4`/`-6`/`-o` sits before
|
|
371
|
+
// the two-word prefix, so `ip -4 addr show` never even reaches the two-word
|
|
372
|
+
// match and is refused visibly (accepted cost). `first: ['show', 'list']`:
|
|
373
|
+
// "add"/"del"/"flush" cannot be the first operand; after `show`/`list`,
|
|
374
|
+
// operands like `dev eth0`, `to PREFIX`, `label`, `scope` are free, and a
|
|
375
|
+
// bare `ip addr` is valid (vacuous `first`). Abbreviations (`ip a`, `ip ad
|
|
376
|
+
// sh`) are documented in GNU but refused here since the grammar requires the
|
|
377
|
+
// full two words — accepted cost. Measured: busybox (`ip addr show`, `ip
|
|
378
|
+
// addr list`, bare `ip addr` all ran); GNU ip(8) documented only (man7: "show
|
|
379
|
+
// (or list)").
|
|
380
|
+
"ip addr": { readOnly: true, operandsAreData: true, grammar: { args: 'exact', operands: { first: ['show', 'list'] } } }, // two-word: class only
|
|
381
|
+
// ip route: `first: ['show', 'list', 'get']` — `get ADDR` is a read-only
|
|
382
|
+
// query (ip-route(8): "no packets are actually sent"). Excludes `add`,
|
|
383
|
+
// `del`, `change`, `append`, `replace`, `flush`, `restore` (all write) and
|
|
384
|
+
// `save` (dumps to stdout, but its sibling `restore` writes, so the verb pair
|
|
385
|
+
// is left out entirely rather than split). Measured: busybox (`ip route
|
|
386
|
+
// list`, `ip route show` ran; `ip route get 10.0.0.1` queried without
|
|
387
|
+
// writing); GNU ip(8) + ip-route(8) documented only.
|
|
388
|
+
"ip route": { readOnly: true, operandsAreData: true, grammar: { args: 'exact', operands: { first: ['show', 'list', 'get'] } } }, // two-word: class only
|
|
389
|
+
// journalctl: display/filter flags, plus `-n`/`--lines`, `-b`/`--boot` and
|
|
390
|
+
// `--facility` as optional-value (`optionalValueFlags`; journalctl(1): the
|
|
391
|
+
// argument may be omitted) — a bare word or an attached form (`-b1`,
|
|
392
|
+
// `--boot=2`) stays read-only, but the matcher never consumes a following
|
|
393
|
+
// SEPARATE word as the value, so `-b -1` still refuses on the unrecognised
|
|
394
|
+
// `-1` (an accepted cost, not a claim that every attached spelling is
|
|
395
|
+
// refused). No operand rule (unit names/match patterns — reads). Excludes
|
|
396
|
+
// the archive-mutating
|
|
397
|
+
// family — `--vacuum-time`/`--vacuum-size` (deletes archived journals;
|
|
398
|
+
// `--vacuum-time=1s` measured destructive elsewhere in this advisory round),
|
|
399
|
+
// `--rotate` (measured elsewhere too), `--flush`/`--sync`/`--relinquish-var`
|
|
400
|
+
// (redirect the daemon's disk writes), `--setup-keys` (generates FSS keys)
|
|
401
|
+
// and `--verify`/`--verify-key`/`--root` (left out as unnecessary). Entirely
|
|
402
|
+
// documented only — journalctl(1) (man7, systemd 262~devel) — journalctl is
|
|
403
|
+
// absent from every measured image and from macOS.
|
|
404
|
+
"journalctl": { readOnly: true, operandsAreData: true, grammar: { args: 'getopt',
|
|
405
|
+
flags: ['-f', '--follow', '-e', '--pager-end', '-r', '--reverse', '-k', '--dmesg', '-q', '--quiet',
|
|
406
|
+
'-x', '--catalog', '-a', '--all', '-l', '--full', '-m', '--merge', '--no-pager', '--no-hostname',
|
|
407
|
+
'--no-tail', '--disk-usage', '--header', '--list-boots', '--system', '--user'],
|
|
408
|
+
valueFlags: ['-u', '--unit', '--since', '--until', '-S', '-U', '-o', '--output', '-p', '--priority',
|
|
409
|
+
'-g', '--grep', '-t', '--identifier', '-D', '--directory'],
|
|
410
|
+
optionalValueFlags: ['-n', '--lines', '-b', '--boot', '--facility'] } },
|
|
411
|
+
"ls": { readOnly: true, operandsAreData: true, grammar: { args: 'any', audit: 'measured GNU/busybox/BSD: lists directory entries; no write mode' } },
|
|
412
|
+
// netstat: ledger correction — moved from the design round's 'any' guess to
|
|
413
|
+
// a grammar; audit.md's main table still records this entry as 'any'
|
|
414
|
+
// ("every option is display"). The corrected reasons (progress.md ledger):
|
|
415
|
+
// FreeBSD netstat(1) documents `-z` as resetting counters (documented, not
|
|
416
|
+
// measured — excluded, not listed below), and `-p` is put in `flags` rather
|
|
417
|
+
// than `valueFlags` because it takes a value on BSD/Windows but takes none
|
|
418
|
+
// on net-tools (Linux) — listing it as a required-value flag would
|
|
419
|
+
// force-consume the next word on the implementation where it takes none.
|
|
420
|
+
// `-f`/`-I` are optional-value (`optionalValueFlags`). No operand rule
|
|
421
|
+
// needed. Measured: busybox + BSD (`netstat -rn`, etc.); net-tools
|
|
422
|
+
// netstat(8) documented only for anything beyond the measured set.
|
|
423
|
+
"netstat": { readOnly: true, operandsAreData: true, grammar: { args: 'getopt',
|
|
424
|
+
flags: ['-a', '-n', '-t', '-u', '-l', '-p', '-r', '-i', '-s', '-e', '-W', '-x', '-w', '-4', '-6',
|
|
425
|
+
'-v', '-b', '-d', '-m'],
|
|
426
|
+
optionalValueFlags: ['-f', '-I'] } },
|
|
427
|
+
"nslookup": { readOnly: true, operandsAreData: true, grammar: { args: 'any', audit: 'measured busybox/BSD: sends a DNS query; interactive mode does nothing beyond querying, and interactive input is unreachable by design (out of scope)' } },
|
|
428
|
+
"ping": { readOnly: true, operandsAreData: true, grammar: { args: 'any', audit: 'measured busybox; documented (BSD man): sends ICMP echo requests and prints replies; writes nothing locally (ICMP egress tracked separately)' } },
|
|
429
|
+
"printenv": { readOnly: true, operandsAreData: true, grammar: { args: 'any', audit: 'measured GNU/BSD: prints environment variables; no write mode' } },
|
|
430
|
+
"printf": { readOnly: true, operandsAreData: true, grammar: { args: 'any', audit: 'measured GNU/BSD: writes formatted text to stdout only; a file write needs shell redirection, which the outer gate already refuses' } },
|
|
431
|
+
"ps": { readOnly: true, operandsAreData: true, grammar: { args: 'any', audit: "measured busybox; documented (procps ps(1): 'works by reading /proc'): lists processes; no write mode" } },
|
|
432
|
+
"pwd": { readOnly: true, operandsAreData: true, grammar: { args: 'any', audit: 'measured GNU/BSD: prints the working directory; no write mode' } },
|
|
433
|
+
"readlink": { readOnly: true, operandsAreData: true, grammar: { args: 'any', audit: 'measured GNU/BSD: resolves a symlink; no write mode' } },
|
|
434
|
+
"realpath": { readOnly: true, operandsAreData: true, grammar: { args: 'any', audit: 'measured GNU/BSD: resolves a canonical path; no write mode' } },
|
|
435
|
+
"seq": { readOnly: true, operandsAreData: true, grammar: { args: 'any', audit: 'measured GNU/BSD: prints a numeric sequence to stdout; no write mode' } },
|
|
436
|
+
// sort: display/comparison flags only, plus `--check` as optional-value
|
|
437
|
+
// (`optionalValueFlags`): a bare `--check`, or an attached
|
|
438
|
+
// `--check=diagnose-first`/`--check=silent`, both stay read-only — a
|
|
439
|
+
// following SEPARATE word is never consumed as the value, which is why the
|
|
440
|
+
// plain `-c` still exists as the no-value form. Value flags read a key
|
|
441
|
+
// spec, a field separator, a buffer size or a parallelism count. No
|
|
442
|
+
// operand-count limit (multiple input files are legitimate). Windows 11
|
|
443
|
+
// build 26200 measurement: `sort.exe /O FILE IN` writes FILE,
|
|
444
|
+
// case-insensitive (`/o`), abbreviations accepted (`/OU`, `/OUTPUT`) — `-O`
|
|
445
|
+
// is not a switch, so nothing here already excludes it. A `/X` switch is an
|
|
446
|
+
// operand under this POSIX grammar and cannot be told apart from a path
|
|
447
|
+
// like `/etc`; `each` refuses any single-segment `/word` operand, with or
|
|
448
|
+
// without a trailing slash — the trailing-slash form re-measured on the VM
|
|
449
|
+
// 2026-09-26 (build 10.0.26200.9550, confined to %TEMP%): `/O/`, `/o/`,
|
|
450
|
+
// `/OU/`, `/OUTPUT/`, `/T/`, `/A/` are all "Invalid switch" and write
|
|
451
|
+
// nothing (control `/O` writes), but the rule is shared with `arp`, whose
|
|
452
|
+
// `/d`/`/s` slash tolerance cannot be measured without running a
|
|
453
|
+
// state-changing form, so it stays fail-closed. The cost is that an
|
|
454
|
+
// operand naming a file directly under `/` (`sort /data`), or such a
|
|
455
|
+
// directory with a trailing slash (`sort /etc/`), is refused too. Excludes `-o`/`--output`
|
|
456
|
+
// (writes — this advisory's own finding), `--compress-program` (executes a
|
|
457
|
+
// program — measured 14,224 times in the same finding), `-T`/`--temporary-directory`
|
|
458
|
+
// (chooses a write location; a viewer has no use for it, decided in the
|
|
459
|
+
// friction table) and `--random-source`/`--files0-from` (read a file but are
|
|
460
|
+
// unnecessary). `DISQUALIFYING_ARGS`'s own sort rule still runs before this
|
|
461
|
+
// grammar and can still raise the class; the grammar only ever draws the
|
|
462
|
+
// read-only/safe line. Measured: GNU d12 + trixie --help; busybox --help
|
|
463
|
+
// (rejects sort's long options itself: `sort: unrecognized option:
|
|
464
|
+
// key=2`); BSD man (same option family; BSD's own `-o` never reaches this
|
|
465
|
+
// grammar since it is not listed).
|
|
466
|
+
"sort": { readOnly: true, operandsAreData: true, grammar: { args: 'getopt',
|
|
467
|
+
flags: ['-b', '-c', '-d', '-f', '-g', '-h', '-i', '-M', '-m', '-n', '-R', '-r', '-s', '-u', '-V', '-z',
|
|
468
|
+
'--ignore-leading-blanks', '--dictionary-order', '--ignore-case', '--general-numeric-sort',
|
|
469
|
+
'--human-numeric-sort', '--ignore-nonprinting', '--month-sort', '--merge', '--numeric-sort',
|
|
470
|
+
'--random-sort', '--reverse', '--stable', '--unique', '--version-sort', '--debug',
|
|
471
|
+
'--zero-terminated'],
|
|
472
|
+
valueFlags: ['-k', '--key', '-t', '--field-separator', '-S', '--buffer-size', '--sort', '--parallel'],
|
|
473
|
+
optionalValueFlags: ['--check'],
|
|
474
|
+
operands: { each: /^(?!\/[^/]*\/?$)/ } } },
|
|
475
|
+
// ss: entirely documented only — ss(8) (man7, iproute2); ss is absent from
|
|
476
|
+
// every measured image and from macOS. Display flags only, plus value flags
|
|
477
|
+
// that name a socket family/query/filter. Excludes `-K`/`--kill` (forcibly
|
|
478
|
+
// closes sockets), `-D`/`--diag` (dumps raw TCP info to a file), `-N`/`--net`
|
|
479
|
+
// (switches network namespace via setns) and `-b`/`--bpf` (admin); a handful
|
|
480
|
+
// of display-only-but-unnecessary flags left out rather than exhaustively
|
|
481
|
+
// listed (`-E`/`-T`/`-Q`/`-S`/`-M`/`-Z`/`-z`/`--tos`/`--cgroup`/…).
|
|
482
|
+
"ss": { readOnly: true, operandsAreData: true, grammar: { args: 'getopt',
|
|
483
|
+
flags: ['-H', '--no-header', '-O', '--oneline', '-n', '--numeric', '-r', '--resolve', '-a', '--all',
|
|
484
|
+
'-l', '--listening', '-o', '--options', '-e', '--extended', '-m', '--memory', '-p', '--processes',
|
|
485
|
+
'-i', '--info', '-s', '--summary', '-t', '--tcp', '-u', '--udp', '-d', '--dccp', '-w', '--raw',
|
|
486
|
+
'-x', '--unix', '-4', '-6', '-0', '--packet'],
|
|
487
|
+
valueFlags: ['-f', '--family', '-A', '--query', '--socket', '-F', '--filter'] } },
|
|
488
|
+
"stat": { readOnly: true, operandsAreData: true, grammar: { args: 'any', audit: 'measured GNU/busybox/BSD: prints file or filesystem status; no write mode' } },
|
|
489
|
+
// systemctl status: ledger correction — moved from the design round's 'any'
|
|
490
|
+
// guess to a grammar; audit.md's main table still records this entry as
|
|
491
|
+
// 'any' ("only reports run state and log; subcommands that mutate state are
|
|
492
|
+
// outside the two-word match"). The corrected reason (progress.md ledger):
|
|
493
|
+
// systemctl(1) documents `-H`/`--host` as running the operation over SSH
|
|
494
|
+
// (executes another program on another host) and `-M`/`--machine` as
|
|
495
|
+
// targeting a container/VM — both excluded, not listed below. `-n`/
|
|
496
|
+
// `--lines` and `-o`/`--output` are optional-value (`optionalValueFlags`).
|
|
497
|
+
// Entirely documented only — systemctl(1) (man7, systemd 262~devel) —
|
|
498
|
+
// absent from every measured image and from macOS.
|
|
499
|
+
"systemctl status": { readOnly: true, operandsAreData: true, grammar: { args: 'getopt',
|
|
500
|
+
flags: ['-l', '--full', '--no-pager', '-a', '--all', '-q', '--quiet', '--user', '--system', '-r',
|
|
501
|
+
'--recursive', '--no-legend'],
|
|
502
|
+
optionalValueFlags: ['-n', '--lines', '-o', '--output'] } }, // two-word: class only
|
|
503
|
+
"tail": { readOnly: true, operandsAreData: true, grammar: { args: 'any', audit: 'measured GNU/busybox/BSD: prints the last lines of a file; -f/-F only follow, they do not write' } },
|
|
504
|
+
"test": { readOnly: true, operandsAreData: true, grammar: { args: 'any', audit: 'measured GNU/BSD: evaluates a conditional expression and sets exit status; no I/O of any kind' } },
|
|
505
|
+
"top": { readOnly: true, operandsAreData: true, grammar: { args: 'any', audit: "measured busybox/BSD; documented (procps top(1)): no non-interactive write mode in any implementation — kill/renice/config-write keys are interactive-only, and batch mode ('top will not accept input') takes none of them" } },
|
|
506
|
+
"tr": { readOnly: true, operandsAreData: true, grammar: { args: 'any', audit: 'measured GNU/BSD: translates or deletes characters from stdin to stdout; no write mode' } },
|
|
507
|
+
"traceroute": { readOnly: true, operandsAreData: true, grammar: { args: 'any', audit: 'measured busybox; documented (BSD man): sends probe packets and prints the path; writes nothing locally (ICMP/UDP egress tracked separately)' } },
|
|
508
|
+
"true": { readOnly: true, operandsAreData: true, grammar: { args: 'any', audit: 'measured GNU: exits 0 immediately; no I/O of any kind' } },
|
|
509
|
+
"uname": { readOnly: true, operandsAreData: true, grammar: { args: 'any', audit: 'measured GNU/BSD: prints system identification; no write mode' } },
|
|
510
|
+
// uniq: display flags only, plus `--all-repeated` kept optional-value out of
|
|
511
|
+
// valueFlags (rule 1). `operands: { max: 1 }` — INPUT only; a second operand
|
|
512
|
+
// is OUTFILE and is OVERWRITTEN (measured elsewhere in this advisory:
|
|
513
|
+
// `uniq /etc/passwd /root/.ssh/authorized_keys`; GNU's own usage line is
|
|
514
|
+
// `[INPUT [OUTPUT]]`, busybox's `[FILE [OUTFILE]]`). `optionsBeforeOperands:
|
|
515
|
+
// true` (final-review fix): macOS's optstring is `+`-prefixed (`+D::cdif:s:u`,
|
|
516
|
+
// POSIX order), so `uniq IN -c` does not read `-c` as the count flag there —
|
|
517
|
+
// it is the OUTFILE, and uniq overwrites it; GNU permutes, so the spelling
|
|
518
|
+
// is only harmless there, and the grammar must hold for every
|
|
519
|
+
// implementation. Excludes `--group` (optional-value, reads, but left out as
|
|
520
|
+
// unnecessary — not a minimal grammar addition). Measured: GNU d12 --help;
|
|
521
|
+
// busybox --help; BSD man (same option family plus `-D --all-repeated
|
|
522
|
+
// [septype]`).
|
|
523
|
+
"uniq": { readOnly: true, operandsAreData: true, grammar: { args: 'getopt',
|
|
524
|
+
flags: ['-c', '--count', '-d', '--repeated', '-u', '--unique', '-i', '--ignore-case', '-z',
|
|
525
|
+
'--zero-terminated', '-D'],
|
|
526
|
+
valueFlags: ['-f', '--skip-fields', '-s', '--skip-chars', '-w', '--check-chars'],
|
|
527
|
+
optionalValueFlags: ['--all-repeated'],
|
|
528
|
+
optionsBeforeOperands: true,
|
|
529
|
+
operands: { max: 1 } } },
|
|
530
|
+
"uptime": { readOnly: true, operandsAreData: true, grammar: { args: 'any', audit: 'measured busybox/BSD: prints load average and boot time; no write mode' } },
|
|
531
|
+
"vmstat": { readOnly: true, operandsAreData: true, grammar: { args: 'any', audit: "documented (procps vmstat(8): 'requires read access to /proc'): reports virtual-memory statistics; no write mode" } },
|
|
532
|
+
"wc": { readOnly: true, operandsAreData: true, grammar: { args: 'any', audit: 'measured GNU/BSD: counts lines, words and bytes of input; no write mode' } },
|
|
533
|
+
"whereis": { readOnly: true, operandsAreData: true, grammar: { args: 'any', audit: 'measured GNU/BSD: searches PATH directories for binaries, sources and manuals; no write mode' } },
|
|
534
|
+
"which": { readOnly: true, operandsAreData: true, grammar: { args: 'any', audit: 'measured GNU/BSD: locates a binary on PATH; no write mode' } },
|
|
535
|
+
"who": { readOnly: true, operandsAreData: true, grammar: { args: 'any', audit: 'measured GNU/BSD: lists logged-in sessions from utmp; no write mode' } },
|
|
536
|
+
"whoami": { readOnly: true, operandsAreData: true, grammar: { args: 'any', audit: 'measured GNU/BSD: prints the effective username; no write mode' } },
|
|
537
|
+
};
|
|
538
|
+
/** The class half of READERS, as the shape its consumers already expect. */
|
|
539
|
+
const READ_ONLY_ALLOWLIST = new Set(Object.entries(READERS).filter(([, e]) => e.readOnly).map(([name]) => name));
|
|
32
540
|
// Deliberately NOT read-only: `env`, because it is an exec wrapper. `env <cmd>`
|
|
33
541
|
// runs <cmd>, so allowlisting the name `env` vouched for a command the
|
|
34
542
|
// classifier never looked at — `env sudo rm -f /etc/passwd` classified
|
|
@@ -177,44 +685,123 @@ const CLASS_RANK = {
|
|
|
177
685
|
destructive: 2,
|
|
178
686
|
privileged: 3,
|
|
179
687
|
};
|
|
688
|
+
const INTERPRETERS = Object.assign(Object.create(null), {
|
|
689
|
+
sh: { programBearingWords: ['-c'], readable: true },
|
|
690
|
+
bash: { programBearingWords: ['-c'], readable: true },
|
|
691
|
+
dash: { programBearingWords: ['-c'], readable: true },
|
|
692
|
+
zsh: { programBearingWords: ['-c'], readable: true },
|
|
693
|
+
ksh: { programBearingWords: ['-c'], readable: true },
|
|
694
|
+
ash: { programBearingWords: ['-c'], readable: true },
|
|
695
|
+
python: { programBearingWords: ['-c'], readable: false },
|
|
696
|
+
python2: { programBearingWords: ['-c'], readable: false },
|
|
697
|
+
python3: { programBearingWords: ['-c'], readable: false },
|
|
698
|
+
perl: { programBearingWords: ['-e', '-E'], readable: false },
|
|
699
|
+
ruby: { programBearingWords: ['-e'], readable: false },
|
|
700
|
+
// `-p`/`--print` evaluate exactly as `-e` does and then print the result.
|
|
701
|
+
node: { programBearingWords: ['-e', '--eval', '-p', '--print'], readable: false },
|
|
702
|
+
php: { programBearingWords: ['-r'], readable: false },
|
|
703
|
+
// Measured on 2.11.0: each of these classified `safe` while the identical
|
|
704
|
+
// attack through python3 -c classified `destructive` (GHSA-qmx6-47vm-3vf7).
|
|
705
|
+
// `readable: false` throughout, matching python/perl/node: the program is not
|
|
706
|
+
// shell text, so its presence is what counts rather than its content.
|
|
707
|
+
osascript: { programBearingWords: ['-e'], readable: false },
|
|
708
|
+
lua: { programBearingWords: ['-e'], readable: false },
|
|
709
|
+
Rscript: { programBearingWords: ['-e'], readable: false },
|
|
710
|
+
bun: { programBearingWords: ['-e'], readable: false },
|
|
711
|
+
// No program-bearing flag: real tclsh takes a script FILE as its positional
|
|
712
|
+
// argument or reads one from stdin, unlike sh/bash/python/pwsh, none of
|
|
713
|
+
// which have a `-c` of its own. An empty list still marks tclsh as an
|
|
714
|
+
// unreadable interpreter for `readsProgramFromStdin` (`echo … | tclsh`),
|
|
715
|
+
// which is the genuine carrier; it is simply never handed a program inline
|
|
716
|
+
// on its own command line, so there is no flag to name here.
|
|
717
|
+
tclsh: { programBearingWords: [], readable: false },
|
|
718
|
+
// `eval` is a subcommand, not a flag — the field is named for what it holds.
|
|
719
|
+
deno: { programBearingWords: ['eval'], readable: false },
|
|
720
|
+
pwsh: { programBearingWords: ['-c', '-Command', '-e', '-EncodedCommand'], readable: false },
|
|
721
|
+
powershell: { programBearingWords: ['-c', '-Command', '-e', '-EncodedCommand'], readable: false },
|
|
722
|
+
});
|
|
180
723
|
/**
|
|
181
|
-
*
|
|
724
|
+
* Every `INTERPRETERS` entry, keyed by its name lower-cased.
|
|
182
725
|
*
|
|
183
|
-
*
|
|
184
|
-
*
|
|
185
|
-
*
|
|
726
|
+
* A second index rather than lower-casing the lookup key against `INTERPRETERS`
|
|
727
|
+
* directly, because that table's own keys are not uniformly lower-case —
|
|
728
|
+
* `Rscript` is not `rscript` — so folding only the *input* would still miss it.
|
|
729
|
+
* Built once at module load: the table is fixed, so there is nothing to keep in
|
|
730
|
+
* sync.
|
|
731
|
+
*/
|
|
732
|
+
const INTERPRETERS_BY_LOWERCASE_NAME = Object.assign(Object.create(null), Object.fromEntries(Object.entries(INTERPRETERS).map(([name, spec]) => [name.toLowerCase(), spec])));
|
|
733
|
+
/** A Windows executable suffix this table's keys never carry. */
|
|
734
|
+
const WINDOWS_EXE_SUFFIX = /\.(exe|cmd|bat)$/i;
|
|
735
|
+
/**
|
|
736
|
+
* Resolve an interpreter table entry for a command word already stripped of
|
|
737
|
+
* its path (`stripPath(unquote(word))`, as every call site already computes
|
|
738
|
+
* it for the exact-match case below).
|
|
186
739
|
*
|
|
187
|
-
*
|
|
188
|
-
*
|
|
189
|
-
*
|
|
190
|
-
*
|
|
191
|
-
*
|
|
192
|
-
*
|
|
740
|
+
* The table's keys are exact, case-sensitive, extension-free spellings, so a
|
|
741
|
+
* direct lookup is tried first and is the whole cost for the common case —
|
|
742
|
+
* `sh`, `python3`, the bare lowercase `pwsh`. The fallback strips a trailing
|
|
743
|
+
* `.exe`/`.cmd`/`.bat` and folds case, which is what a Windows target's own
|
|
744
|
+
* shell hands back for the *same* binary: `powershell.exe`, `PWSH.EXE` and
|
|
745
|
+
* `PowerShell` all name the interpreter this table already lists under
|
|
746
|
+
* `powershell`/`pwsh`.
|
|
193
747
|
*
|
|
194
|
-
*
|
|
195
|
-
*
|
|
748
|
+
* Applied to every entry, not only pwsh/powershell. The measured bug is
|
|
749
|
+
* about those two, but the gap is not specific to them: a target running
|
|
750
|
+
* `python.exe` or `Node.EXE` carries the identical mismatch, and scoping the
|
|
751
|
+
* fix to two names would leave the rest of the table exactly as blind as it
|
|
752
|
+
* was. This is the binary-*name* question, answered once — it does not touch
|
|
753
|
+
* flag matching, which stays case-sensitive per interpreter (`perl -E` and
|
|
754
|
+
* `perl -e` are still two different flags) via the existing, separate
|
|
755
|
+
* `caseInsensitive` parameter threaded through `programAfterFlag` and
|
|
756
|
+
* friends.
|
|
196
757
|
*/
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
dash: { flags: ['-c'], readable: true },
|
|
204
|
-
zsh: { flags: ['-c'], readable: true },
|
|
205
|
-
ksh: { flags: ['-c'], readable: true },
|
|
206
|
-
ash: { flags: ['-c'], readable: true },
|
|
207
|
-
python: { flags: ['-c'], readable: false },
|
|
208
|
-
python2: { flags: ['-c'], readable: false },
|
|
209
|
-
python3: { flags: ['-c'], readable: false },
|
|
210
|
-
perl: { flags: ['-e', '-E'], readable: false },
|
|
211
|
-
ruby: { flags: ['-e'], readable: false },
|
|
212
|
-
// `-p`/`--print` evaluate exactly as `-e` does and then print the result.
|
|
213
|
-
node: { flags: ['-e', '--eval', '-p', '--print'], readable: false },
|
|
214
|
-
php: { flags: ['-r'], readable: false },
|
|
215
|
-
});
|
|
758
|
+
function resolveInterpreter(word) {
|
|
759
|
+
const exact = INTERPRETERS[word];
|
|
760
|
+
if (exact !== undefined)
|
|
761
|
+
return exact;
|
|
762
|
+
return INTERPRETERS_BY_LOWERCASE_NAME[word.replace(WINDOWS_EXE_SUFFIX, '').toLowerCase()];
|
|
763
|
+
}
|
|
216
764
|
/** `find … -exec <cmd> +` runs cmd. */
|
|
217
765
|
const FIND_EXEC_FLAGS = new Set(['-exec', '-execdir', '-ok', '-okdir']);
|
|
766
|
+
/**
|
|
767
|
+
* GNU sort's own long-option table, spelled in full — every entry `sort
|
|
768
|
+
* --help` lists (coreutils 9.11, `docker exec ssh-mcp-ssh-admin-1 sort
|
|
769
|
+
* --help`). Used only to compute which prefixes of `--output` and
|
|
770
|
+
* `--compress-program` `getopt_long` treats as unambiguous.
|
|
771
|
+
*
|
|
772
|
+
* `getopt_long` resolves a `--` word to the option whose name it is an
|
|
773
|
+
* unambiguous prefix of: a prefix shared with another option name is
|
|
774
|
+
* refused outright ("option '--c' is ambiguous; possibilities: '--check'
|
|
775
|
+
* '--compress-program'") rather than run, so it must not be treated as a
|
|
776
|
+
* write. Measured against the binary: `sort --c=x k` exits 2 with no file
|
|
777
|
+
* created; `sort --co=x k` — the second letter is where `--check` and
|
|
778
|
+
* `--compress-program` diverge — runs.
|
|
779
|
+
*/
|
|
780
|
+
const SORT_LONG_OPTIONS = [
|
|
781
|
+
'ignore-leading-blanks', 'dictionary-order', 'ignore-case',
|
|
782
|
+
'general-numeric-sort', 'ignore-nonprinting', 'month-sort',
|
|
783
|
+
'human-numeric-sort', 'numeric-sort', 'random-sort', 'random-source',
|
|
784
|
+
'reverse', 'sort', 'version-sort', 'batch-size', 'check',
|
|
785
|
+
'compress-program', 'debug', 'files0-from', 'key', 'merge', 'output',
|
|
786
|
+
'stable', 'buffer-size', 'field-separator', 'temporary-directory',
|
|
787
|
+
'parallel', 'unique', 'zero-terminated', 'help', 'version',
|
|
788
|
+
];
|
|
789
|
+
/**
|
|
790
|
+
* The regex-alternation source matching any prefix of `full` that names
|
|
791
|
+
* exactly one entry of `SORT_LONG_OPTIONS` — every prefix `getopt_long`
|
|
792
|
+
* would accept for it, from the shortest unambiguous one up to `full`
|
|
793
|
+
* itself — each optionally followed by `=value`.
|
|
794
|
+
*/
|
|
795
|
+
function unambiguousLongOptionSource(full) {
|
|
796
|
+
const prefixes = [];
|
|
797
|
+
for (let len = 1; len <= full.length; len++) {
|
|
798
|
+
const prefix = full.slice(0, len);
|
|
799
|
+
if (SORT_LONG_OPTIONS.filter((opt) => opt.startsWith(prefix)).length === 1) {
|
|
800
|
+
prefixes.push(prefix);
|
|
801
|
+
}
|
|
802
|
+
}
|
|
803
|
+
return `--(?:${prefixes.join('|')})(?:=|$)`;
|
|
804
|
+
}
|
|
218
805
|
/**
|
|
219
806
|
* How deep a substitution may nest before we stop reading and refuse to guess.
|
|
220
807
|
*
|
|
@@ -406,8 +993,22 @@ function matchesEitherForm(command, test) {
|
|
|
406
993
|
* Four carriers, all of which a remote shell expands and runs:
|
|
407
994
|
* `$(...)`, backticks, process substitution `<(...)` / `>(...)`, and a shell
|
|
408
995
|
* given `-c`.
|
|
996
|
+
*
|
|
997
|
+
* @param speculativeOperands Whether the catch-all below — any multi-word,
|
|
998
|
+
* non-flag operand of a segment no more specific reader claimed — should be
|
|
999
|
+
* pushed. That catch-all is a guess about a binary this file does not
|
|
1000
|
+
* recognise; the four carriers above are not guesses, the shell really does
|
|
1001
|
+
* run what they hold, and are pushed regardless of this flag. Defaults to
|
|
1002
|
+
* `true` for `classifyCommand`'s own recursion, where a guess may raise a
|
|
1003
|
+
* command's *class* and leave role, tier and approval to weigh in.
|
|
1004
|
+
* `findForbiddenMatch` passes `false`: its recursion feeds
|
|
1005
|
+
* `FORBIDDEN_RULES`, the one unconditional denylist, and a guess must not
|
|
1006
|
+
* be able to produce a refusal nobody can override (the maintainer's
|
|
1007
|
+
* ruling — see the block comment above `findForbiddenMatch`). `$()`,
|
|
1008
|
+
* backticks and `sh -c` are certain carriers and keep reaching the
|
|
1009
|
+
* denylist either way.
|
|
409
1010
|
*/
|
|
410
|
-
export function nestedCommands(command) {
|
|
1011
|
+
export function nestedCommands(command, speculativeOperands = true) {
|
|
411
1012
|
const found = [];
|
|
412
1013
|
// `$(...)`, `<(...)`, `>(...)` — scanned rather than matched, because a regex
|
|
413
1014
|
// cannot balance parentheses and `echo $(echo $(sudo id))` is the case that
|
|
@@ -468,13 +1069,86 @@ export function nestedCommands(command) {
|
|
|
468
1069
|
for (const inner of awk?.pipedInto ?? [])
|
|
469
1070
|
found.push(inner);
|
|
470
1071
|
if (!operandsAreData(words)) {
|
|
1072
|
+
// Which operand *indices* an interpreter actually consumed as a program —
|
|
1073
|
+
// not whether the segment's head happens to be a name in the table, and not
|
|
1074
|
+
// a single segment-wide flag either. A set of indices rather than a boolean:
|
|
1075
|
+
// `unknownbin sh -c true 'sudo id'` has `sh -c` consume `true` (harmless,
|
|
1076
|
+
// pushed below) and leave `'sudo id'` untouched, and a boolean here defused
|
|
1077
|
+
// the catch-all for that second operand too — measured, it classified
|
|
1078
|
+
// `safe`. Only the specific word an interpreter actually read should be
|
|
1079
|
+
// excluded from the catch-all; every other operand in the segment is still
|
|
1080
|
+
// this binary's own, unclaimed by anything more specific.
|
|
1081
|
+
//
|
|
1082
|
+
// A binary being recognised is also not the same as this segment's
|
|
1083
|
+
// invocation of it being one the loop could parse:
|
|
1084
|
+
// `pwsh -ExecutionPolicy Bypass -Command 'sudo id'` has a recognised head
|
|
1085
|
+
// and a program on the line, but `-ExecutionPolicy` takes a value
|
|
1086
|
+
// (`Bypass`) that isn't itself a flag, and `programAfterFlag` gives up
|
|
1087
|
+
// rather than guess past it. Gating the catch-all below on "head is in the
|
|
1088
|
+
// table" excluded exactly the binaries this file just learned, and for
|
|
1089
|
+
// precisely the invocations its own flag-walk cannot follow — a net
|
|
1090
|
+
// regression, not a wash.
|
|
1091
|
+
const consumedOperands = new Set();
|
|
471
1092
|
for (let i = 0; i < words.length; i++) {
|
|
472
|
-
const spec =
|
|
473
|
-
|
|
1093
|
+
const spec = resolveInterpreter(stripPath(unquote(words[i])));
|
|
1094
|
+
// pwsh/powershell's own parameter binder resolves `-ENC`/`-Enc`/`-enc` to the
|
|
1095
|
+
// same parameter; every other interpreter's flags are exact letters (`-E` and
|
|
1096
|
+
// `-e` are different flags to perl). `-EncodedCommand` only ever appears on
|
|
1097
|
+
// the two entries whose own parameter binder works this way, so its presence
|
|
1098
|
+
// is the signal for which interpreter this is, not a hardcoded name check.
|
|
1099
|
+
const caseInsensitive = spec?.programBearingWords.includes('-EncodedCommand') ?? false;
|
|
1100
|
+
if (spec === undefined || isFlagValue(words, i, spec.programBearingWords, caseInsensitive))
|
|
474
1101
|
continue;
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
1102
|
+
// Same signal, second job: pwsh/powershell's argument parser also does not stop
|
|
1103
|
+
// at an unrecognised option's bare value (`-ExecutionPolicy Bypass`) the way a
|
|
1104
|
+
// POSIX interpreter stops at its first positional argument. See
|
|
1105
|
+
// `programAfterFlag`'s `tolerateUnknownWordsAtHead` for why this is scoped to
|
|
1106
|
+
// `i === 0` there rather than here.
|
|
1107
|
+
const result = programAfterFlag(words, i, spec.programBearingWords, caseInsensitive, caseInsensitive);
|
|
1108
|
+
if (result !== null) {
|
|
1109
|
+
consumedOperands.add(result.index);
|
|
1110
|
+
found.push(result.program);
|
|
1111
|
+
// An encoded program is opaque to every text scan until it is decoded. Gated on
|
|
1112
|
+
// the word `programAfterFlag` actually matched — the table's own canonical
|
|
1113
|
+
// spelling, so every case-folded or clustered form of `-EncodedCommand`/`-e`
|
|
1114
|
+
// reaches this the same way a literal `-EncodedCommand` does — not a scan of
|
|
1115
|
+
// the whole segment for one long spelling.
|
|
1116
|
+
if (caseInsensitive && (result.flag === '-EncodedCommand' || result.flag === '-e')) {
|
|
1117
|
+
const decoded = decodedPowerShellCommand(result.program);
|
|
1118
|
+
if (decoded !== null)
|
|
1119
|
+
found.push(decoded);
|
|
1120
|
+
}
|
|
1121
|
+
}
|
|
1122
|
+
}
|
|
1123
|
+
// An operand of a binary nothing more specific has read is classified as a
|
|
1124
|
+
// command in its own right, rather than scanned as text.
|
|
1125
|
+
//
|
|
1126
|
+
// The gate is the awk reader's own result, and now which specific operands
|
|
1127
|
+
// an interpreter actually consumed, rather than a list of names: a name
|
|
1128
|
+
// list here would be the defect this change exists to fix
|
|
1129
|
+
// (GHSA-qmx6-47vm-3vf7), and "the head is a name in the table" turned out
|
|
1130
|
+
// to be one too — it answers a different question than "did this
|
|
1131
|
+
// invocation's program-bearing word actually resolve". `awk` is already
|
|
1132
|
+
// null for every non-awk segment.
|
|
1133
|
+
//
|
|
1134
|
+
// Whitespace is what separates an operand worth classifying from one that is
|
|
1135
|
+
// not: a single token is a path, a flag value or a subcommand, while a
|
|
1136
|
+
// multi-word operand has the shape of a command. Flags are skipped.
|
|
1137
|
+
//
|
|
1138
|
+
// Deliberately NOT a text scan for `sudo`. That version asserted elevations
|
|
1139
|
+
// the awk reader and the variable-command-word logic refuse to assert — both
|
|
1140
|
+
// cap at `destructive` on purpose — and it out-ranked the nested
|
|
1141
|
+
// classification that names the elevated binary, reporting `awk` where `id`
|
|
1142
|
+
// was correct.
|
|
1143
|
+
if (speculativeOperands && awk === null) {
|
|
1144
|
+
for (let i = 1; i < words.length; i++) {
|
|
1145
|
+
if (consumedOperands.has(i))
|
|
1146
|
+
continue;
|
|
1147
|
+
if (words[i].startsWith('-'))
|
|
1148
|
+
continue;
|
|
1149
|
+
if (/\s/.test(words[i]))
|
|
1150
|
+
found.push(words[i]);
|
|
1151
|
+
}
|
|
478
1152
|
}
|
|
479
1153
|
}
|
|
480
1154
|
// `-exec` is a flag rather than a command word, so this one is still a scan.
|
|
@@ -541,7 +1215,58 @@ const EXEC_WRAPPERS = new Set([
|
|
|
541
1215
|
* the `+` terminator carries none.
|
|
542
1216
|
*/
|
|
543
1217
|
// Null-prototype for the same reason as INTERPRETERS: indexed by the command word.
|
|
544
|
-
const DISQUALIFYING_ARGS = Object.assign(Object.create(null), {
|
|
1218
|
+
const DISQUALIFYING_ARGS = Object.assign(Object.create(null), {
|
|
1219
|
+
find: /^-(exec|execdir|ok|okdir|delete|fprintf?|fls)$/,
|
|
1220
|
+
// GNU sort execs this for every temporary file it spills, so a reader
|
|
1221
|
+
// becomes a launcher. Measured against coreutils 9.11: an attacker-named
|
|
1222
|
+
// script ran 14,224 times for one 200k-line input, and the whole command
|
|
1223
|
+
// classified `read-only` — which a `readOnly` viewer is allowed to run,
|
|
1224
|
+
// while running that same program directly is denied.
|
|
1225
|
+
//
|
|
1226
|
+
// `-o FILE` / `--output=FILE` is the same shape of bug with a plainer
|
|
1227
|
+
// payoff: it creates and truncates FILE, which is a write a `readOnly`
|
|
1228
|
+
// profile must never reach through a command classified `read-only`.
|
|
1229
|
+
// Measured on HEAD before this rule: `sort -o /root/.ssh/authorized_keys
|
|
1230
|
+
// /tmp/key.pub` classified `read-only`.
|
|
1231
|
+
//
|
|
1232
|
+
// Targeted round (2026-09-24, R2+R3): the version above closed the exact
|
|
1233
|
+
// spellings `-o`, `--output` and `--compress-program` and left the CLASS
|
|
1234
|
+
// open — GNU sort's own option grammar has two escapes past a fixed
|
|
1235
|
+
// spelling, both measured against the real binary:
|
|
1236
|
+
//
|
|
1237
|
+
// - A short-option cluster is scanned left to right, and any of sort's
|
|
1238
|
+
// own *argument-less* short flags may sit ahead of `-o` without
|
|
1239
|
+
// consuming it: `sort -mo out in` writes `out` exactly as `sort -o out
|
|
1240
|
+
// in` does. `-m` (merge) is itself argument-less, and its absence from
|
|
1241
|
+
// the char class below is exactly what let `sort -mo …` through as
|
|
1242
|
+
// `read-only`.
|
|
1243
|
+
// - `getopt_long` resolves a `--` word by unambiguous-prefix matching:
|
|
1244
|
+
// `--o`, `--ou`, `--out`, `--outp`, `--outpu` all mean `--output`
|
|
1245
|
+
// (nothing else starts with `o`), and `--co` through
|
|
1246
|
+
// `--compress-progra` all mean `--compress-program` (`--c` alone is
|
|
1247
|
+
// ambiguous with `--check` and sort refuses to run at all —
|
|
1248
|
+
// `unambiguousLongOptionSource` excludes it for exactly that reason,
|
|
1249
|
+
// computed from sort's own option table rather than hand-picked).
|
|
1250
|
+
//
|
|
1251
|
+
// The cluster branch is still deliberately narrower than "any `o` in a
|
|
1252
|
+
// dash word": `-[bcCdfghiMmnRrsuVz]*o` only allows GNU sort's own
|
|
1253
|
+
// argument-less short flags ahead of the `o`, so it stops at the first
|
|
1254
|
+
// flag that takes a value of its own. Without that, `-tofile` — `-t`
|
|
1255
|
+
// (field separator) with its value attached, not `-o` — would be
|
|
1256
|
+
// misread as a write. `-t`, `-k`, `-S` and `-T` are exactly the short
|
|
1257
|
+
// flags this excludes, because each consumes the rest of a clustered
|
|
1258
|
+
// word as its own argument, so a following `o` is that argument's text,
|
|
1259
|
+
// not `-o` invoked.
|
|
1260
|
+
//
|
|
1261
|
+
// `find`'s entry above does not get the same prefix-abbreviation
|
|
1262
|
+
// treatment: `find` parses its predicates itself rather than through
|
|
1263
|
+
// `getopt_long`, and accepts no abbreviated spelling of `-exec` — `find .
|
|
1264
|
+
// -exe` is "unknown predicate `-exe`", measured. There is no class to
|
|
1265
|
+
// close there.
|
|
1266
|
+
sort: new RegExp(`^(?:${unambiguousLongOptionSource('compress-program')}`
|
|
1267
|
+
+ `|${unambiguousLongOptionSource('output')}`
|
|
1268
|
+
+ '|-[bcCdfghiMmnRrsuVz]*o)'),
|
|
1269
|
+
});
|
|
545
1270
|
/** A leading `NAME=value`, which a shell treats as an assignment, not a command. */
|
|
546
1271
|
const ASSIGNMENT = /^[A-Za-z_][A-Za-z0-9_]*=/;
|
|
547
1272
|
/** `timeout 5s`, `nice 10` — a bare argument some wrappers take before the command. */
|
|
@@ -673,11 +1398,26 @@ function elevatedBinaryOf(command) {
|
|
|
673
1398
|
* behind a wrapper, behind an assignment, or after a separator all reached root
|
|
674
1399
|
* with the command classified `safe` or `read-only`.
|
|
675
1400
|
*/
|
|
676
|
-
/**
|
|
1401
|
+
/**
|
|
1402
|
+
* An allowlisted binary carrying a flag that makes it write or execute.
|
|
1403
|
+
*
|
|
1404
|
+
* Keyed on `effectiveCommandIndex`, not `parseSegments`. `parseSegments`
|
|
1405
|
+
* (`parseWords`) only steps over a privilege prefix (`sudo`, `su`, …), not an
|
|
1406
|
+
* exec wrapper (`env`, `nohup`, `timeout`, …), so `env sort
|
|
1407
|
+
* --compress-program=X` read `head` as `"env"` — not in `DISQUALIFYING_ARGS`
|
|
1408
|
+
* — and the flag went unnoticed. `effectiveCommandIndex` already reads past
|
|
1409
|
+
* both, which is what `operandsAreData` and `readsProgramFromStdin` use it
|
|
1410
|
+
* for. Deliberately not fixed by changing `parseWords` itself:
|
|
1411
|
+
* `invokedWords` also reads it, and widening what counts as "the command"
|
|
1412
|
+
* there is a different, larger change this fix does not take on.
|
|
1413
|
+
*/
|
|
677
1414
|
function hasDisqualifyingArgs(command) {
|
|
678
|
-
return
|
|
679
|
-
const
|
|
680
|
-
|
|
1415
|
+
return tokenizeSegments(command).some((words) => {
|
|
1416
|
+
const idx = effectiveCommandIndex(words);
|
|
1417
|
+
if (idx === -1)
|
|
1418
|
+
return false;
|
|
1419
|
+
const rule = DISQUALIFYING_ARGS[stripPath(words[idx])];
|
|
1420
|
+
return rule !== undefined && words.slice(idx + 1).some((arg) => rule.test(arg));
|
|
681
1421
|
});
|
|
682
1422
|
}
|
|
683
1423
|
function invokedWords(command) {
|
|
@@ -769,9 +1509,21 @@ export function findForbiddenMatch(command, depth = 0) {
|
|
|
769
1509
|
// They still classified `destructive`, which on the `prod` tier degrades an
|
|
770
1510
|
// absolute `deny` into `require-approval`: a rule that answers "never" became
|
|
771
1511
|
// one a human can click through.
|
|
1512
|
+
//
|
|
1513
|
+
// `speculativeOperands: false` — the maintainer's ruling. `nestedCommands`'
|
|
1514
|
+
// catch-all is a guess about an unrecognised binary's operands, and this loop
|
|
1515
|
+
// feeds the one unconditional rule in the policy: a guess may raise a
|
|
1516
|
+
// command's class, which role, tier and approval still get to weigh in on,
|
|
1517
|
+
// but it must not manufacture a refusal nobody can override. Measured before
|
|
1518
|
+
// this parameter existed: `git commit -m 'reboot the worker pool'` was a hard
|
|
1519
|
+
// deny on an admin profile with `approvalPolicy: 'auto'`, because the catch-all
|
|
1520
|
+
// read the quoted commit message as an operand starting with a forbidden word.
|
|
1521
|
+
// `$()`, backticks and `sh -c` are certain carriers, not guesses — the shell
|
|
1522
|
+
// really does run what they hold — and are unaffected: they are pushed by
|
|
1523
|
+
// `nestedCommands` regardless of this flag.
|
|
772
1524
|
if (depth >= MAX_NESTING_DEPTH)
|
|
773
1525
|
return null;
|
|
774
|
-
for (const inner of nestedCommands(command)) {
|
|
1526
|
+
for (const inner of nestedCommands(command, false)) {
|
|
775
1527
|
const match = findForbiddenMatch(inner, depth + 1);
|
|
776
1528
|
if (match !== null)
|
|
777
1529
|
return match;
|
|
@@ -874,7 +1626,9 @@ function effectiveCommandIndex(words) {
|
|
|
874
1626
|
*/
|
|
875
1627
|
function operandsAreData(words) {
|
|
876
1628
|
const idx = effectiveCommandIndex(words);
|
|
877
|
-
|
|
1629
|
+
if (idx === -1)
|
|
1630
|
+
return false;
|
|
1631
|
+
return READERS[stripPath(unquote(words[idx]))]?.operandsAreData === true;
|
|
878
1632
|
}
|
|
879
1633
|
/**
|
|
880
1634
|
* Whether an interpreter name is the value of the flag before it rather than a command.
|
|
@@ -904,59 +1658,169 @@ function operandsAreData(words) {
|
|
|
904
1658
|
function skippableBetweenFlags(word) {
|
|
905
1659
|
return word === '' || word.startsWith('-');
|
|
906
1660
|
}
|
|
907
|
-
|
|
1661
|
+
/**
|
|
1662
|
+
* Whether `word` is a spelling of `flag`, honouring case-insensitivity when asked.
|
|
1663
|
+
*
|
|
1664
|
+
* Scoped per call, never globally: pwsh/powershell's own parameter binder resolves
|
|
1665
|
+
* `-ENC`, `-Enc` and `-enc` to the same parameter, but `-E` and `-e` are two different
|
|
1666
|
+
* flags to perl. Every caller here is told explicitly, per invocation, whether the
|
|
1667
|
+
* interpreter it is matching against is one of the case-insensitive ones — folding case
|
|
1668
|
+
* is never the default.
|
|
1669
|
+
*/
|
|
1670
|
+
function sameFlag(word, flag, caseInsensitive) {
|
|
1671
|
+
return caseInsensitive ? word.toLowerCase() === flag.toLowerCase() : word === flag;
|
|
1672
|
+
}
|
|
1673
|
+
function isFlagValue(words, i, flags, caseInsensitive = false) {
|
|
908
1674
|
if (i === 0)
|
|
909
1675
|
return false;
|
|
910
1676
|
const previous = words[i - 1];
|
|
911
|
-
|
|
1677
|
+
if (!(previous.length > 1 && previous.startsWith('-')))
|
|
1678
|
+
return false;
|
|
1679
|
+
return flags.some((f) => sameFlag(previous, f, caseInsensitive));
|
|
912
1680
|
}
|
|
913
1681
|
/**
|
|
914
1682
|
* The program an interpreter was handed on its command line, or null.
|
|
915
1683
|
*
|
|
916
1684
|
* Only flags may sit between the interpreter and its flag; anything else means this was
|
|
917
1685
|
* not that kind of invocation, which is what keeps `python3 script.py` — a program this
|
|
918
|
-
* cannot read either, but one every deployment runs — out of the gate.
|
|
1686
|
+
* cannot read either, but one every deployment runs — out of the gate. That rule holds
|
|
1687
|
+
* for every interpreter here except pwsh/powershell (see `tolerateUnknownWordsAtHead`):
|
|
1688
|
+
* real POSIX interpreters stop parsing their own options at the first positional
|
|
1689
|
+
* argument — `python3 script.py -c 'evil'` hands `-c evil` to the script as `argv`, not
|
|
1690
|
+
* to python — so treating a bare word as "not this kind of invocation" is correct for
|
|
1691
|
+
* them, not merely convenient.
|
|
1692
|
+
*
|
|
1693
|
+
* Returns which of `flags` matched alongside the program, not just the program text, so
|
|
1694
|
+
* a caller can tell `-EncodedCommand` from its `-e` abbreviation apart from every other
|
|
1695
|
+
* program-bearing word — decoding is specific to that one flag, and both spellings reach
|
|
1696
|
+
* here as an ordinary match.
|
|
1697
|
+
*
|
|
1698
|
+
* @param tolerateUnknownWordsAtHead pwsh/powershell's own argument parser walks the
|
|
1699
|
+
* whole command line looking for named parameters it recognises and does not stop at
|
|
1700
|
+
* an unrecognised one's value (`-ExecutionPolicy Bypass`) the way a POSIX interpreter
|
|
1701
|
+
* stops at its first positional argument. Only takes effect when `from` is the
|
|
1702
|
+
* segment's *effective* command word — `effectiveCommandIndex(words)`, which reads
|
|
1703
|
+
* past a privilege prefix or an exec wrapper (`env`, `nohup`, `timeout`, …), not merely
|
|
1704
|
+
* position 0. `from === 0` missed exactly the shape those wrappers exist to describe:
|
|
1705
|
+
* `env pwsh -ExecutionPolicy Bypass -EncodedCommand …` put `pwsh` at index 1, the
|
|
1706
|
+
* tolerance never engaged, and the interpreter loop's own flag-walk gave up at
|
|
1707
|
+
* `Bypass` before ever reaching `-EncodedCommand` — classified `safe`. Still unambiguous
|
|
1708
|
+
* about *which* word is being invoked, not a value or a search term this file has no
|
|
1709
|
+
* business reinterpreting (`grep -e perl -e python` never reaches here with this set,
|
|
1710
|
+
* because `perl` is not the segment's effective command word and isn't pwsh-family
|
|
1711
|
+
* regardless; nor is `customtool --search pwsh …`, where `customtool` is). Structural,
|
|
1712
|
+
* not a list of pwsh's value-taking flags: this file does not need to know
|
|
1713
|
+
* `-ExecutionPolicy` exists to stop being confused by it.
|
|
919
1714
|
*/
|
|
920
|
-
function programAfterFlag(words, from, flags) {
|
|
1715
|
+
function programAfterFlag(words, from, flags, caseInsensitive = false, tolerateUnknownWordsAtHead = false) {
|
|
1716
|
+
const tolerateUnknownWords = tolerateUnknownWordsAtHead && from === effectiveCommandIndex(words);
|
|
921
1717
|
for (let j = from + 1; j < words.length; j++) {
|
|
922
1718
|
const word = words[j];
|
|
923
|
-
|
|
924
|
-
|
|
1719
|
+
const exact = flags.find((f) => sameFlag(word, f, caseInsensitive));
|
|
1720
|
+
if (exact !== undefined) {
|
|
1721
|
+
// A subcommand — a program-bearing word that is not itself a `-` flag, such as
|
|
1722
|
+
// `deno`'s `eval` — may still have its own options before the code: `deno eval
|
|
1723
|
+
// --unstable <code>` is one invocation, not two. A real flag like `-c` or
|
|
1724
|
+
// `-Command` never has anything of its own between it and the program, so this
|
|
1725
|
+
// only widens the subcommand case.
|
|
1726
|
+
let k = j + 1;
|
|
1727
|
+
if (!exact.startsWith('-')) {
|
|
1728
|
+
while (k < words.length && skippableBetweenFlags(words[k]))
|
|
1729
|
+
k++;
|
|
1730
|
+
}
|
|
1731
|
+
const program = words[k];
|
|
1732
|
+
return program === undefined ? null : { program, flag: exact, index: k };
|
|
1733
|
+
}
|
|
1734
|
+
// Deliberately case-SENSITIVE even for pwsh/powershell: this branch exists for a
|
|
1735
|
+
// value glued directly onto a short flag with no separating space (`-csudo id`).
|
|
1736
|
+
// Folding case here as well made `-ExecutionPolicy` — a real pwsh option this file
|
|
1737
|
+
// does not otherwise track — case-insensitively start with `-e` and get read as
|
|
1738
|
+
// `-e` plus an attached `xecutionPolicy`, which swallowed `-ExecutionPolicy Bypass
|
|
1739
|
+
// -Command 'sudo id'` into `destructive` and, worse, made `foundProgram` true so
|
|
1740
|
+
// the catch-all below never ran. Every case-insensitive spelling the table needs
|
|
1741
|
+
// to accept (`-enc`, `-ec`, `-EC`, `-ENC`, `-E`, `-EncodedCOMMAND`, `-COMMAND`,
|
|
1742
|
+
// `-C`, …) is already reached through the exact match above or the cluster match
|
|
1743
|
+
// below, so this branch does not need to fold case to cover them.
|
|
925
1744
|
const attached = flags.find((f) => word.startsWith(f) && word.length > f.length);
|
|
926
1745
|
if (attached !== undefined) {
|
|
927
1746
|
const rest = word.slice(attached.length);
|
|
928
1747
|
// `sh -c'sudo id'` tokenises to `-csudo id`, so the program is attached. `bash -cx`
|
|
929
1748
|
// is a flag cluster and the program is the next word. A space, or the `=` of
|
|
930
1749
|
// `--eval=…`, is what tells them apart: a cluster is letters only.
|
|
931
|
-
if (/\s/.test(rest) || rest.startsWith('='))
|
|
932
|
-
return rest.replace(/^=/, '');
|
|
933
|
-
|
|
1750
|
+
if (/\s/.test(rest) || rest.startsWith('=')) {
|
|
1751
|
+
return { program: rest.replace(/^=/, ''), flag: attached, index: j };
|
|
1752
|
+
}
|
|
1753
|
+
const program = words[j + 1];
|
|
1754
|
+
return program === undefined ? null : { program, flag: attached, index: j + 1 };
|
|
934
1755
|
}
|
|
935
1756
|
// The program flag need not lead the cluster: `bash -xc 'sudo id'` runs exactly what
|
|
936
1757
|
// `bash -cx 'sudo id'` runs, and a prefix test saw the second and missed the first.
|
|
937
|
-
|
|
938
|
-
|
|
939
|
-
|
|
1758
|
+
const clusterFlag = clusterCarriesFlag(word, flags, caseInsensitive);
|
|
1759
|
+
if (clusterFlag !== null) {
|
|
1760
|
+
const program = words[j + 1];
|
|
1761
|
+
return program === undefined ? null : { program, flag: clusterFlag, index: j + 1 };
|
|
1762
|
+
}
|
|
1763
|
+
if (!skippableBetweenFlags(word) && !tolerateUnknownWords)
|
|
940
1764
|
return null;
|
|
941
1765
|
}
|
|
942
1766
|
return null;
|
|
943
1767
|
}
|
|
1768
|
+
/** How much base64 is worth decoding before the answer stops changing. */
|
|
1769
|
+
const MAX_ENCODED_CHARS = 64 * 1024;
|
|
944
1770
|
/**
|
|
945
|
-
*
|
|
1771
|
+
* The command inside `-EncodedCommand`, or null.
|
|
1772
|
+
*
|
|
1773
|
+
* PowerShell encodes UTF-16LE, so decoding as utf8 yields text with a NUL between
|
|
1774
|
+
* every character and no pattern matches it. `Buffer.from(x, 'base64')` never
|
|
1775
|
+
* throws — it drops characters outside the alphabet — so malformed input produces
|
|
1776
|
+
* a wrong answer rather than an exception, and the caller must treat null and
|
|
1777
|
+
* nonsense alike: the flag alone has already made the command `destructive`.
|
|
1778
|
+
*
|
|
1779
|
+
* Reads a bounded, 4-aligned PREFIX rather than refusing outright past the limit. A
|
|
1780
|
+
* refusal is a downgrade path: it turns "pad the payload past 64 KiB" into a way to
|
|
1781
|
+
* trade `privileged` for `destructive`, which is strictly better for whoever is padding
|
|
1782
|
+
* it. 4-aligned because base64 decodes in groups of four characters to three bytes;
|
|
1783
|
+
* truncating mid-group corrupts the last partial character instead of just dropping
|
|
1784
|
+
* trailing content the decode was never going to reach anyway.
|
|
1785
|
+
*/
|
|
1786
|
+
function decodedPowerShellCommand(operand) {
|
|
1787
|
+
const prefix = operand.length > MAX_ENCODED_CHARS
|
|
1788
|
+
? operand.slice(0, MAX_ENCODED_CHARS - (MAX_ENCODED_CHARS % 4))
|
|
1789
|
+
: operand;
|
|
1790
|
+
const decoded = Buffer.from(prefix, 'base64').toString('utf16le');
|
|
1791
|
+
return decoded.includes('�') || decoded.trim() === '' ? null : decoded;
|
|
1792
|
+
}
|
|
1793
|
+
/**
|
|
1794
|
+
* What follows a short flag inside a single-dash cluster, or the `flags` entry it
|
|
1795
|
+
* carries, or null if it carries none.
|
|
946
1796
|
*
|
|
947
1797
|
* Clusters only — a run of single letters after one dash. Long flags and attached values
|
|
948
|
-
* are handled before this is reached.
|
|
1798
|
+
* are handled before this is reached. Returns the canonical flag rather than a boolean
|
|
1799
|
+
* for the same reason `programAfterFlag` does: a caller deciding whether to decode
|
|
1800
|
+
* compares against the table's own spellings, not against whatever letters happened to
|
|
1801
|
+
* be clustered together.
|
|
949
1802
|
*/
|
|
950
|
-
function clusterCarriesFlag(word, flags) {
|
|
1803
|
+
function clusterCarriesFlag(word, flags, caseInsensitive = false) {
|
|
951
1804
|
// Three letters at most. `/^-[A-Za-z]+$/` alone also matches every single-dash long
|
|
952
1805
|
// option, and `find`'s predicates are full of them: `-type` contains perl's `-e`, so
|
|
953
1806
|
// `find . -name perl -type f` read as a carrier and asked for approval.
|
|
954
1807
|
if (!/^-[A-Za-z]{2,3}$/.test(word))
|
|
955
|
-
return
|
|
956
|
-
const body = word.slice(1);
|
|
1808
|
+
return null;
|
|
1809
|
+
const body = caseInsensitive ? word.slice(1).toLowerCase() : word.slice(1);
|
|
1810
|
+
const shortFlags = flags.filter((flag) => flag.length === 2);
|
|
957
1811
|
// A cluster is letters only, so whatever follows the program flag is more flags — the
|
|
958
1812
|
// program itself is always the next word.
|
|
959
|
-
|
|
1813
|
+
//
|
|
1814
|
+
// Scanned by the BODY's own character order, not the table's order: `-ec` must
|
|
1815
|
+
// resolve to `-e` (so a caller deciding whether to decode sees `-e`), not to `-c`
|
|
1816
|
+
// just because `-c` happens to sit first in pwsh's `programBearingWords`. The first
|
|
1817
|
+
// letter in the cluster is the one the invocation leads with.
|
|
1818
|
+
for (const letter of body) {
|
|
1819
|
+
const match = shortFlags.find((flag) => (caseInsensitive ? flag[1].toLowerCase() : flag[1]) === letter);
|
|
1820
|
+
if (match !== undefined)
|
|
1821
|
+
return match;
|
|
1822
|
+
}
|
|
1823
|
+
return null;
|
|
960
1824
|
}
|
|
961
1825
|
/** Spellings of "the program is on standard input" that look like a file operand. */
|
|
962
1826
|
const STDIN_PATHS = new Set(['-', '/dev/stdin', '/dev/fd/0', '/proc/self/fd/0']);
|
|
@@ -975,10 +1839,10 @@ function readsProgramFromStdin(words) {
|
|
|
975
1839
|
const bin = stripPath(words[idx]);
|
|
976
1840
|
// Interpreters only. awk's one program-from-stdin form is `awk -f -`, already gated as a
|
|
977
1841
|
// file flag; every other piped awk reads data, not a program.
|
|
978
|
-
const spec =
|
|
1842
|
+
const spec = resolveInterpreter(bin);
|
|
979
1843
|
if (spec === undefined)
|
|
980
1844
|
return false;
|
|
981
|
-
const flags = spec.
|
|
1845
|
+
const flags = spec.programBearingWords;
|
|
982
1846
|
let sawStdinFlag = false;
|
|
983
1847
|
for (let j = idx + 1; j < words.length; j++) {
|
|
984
1848
|
const word = words[j];
|
|
@@ -1074,12 +1938,16 @@ function hasUnreadableProgram(command) {
|
|
|
1074
1938
|
return true;
|
|
1075
1939
|
if (!operandsAreData(words)) {
|
|
1076
1940
|
for (let j = 0; j < words.length; j++) {
|
|
1077
|
-
const spec =
|
|
1941
|
+
const spec = resolveInterpreter(stripPath(unquote(words[j])));
|
|
1078
1942
|
if (spec === undefined || spec.readable)
|
|
1079
1943
|
continue;
|
|
1080
|
-
|
|
1944
|
+
// Same case-insensitivity as `nestedCommands`'s interpreter loop, and for the
|
|
1945
|
+
// same reason: pwsh/powershell's own parameter binder does not distinguish
|
|
1946
|
+
// `-EC` from `-ec`, so neither should this.
|
|
1947
|
+
const caseInsensitive = spec.programBearingWords.includes('-EncodedCommand');
|
|
1948
|
+
if (isFlagValue(words, j, spec.programBearingWords, caseInsensitive))
|
|
1081
1949
|
continue;
|
|
1082
|
-
if (programAfterFlag(words, j, spec.
|
|
1950
|
+
if (programAfterFlag(words, j, spec.programBearingWords, caseInsensitive, caseInsensitive) !== null)
|
|
1083
1951
|
return true;
|
|
1084
1952
|
}
|
|
1085
1953
|
}
|
|
@@ -1190,7 +2058,19 @@ export function classifyCommand(command, depth = 0) {
|
|
|
1190
2058
|
if (CLASS_RANK[parsed.class] > CLASS_RANK[highest.class])
|
|
1191
2059
|
highest = parsed;
|
|
1192
2060
|
}
|
|
1193
|
-
|
|
2061
|
+
// `readOnlyRejection` only ever describes the outer command's own grammar
|
|
2062
|
+
// rejection, and only means anything when that is also what decided the
|
|
2063
|
+
// final class. `highest === outer` (reference equality) is exactly that
|
|
2064
|
+
// condition: the synthetic floor and the nested-command loop above only
|
|
2065
|
+
// ever replace `highest` with something of strictly higher rank than
|
|
2066
|
+
// `outer`'s, so if either one fired, `highest` is no longer `outer` and the
|
|
2067
|
+
// rejection — which was never a fact about the synthetic verb or the nested
|
|
2068
|
+
// command anyway — is correctly left off.
|
|
2069
|
+
const result = { binary: highest.binary, fullCommand: trimmed, class: highest.class };
|
|
2070
|
+
if (highest === outer && outer.readOnlyRejection) {
|
|
2071
|
+
result.readOnlyRejection = outer.readOnlyRejection;
|
|
2072
|
+
}
|
|
2073
|
+
return result;
|
|
1194
2074
|
}
|
|
1195
2075
|
/** The class of the command itself, reading none of what it carries. */
|
|
1196
2076
|
function classifyOuter(trimmed) {
|
|
@@ -1218,14 +2098,74 @@ function classifyOuter(trimmed) {
|
|
|
1218
2098
|
if (hasUnnameableCommand(trimmed)) {
|
|
1219
2099
|
return { binary, fullCommand, class: 'destructive' };
|
|
1220
2100
|
}
|
|
1221
|
-
const
|
|
2101
|
+
const words = tokenizeSegments(fullCommand)[0] ?? [];
|
|
2102
|
+
const twoWordPrefix = words.slice(0, 2).join(' ');
|
|
1222
2103
|
if (READ_ONLY_ALLOWLIST.has(binary) || READ_ONLY_ALLOWLIST.has(twoWordPrefix)
|
|
1223
2104
|
|| READ_ONLY_SYNTHETIC.has(binary)) {
|
|
1224
2105
|
if (SHELL_CONTROL_CHARS.test(trimmed)) {
|
|
1225
2106
|
return { binary, fullCommand, class: 'safe' };
|
|
1226
2107
|
}
|
|
2108
|
+
// The SFTP verbs are synthesised by the tool layer, never typed by a caller,
|
|
2109
|
+
// so there is no argv for a grammar to read — READ_ONLY_SYNTHETIC stays
|
|
2110
|
+
// exactly as it was.
|
|
2111
|
+
if (READ_ONLY_SYNTHETIC.has(binary)) {
|
|
2112
|
+
return { binary, fullCommand, class: 'read-only' };
|
|
2113
|
+
}
|
|
2114
|
+
// Whichever key matched decides both which entry answers and how many
|
|
2115
|
+
// leading words are the command name rather than an argument: a two-word
|
|
2116
|
+
// entry's grammar reads words[2..], a one-word entry's reads words[1..].
|
|
2117
|
+
const matchedTwoWord = READ_ONLY_ALLOWLIST.has(twoWordPrefix);
|
|
2118
|
+
const readerName = matchedTwoWord ? twoWordPrefix : binary;
|
|
2119
|
+
const entry = READERS[readerName];
|
|
2120
|
+
const argWords = words.slice(matchedTwoWord ? 2 : 1);
|
|
2121
|
+
// An unquoted glob word is not a word this grammar can vouch for: the
|
|
2122
|
+
// shell replaces it with however many filenames it matches, and the
|
|
2123
|
+
// grammar's operand count — the thing it was about to prove — is false
|
|
2124
|
+
// before the binary runs. `'any'` entries are exempt: their claim is that
|
|
2125
|
+
// no option or operand of the binary writes or executes, so extra
|
|
2126
|
+
// operands from an expansion are still data.
|
|
2127
|
+
if (entry.grammar.args !== 'any') {
|
|
2128
|
+
const globWord = firstUnquotedGlobWord(fullCommand);
|
|
2129
|
+
if (globWord !== null) {
|
|
2130
|
+
return {
|
|
2131
|
+
binary,
|
|
2132
|
+
fullCommand,
|
|
2133
|
+
class: 'safe',
|
|
2134
|
+
readOnlyRejection: { binary: readerName, word: globWord },
|
|
2135
|
+
};
|
|
2136
|
+
}
|
|
2137
|
+
}
|
|
2138
|
+
const match = matchesGrammar(argWords, entry.grammar);
|
|
2139
|
+
if (!match.ok) {
|
|
2140
|
+
return {
|
|
2141
|
+
binary,
|
|
2142
|
+
fullCommand,
|
|
2143
|
+
class: 'safe',
|
|
2144
|
+
readOnlyRejection: { binary: readerName, word: match.word },
|
|
2145
|
+
};
|
|
2146
|
+
}
|
|
1227
2147
|
return { binary, fullCommand, class: 'read-only' };
|
|
1228
2148
|
}
|
|
1229
2149
|
return { binary, fullCommand, class: 'safe' };
|
|
1230
2150
|
}
|
|
1231
|
-
|
|
2151
|
+
/**
|
|
2152
|
+
* The sentence a refusal appends when it carries a `readOnlyRejection`, worded
|
|
2153
|
+
* so the reason names the binary that would have qualified and the argv word
|
|
2154
|
+
* that cost it: "is not accepted there" rather than "is not one of them",
|
|
2155
|
+
* because most rejections are operand rules (`hostname NAME`, `sort /data`) or
|
|
2156
|
+
* a listed value flag missing its value, where "is not one of them" would be
|
|
2157
|
+
* false — it may be a perfectly good flag name, just not one bare or in this
|
|
2158
|
+
* position. One function so the engine's role-binding denial and
|
|
2159
|
+
* `read-command`'s `enforceClass` refusal never drift into two spellings of
|
|
2160
|
+
* the same sentence.
|
|
2161
|
+
*/
|
|
2162
|
+
function formatReadOnlyRejection(rejection) {
|
|
2163
|
+
// The rejected word can be the empty string — a quoted empty operand
|
|
2164
|
+
// (`hostname ""`) — and two bare backticks would render as a blank where
|
|
2165
|
+
// the word that cost the class should be. `""` is the shell's own spelling
|
|
2166
|
+
// of that operand.
|
|
2167
|
+
const word = rejection.word === '' ? '""' : rejection.word;
|
|
2168
|
+
return `\`${rejection.binary}\` is read-only only with the options and operands its grammar lists; ` +
|
|
2169
|
+
`\`${word}\` is not accepted there.`;
|
|
2170
|
+
}
|
|
2171
|
+
export { READ_ONLY_ALLOWLIST, READERS, READ_ONLY_SYNTHETIC, isDestructive, formatReadOnlyRejection, };
|