ssh-mcp 2.12.0 → 2.14.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 +702 -153
- 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,19 +18,106 @@ 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]/;
|
|
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, dialect = 'posix') {
|
|
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 (dialect === 'posix' && 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 (dialect === 'posix' && 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, dialect);
|
|
94
|
+
return globInWord ? word : null;
|
|
95
|
+
}
|
|
20
96
|
/**
|
|
21
97
|
* The binaries this classifier will vouch for, and what it vouches for about them.
|
|
22
98
|
*
|
|
23
|
-
*
|
|
24
|
-
* interpreter carrier scan off for two verbs by adding them to a list
|
|
25
|
-
* classes:
|
|
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:
|
|
26
102
|
*
|
|
27
103
|
* readOnly does this binary only read? -> decides the class
|
|
28
104
|
* operandsAreData can its operands hide a command? -> decides whether the
|
|
29
105
|
* carrier scan runs
|
|
30
|
-
*
|
|
31
|
-
*
|
|
32
|
-
*
|
|
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`.
|
|
33
121
|
*
|
|
34
122
|
* Two mechanisms answer the second question outside this table and are not
|
|
35
123
|
* affected by it — `DISQUALIFYING_ARGS` and `FIND_EXEC_FLAGS` — which is why
|
|
@@ -37,77 +125,415 @@ const SHELL_CONTROL_CHARS = /[;&|<>`$(){}\n\r]/;
|
|
|
37
125
|
* still `privileged`.
|
|
38
126
|
*
|
|
39
127
|
* The two-word entries are looked up only for the class: `operandsAreData` reads
|
|
40
|
-
* a single word, so those rows never reach the second question.
|
|
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).
|
|
41
130
|
*/
|
|
42
131
|
const READERS = {
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
"
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
"
|
|
62
|
-
"
|
|
63
|
-
"
|
|
64
|
-
"
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
"
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
"
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
"
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
"
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
"
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
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' } },
|
|
111
537
|
};
|
|
112
538
|
/** The class half of READERS, as the shape its consumers already expect. */
|
|
113
539
|
const READ_ONLY_ALLOWLIST = new Set(Object.entries(READERS).filter(([, e]) => e.readOnly).map(([name]) => name));
|
|
@@ -396,9 +822,11 @@ const MAX_NESTING_DEPTH = 8;
|
|
|
396
822
|
* Not a shell parser. Variable expansion, arithmetic and here-documents are out of scope —
|
|
397
823
|
* `hasUnnameableCommand` is what refuses a command word this cannot resolve.
|
|
398
824
|
*/
|
|
399
|
-
function tokenizeSegments(command) {
|
|
400
|
-
return tokenizeSegmentsDetailed(command).map((segment) => segment.words);
|
|
825
|
+
function tokenizeSegments(command, dialect = 'posix') {
|
|
826
|
+
return tokenizeSegmentsDetailed(command, true, dialect).map((segment) => segment.words);
|
|
401
827
|
}
|
|
828
|
+
/** Inside double quotes, the characters POSIX lets a backslash escape. */
|
|
829
|
+
const DOUBLE_QUOTE_ESCAPABLE = new Set(['$', '`', '"', '\\', '\n']);
|
|
402
830
|
/**
|
|
403
831
|
* The same split, keeping the separator that introduced each segment.
|
|
404
832
|
*
|
|
@@ -406,7 +834,7 @@ function tokenizeSegments(command) {
|
|
|
406
834
|
* and `&` merely sequence, and a rule about pipes must not fire on a rule about sequences.
|
|
407
835
|
* Deriving both views from one tokeniser is what stops the two from drifting apart.
|
|
408
836
|
*/
|
|
409
|
-
function tokenizeSegmentsDetailed(command, honorQuotes = true) {
|
|
837
|
+
function tokenizeSegmentsDetailed(command, honorQuotes = true, dialect = 'posix') {
|
|
410
838
|
const segments = [];
|
|
411
839
|
let pending = '';
|
|
412
840
|
let words = [];
|
|
@@ -441,7 +869,8 @@ function tokenizeSegmentsDetailed(command, honorQuotes = true) {
|
|
|
441
869
|
words = [];
|
|
442
870
|
pending = sep;
|
|
443
871
|
};
|
|
444
|
-
for (
|
|
872
|
+
for (let i = 0; i < command.length; i++) {
|
|
873
|
+
const ch = command[i];
|
|
445
874
|
if (escaped) {
|
|
446
875
|
// A backslash quotes the next character, so `\reboot` runs reboot. Keeping the
|
|
447
876
|
// character and dropping the backslash is what the shell does.
|
|
@@ -451,8 +880,18 @@ function tokenizeSegmentsDetailed(command, honorQuotes = true) {
|
|
|
451
880
|
}
|
|
452
881
|
if (quote) {
|
|
453
882
|
// Backslash is literal inside single quotes; inside double quotes it escapes.
|
|
454
|
-
if (ch === '\\' && quote === '"') {
|
|
455
|
-
|
|
883
|
+
if (dialect === 'posix' && ch === '\\' && quote === '"') {
|
|
884
|
+
const next = command[i + 1];
|
|
885
|
+
// POSIX keeps `\x` before an ordinary character: only $ ` " \ and a
|
|
886
|
+
// newline are escapable, so the backslash survives into the word
|
|
887
|
+
// (GHSA-972x-g47g-3922's second finding — dropping it misread both
|
|
888
|
+
// dialects at once). At end of string there is no next character to
|
|
889
|
+
// protect, and the open quote sends the whole scan to the fallback.
|
|
890
|
+
if (next === undefined || DOUBLE_QUOTE_ESCAPABLE.has(next)) {
|
|
891
|
+
escaped = true;
|
|
892
|
+
continue;
|
|
893
|
+
}
|
|
894
|
+
current += ch;
|
|
456
895
|
continue;
|
|
457
896
|
}
|
|
458
897
|
if (ch === quote)
|
|
@@ -461,7 +900,7 @@ function tokenizeSegmentsDetailed(command, honorQuotes = true) {
|
|
|
461
900
|
current += ch;
|
|
462
901
|
continue;
|
|
463
902
|
}
|
|
464
|
-
if (ch === '\\') {
|
|
903
|
+
if (dialect === 'posix' && ch === '\\') {
|
|
465
904
|
escaped = true;
|
|
466
905
|
continue;
|
|
467
906
|
}
|
|
@@ -487,7 +926,7 @@ function tokenizeSegmentsDetailed(command, honorQuotes = true) {
|
|
|
487
926
|
// `privileged` before this tokeniser and `safe` after. So fall back to the scan that
|
|
488
927
|
// treats the quote character as ordinary text, which splits and still sees `sudo id`.
|
|
489
928
|
if (quote !== null)
|
|
490
|
-
return tokenizeSegmentsDetailed(command, false);
|
|
929
|
+
return tokenizeSegmentsDetailed(command, false, dialect);
|
|
491
930
|
return segments;
|
|
492
931
|
}
|
|
493
932
|
/**
|
|
@@ -496,20 +935,26 @@ function tokenizeSegmentsDetailed(command, honorQuotes = true) {
|
|
|
496
935
|
* Segments are rejoined with `; ` so a pattern cannot match across two commands that the
|
|
497
936
|
* shell would run separately.
|
|
498
937
|
*/
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
function normalizeCommand(command) {
|
|
938
|
+
const normalizedCache = new Map();
|
|
939
|
+
function normalizeCommand(command, dialect) {
|
|
502
940
|
// One evaluate asks for this about nineteen times, once per regex rule, and each ask
|
|
503
941
|
// re-ran the tokeniser over the whole command. The asks arrive in a row on the same
|
|
504
|
-
// input, so a
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
942
|
+
// input, so a small cache, evicting the oldest inserted entry, removes almost
|
|
943
|
+
// all of it. The key carries the dialect because both readings of one command
|
|
944
|
+
// are asked about in the same row.
|
|
945
|
+
const key = `${dialect}\u0000${command}`;
|
|
946
|
+
const hit = normalizedCache.get(key);
|
|
947
|
+
if (hit !== undefined)
|
|
948
|
+
return hit;
|
|
949
|
+
const value = normalizeUncached(command, dialect);
|
|
950
|
+
normalizedCache.set(key, value);
|
|
951
|
+
if (normalizedCache.size > 8) {
|
|
952
|
+
normalizedCache.delete(normalizedCache.keys().next().value);
|
|
953
|
+
}
|
|
954
|
+
return value;
|
|
510
955
|
}
|
|
511
|
-
function normalizeUncached(command) {
|
|
512
|
-
return tokenizeSegments(command)
|
|
956
|
+
function normalizeUncached(command, dialect) {
|
|
957
|
+
return tokenizeSegments(command, dialect)
|
|
513
958
|
.map((words) => words.map((w) => (QUOTED_CONTENT.test(w) ? PLACEHOLDER : w)).join(' '))
|
|
514
959
|
.join('; ');
|
|
515
960
|
}
|
|
@@ -550,8 +995,8 @@ const PLACEHOLDER = '\u0000';
|
|
|
550
995
|
* inside a quoted argument into a real one — `grep -E "warn|reboot" syslog` became an
|
|
551
996
|
* unconditional refusal, which is the mention-vs-invocation bug of #91 all over again.
|
|
552
997
|
*/
|
|
553
|
-
function matchesEitherForm(command, test) {
|
|
554
|
-
return test(command) || test(normalizeCommand(command));
|
|
998
|
+
function matchesEitherForm(command, test, dialect) {
|
|
999
|
+
return test(command) || test(normalizeCommand(command, dialect));
|
|
555
1000
|
}
|
|
556
1001
|
/**
|
|
557
1002
|
* The commands hiding inside a command.
|
|
@@ -582,7 +1027,7 @@ function matchesEitherForm(command, test) {
|
|
|
582
1027
|
* backticks and `sh -c` are certain carriers and keep reaching the
|
|
583
1028
|
* denylist either way.
|
|
584
1029
|
*/
|
|
585
|
-
export function nestedCommands(command, speculativeOperands = true) {
|
|
1030
|
+
export function nestedCommands(command, speculativeOperands = true, dialect = 'posix') {
|
|
586
1031
|
const found = [];
|
|
587
1032
|
// `$(...)`, `<(...)`, `>(...)` — scanned rather than matched, because a regex
|
|
588
1033
|
// cannot balance parentheses and `echo $(echo $(sudo id))` is the case that
|
|
@@ -620,7 +1065,7 @@ export function nestedCommands(command, speculativeOperands = true) {
|
|
|
620
1065
|
// scan above does not see it. Read each segment's command word rather than scanning
|
|
621
1066
|
// every word: matching a mention rather than an invocation is #91, and
|
|
622
1067
|
// `cat /usr/bin/python3` names an interpreter without running one.
|
|
623
|
-
const segments = tokenizeSegmentsDetailed(command);
|
|
1068
|
+
const segments = tokenizeSegmentsDetailed(command, true, dialect);
|
|
624
1069
|
for (const { words } of segments) {
|
|
625
1070
|
// Every word, not just the command word. Stopping at the head lost the carrier behind
|
|
626
1071
|
// any wrapper this file does not list: `xargs -I {} sh -c 'sudo id'` stops on `{}`, and
|
|
@@ -851,9 +1296,16 @@ const BARE_NUMERIC = /^\d+(\.\d+)?[smhd]?$/;
|
|
|
851
1296
|
* `\sudo`, `'sudo'` and `"sudo"` all execute sudo — the backslash only
|
|
852
1297
|
* suppresses alias expansion — but a verbatim string comparison sees three
|
|
853
1298
|
* different words. Without this, a one-character edit walks around the check.
|
|
1299
|
+
*
|
|
1300
|
+
* One surrounding quote pair, and nothing else: the scanner has already
|
|
1301
|
+
* resolved escapes by the time a word reaches this, in both the
|
|
1302
|
+
* quote-honouring scan and the `honorQuotes = false` fallback, so a second
|
|
1303
|
+
* pass here could only remove backslashes the dialect meant to keep — the
|
|
1304
|
+
* literal ones inside single quotes, and the POSIX-retained ones inside
|
|
1305
|
+
* double quotes (GHSA-972x-g47g-3922).
|
|
854
1306
|
*/
|
|
855
1307
|
function unquote(word) {
|
|
856
|
-
return word.replace(/^(['"])(.*)\1$/, '$2')
|
|
1308
|
+
return word.replace(/^(['"])(.*)\1$/, '$2');
|
|
857
1309
|
}
|
|
858
1310
|
/**
|
|
859
1311
|
* Privilege-prefix flags that consume the next argument, so `sudo -u root
|
|
@@ -865,14 +1317,14 @@ const PREFIX_VALUE_FLAGS = new Set([
|
|
|
865
1317
|
'--user', '--group', '--prompt', '--close-from', '--host', '--role', '--type',
|
|
866
1318
|
'--other-user', '--command',
|
|
867
1319
|
]);
|
|
868
|
-
/** `/sbin/reboot` and `reboot` are the same invocation. */
|
|
1320
|
+
/** `/sbin/reboot`, `C:\Windows\reboot.exe` and `reboot` are the same invocation. */
|
|
869
1321
|
function stripPath(word) {
|
|
870
|
-
const
|
|
871
|
-
return
|
|
1322
|
+
const cut = Math.max(word.lastIndexOf('/'), word.lastIndexOf('\\'));
|
|
1323
|
+
return cut === -1 ? word : word.slice(cut + 1);
|
|
872
1324
|
}
|
|
873
|
-
function parseSegments(command) {
|
|
1325
|
+
function parseSegments(command, dialect = 'posix') {
|
|
874
1326
|
const segments = [];
|
|
875
|
-
for (const words of tokenizeSegments(command)) {
|
|
1327
|
+
for (const words of tokenizeSegments(command, dialect)) {
|
|
876
1328
|
const segment = parseWords(words);
|
|
877
1329
|
if (segment !== null)
|
|
878
1330
|
segments.push(segment);
|
|
@@ -957,8 +1409,8 @@ function elevatedBinary(words) {
|
|
|
957
1409
|
* holding, and `echo hi; sudo id` recorded `binary: "echo"` against a
|
|
958
1410
|
* privileged decision — in the audit log, the OTel span and OPA's input (#134).
|
|
959
1411
|
*/
|
|
960
|
-
function elevatedBinaryOf(command) {
|
|
961
|
-
for (const words of tokenizeSegments(command)) {
|
|
1412
|
+
function elevatedBinaryOf(command, dialect = 'posix') {
|
|
1413
|
+
for (const words of tokenizeSegments(command, dialect)) {
|
|
962
1414
|
const found = elevatedBinary(words);
|
|
963
1415
|
if (found !== null)
|
|
964
1416
|
return found;
|
|
@@ -985,8 +1437,8 @@ function elevatedBinaryOf(command) {
|
|
|
985
1437
|
* `invokedWords` also reads it, and widening what counts as "the command"
|
|
986
1438
|
* there is a different, larger change this fix does not take on.
|
|
987
1439
|
*/
|
|
988
|
-
function hasDisqualifyingArgs(command) {
|
|
989
|
-
return tokenizeSegments(command).some((words) => {
|
|
1440
|
+
function hasDisqualifyingArgs(command, dialect = 'posix') {
|
|
1441
|
+
return tokenizeSegments(command, dialect).some((words) => {
|
|
990
1442
|
const idx = effectiveCommandIndex(words);
|
|
991
1443
|
if (idx === -1)
|
|
992
1444
|
return false;
|
|
@@ -994,9 +1446,9 @@ function hasDisqualifyingArgs(command) {
|
|
|
994
1446
|
return rule !== undefined && words.slice(idx + 1).some((arg) => rule.test(arg));
|
|
995
1447
|
});
|
|
996
1448
|
}
|
|
997
|
-
function invokedWords(command) {
|
|
1449
|
+
function invokedWords(command, dialect = 'posix') {
|
|
998
1450
|
const invoked = [];
|
|
999
|
-
for (const { head, args } of parseSegments(command)) {
|
|
1451
|
+
for (const { head, args } of parseSegments(command, dialect)) {
|
|
1000
1452
|
invoked.push(head);
|
|
1001
1453
|
// `systemctl reboot` restarts the host. Reading a unit that happens to be
|
|
1002
1454
|
// named after a power action is rare enough that erring towards refusal
|
|
@@ -1018,11 +1470,11 @@ const DOWNLOADERS = new Set(['curl', 'wget']);
|
|
|
1018
1470
|
* runs a shell when the download fails, which is not meaningfully safer than
|
|
1019
1471
|
* running one when it succeeds.
|
|
1020
1472
|
*/
|
|
1021
|
-
function pipesDownloadIntoShell(command) {
|
|
1473
|
+
function pipesDownloadIntoShell(command, dialect = 'posix') {
|
|
1022
1474
|
// One head per pipe stage. Splitting on every separator would make `curl -O x;
|
|
1023
1475
|
// bash build.sh` — download, then run a local script — match a rule whose label says
|
|
1024
1476
|
// "piping a download into a shell", on a list that cannot be switched off.
|
|
1025
|
-
const segments = tokenizeSegmentsDetailed(command);
|
|
1477
|
+
const segments = tokenizeSegmentsDetailed(command, true, dialect);
|
|
1026
1478
|
const heads = segments
|
|
1027
1479
|
.filter((segment, i) => i === 0 || segment.sep === '|')
|
|
1028
1480
|
.map((segment) => parseWords(segment.words)?.head);
|
|
@@ -1032,8 +1484,8 @@ function pipesDownloadIntoShell(command) {
|
|
|
1032
1484
|
return heads.slice(firstDownload + 1).some((h) => h !== undefined && SHELLS.has(h));
|
|
1033
1485
|
}
|
|
1034
1486
|
/** `dd … of=/dev/sda` — writing an image straight onto a block device. */
|
|
1035
|
-
function writesToDevice(command) {
|
|
1036
|
-
return parseSegments(command).some(({ head, args }) => head === 'dd' && args.some((a) => a.startsWith('of=/dev/')));
|
|
1487
|
+
function writesToDevice(command, dialect = 'posix') {
|
|
1488
|
+
return parseSegments(command, dialect).some(({ head, args }) => head === 'dd' && args.some((a) => a.startsWith('of=/dev/')));
|
|
1037
1489
|
}
|
|
1038
1490
|
/**
|
|
1039
1491
|
* `chown -R … /` — a recursive chown whose target is the filesystem root.
|
|
@@ -1041,8 +1493,8 @@ function writesToDevice(command) {
|
|
|
1041
1493
|
* The last non-flag argument is the target; `chown -R app:app /srv/app` is
|
|
1042
1494
|
* ordinary and stays allowed.
|
|
1043
1495
|
*/
|
|
1044
|
-
function chownsRoot(command) {
|
|
1045
|
-
return parseSegments(command).some(({ head, args }) => {
|
|
1496
|
+
function chownsRoot(command, dialect = 'posix') {
|
|
1497
|
+
return parseSegments(command, dialect).some(({ head, args }) => {
|
|
1046
1498
|
if (head !== 'chown' || !args.includes('-R'))
|
|
1047
1499
|
return false;
|
|
1048
1500
|
const positional = args.filter((a) => !a.startsWith('-'));
|
|
@@ -1052,18 +1504,18 @@ function chownsRoot(command) {
|
|
|
1052
1504
|
const FORBIDDEN_RULES = [
|
|
1053
1505
|
...FORBIDDEN_PATTERNS.map((re) => ({
|
|
1054
1506
|
label: String(re),
|
|
1055
|
-
test: (c) => matchesEitherForm(c, (form) => re.test(form)),
|
|
1507
|
+
test: (c, dialect) => matchesEitherForm(c, (form) => re.test(form), dialect),
|
|
1056
1508
|
})),
|
|
1057
1509
|
{
|
|
1058
1510
|
label: 'invoking a power-state command (shutdown, reboot, halt, poweroff) or eval',
|
|
1059
|
-
test: (command) => invokedWords(command).some((w) => FORBIDDEN_INVOCATIONS.has(w)),
|
|
1511
|
+
test: (command, dialect) => invokedWords(command, dialect).some((w) => FORBIDDEN_INVOCATIONS.has(w)),
|
|
1060
1512
|
},
|
|
1061
1513
|
{
|
|
1062
1514
|
label: 'piping a download into a shell (curl or wget into sh, bash or zsh)',
|
|
1063
|
-
test: pipesDownloadIntoShell,
|
|
1515
|
+
test: (c, dialect) => pipesDownloadIntoShell(c, dialect),
|
|
1064
1516
|
},
|
|
1065
|
-
{ label: 'dd writing to a block device (of=/dev/…)', test: writesToDevice },
|
|
1066
|
-
{ label: 'a recursive chown of the filesystem root', test: chownsRoot },
|
|
1517
|
+
{ label: 'dd writing to a block device (of=/dev/…)', test: (c, dialect) => writesToDevice(c, dialect) },
|
|
1518
|
+
{ label: 'a recursive chown of the filesystem root', test: (c, dialect) => chownsRoot(c, dialect) },
|
|
1067
1519
|
];
|
|
1068
1520
|
/**
|
|
1069
1521
|
* Which never-allowed rule this command trips, or null. The single entry point:
|
|
@@ -1072,8 +1524,21 @@ const FORBIDDEN_RULES = [
|
|
|
1072
1524
|
* permit `sudo reboot`.
|
|
1073
1525
|
*/
|
|
1074
1526
|
export function findForbiddenMatch(command, depth = 0) {
|
|
1527
|
+
// The engine calls this directly, so it is a decision point of its own and
|
|
1528
|
+
// reads both dialects like `classifyCommand` does: a refusal under either
|
|
1529
|
+
// reading is a refusal. The destructive-class consultation *inside* a
|
|
1530
|
+
// dialect pass (`isDestructive`) stays in-dialect — that pass is already one
|
|
1531
|
+
// reading of the whole string.
|
|
1532
|
+
if (command.includes('\\')) {
|
|
1533
|
+
const windows = findForbiddenMatchInDialect(command, depth, 'windows');
|
|
1534
|
+
if (windows !== null)
|
|
1535
|
+
return windows;
|
|
1536
|
+
}
|
|
1537
|
+
return findForbiddenMatchInDialect(command, depth, 'posix');
|
|
1538
|
+
}
|
|
1539
|
+
function findForbiddenMatchInDialect(command, depth, dialect) {
|
|
1075
1540
|
for (const rule of FORBIDDEN_RULES) {
|
|
1076
|
-
if (rule.test(command))
|
|
1541
|
+
if (rule.test(command, dialect))
|
|
1077
1542
|
return rule.label;
|
|
1078
1543
|
}
|
|
1079
1544
|
// The same carriers the class scan reads, for the same reason. This list is the
|
|
@@ -1097,15 +1562,15 @@ export function findForbiddenMatch(command, depth = 0) {
|
|
|
1097
1562
|
// `nestedCommands` regardless of this flag.
|
|
1098
1563
|
if (depth >= MAX_NESTING_DEPTH)
|
|
1099
1564
|
return null;
|
|
1100
|
-
for (const inner of nestedCommands(command, false)) {
|
|
1101
|
-
const match =
|
|
1565
|
+
for (const inner of nestedCommands(command, false, dialect)) {
|
|
1566
|
+
const match = findForbiddenMatchInDialect(inner, depth + 1, dialect);
|
|
1102
1567
|
if (match !== null)
|
|
1103
1568
|
return match;
|
|
1104
1569
|
}
|
|
1105
1570
|
return null;
|
|
1106
1571
|
}
|
|
1107
|
-
export function isForbidden(command) {
|
|
1108
|
-
return
|
|
1572
|
+
export function isForbidden(command, dialect = 'posix') {
|
|
1573
|
+
return findForbiddenMatchInDialect(command, 0, dialect) !== null;
|
|
1109
1574
|
}
|
|
1110
1575
|
/**
|
|
1111
1576
|
* Commands that are destructive but legitimate under approval — e.g.
|
|
@@ -1122,9 +1587,9 @@ const DESTRUCTIVE_PATTERNS = [
|
|
|
1122
1587
|
* reading a log that mentions `reboot` is no longer classified destructive
|
|
1123
1588
|
* either.
|
|
1124
1589
|
*/
|
|
1125
|
-
function isDestructive(command) {
|
|
1126
|
-
return (isForbidden(command) ||
|
|
1127
|
-
matchesEitherForm(command, (form) => DESTRUCTIVE_PATTERNS.some((re) => re.test(form))));
|
|
1590
|
+
function isDestructive(command, dialect = 'posix') {
|
|
1591
|
+
return (isForbidden(command, dialect) ||
|
|
1592
|
+
matchesEitherForm(command, (form) => DESTRUCTIVE_PATTERNS.some((re) => re.test(form)), dialect));
|
|
1128
1593
|
}
|
|
1129
1594
|
const LEADING_PRIVILEGE_PREFIXES = [
|
|
1130
1595
|
/^\s*sudo\b/,
|
|
@@ -1132,13 +1597,13 @@ const LEADING_PRIVILEGE_PREFIXES = [
|
|
|
1132
1597
|
/^\s*doas\b/,
|
|
1133
1598
|
/^\s*pkexec\b/,
|
|
1134
1599
|
];
|
|
1135
|
-
export function extractBinary(command) {
|
|
1600
|
+
export function extractBinary(command, dialect = 'posix') {
|
|
1136
1601
|
// The first segment's words, so quoting is resolved — `s"u"do id` used to report
|
|
1137
1602
|
// `s"u"do` in the audit record and the refusal message. Reading the normalised whole
|
|
1138
1603
|
// command instead would be wrong in the other direction: it rejoins segments with
|
|
1139
1604
|
// `; `, so `ls | grep x` would report `ls;`, and this name reaches the audit record,
|
|
1140
1605
|
// the OTel span and OPA's input (#134). A separator is not a binary.
|
|
1141
|
-
let cmd = (tokenizeSegments(command)[0] ?? []).join(' ').trim();
|
|
1606
|
+
let cmd = (tokenizeSegments(command, dialect)[0] ?? []).join(' ').trim();
|
|
1142
1607
|
for (const prefix of LEADING_PRIVILEGE_PREFIXES) {
|
|
1143
1608
|
cmd = cmd.replace(prefix, '').trim();
|
|
1144
1609
|
}
|
|
@@ -1484,8 +1949,8 @@ function awkFindings(words) {
|
|
|
1484
1949
|
* `awk 'BEGIN{system("sudo id")}'` comes out `privileged` rather than flattened
|
|
1485
1950
|
* to the `destructive` this function reports.
|
|
1486
1951
|
*/
|
|
1487
|
-
function hasDangerousAwk(command) {
|
|
1488
|
-
for (const { words } of tokenizeSegmentsDetailed(command)) {
|
|
1952
|
+
function hasDangerousAwk(command, dialect = 'posix') {
|
|
1953
|
+
for (const { words } of tokenizeSegmentsDetailed(command, true, dialect)) {
|
|
1489
1954
|
const findings = awkFindings(words);
|
|
1490
1955
|
if (findings === null)
|
|
1491
1956
|
continue;
|
|
@@ -1497,15 +1962,15 @@ function hasDangerousAwk(command) {
|
|
|
1497
1962
|
// prints into it, and that is assembled at run time. So it is the same
|
|
1498
1963
|
// "we cannot tell" this module reports for `-f progfile`.
|
|
1499
1964
|
for (const target of findings.pipedInto) {
|
|
1500
|
-
const stage = tokenizeSegmentsDetailed(target)[0];
|
|
1965
|
+
const stage = tokenizeSegmentsDetailed(target, true, dialect)[0];
|
|
1501
1966
|
if (stage !== undefined && readsProgramFromStdin(stage.words))
|
|
1502
1967
|
return true;
|
|
1503
1968
|
}
|
|
1504
1969
|
}
|
|
1505
1970
|
return false;
|
|
1506
1971
|
}
|
|
1507
|
-
function hasUnreadableProgram(command) {
|
|
1508
|
-
const segments = tokenizeSegmentsDetailed(command);
|
|
1972
|
+
function hasUnreadableProgram(command, dialect = 'posix') {
|
|
1973
|
+
const segments = tokenizeSegmentsDetailed(command, true, dialect);
|
|
1509
1974
|
for (let i = 0; i < segments.length; i++) {
|
|
1510
1975
|
const { words, sep } = segments[i];
|
|
1511
1976
|
if (sep === '|' && readsProgramFromStdin(words))
|
|
@@ -1545,8 +2010,8 @@ function hasUnreadableProgram(command) {
|
|
|
1545
2010
|
* Only the command word, never the arguments. `echo $HOME` names a command we know;
|
|
1546
2011
|
* promoting that would put a prompt on most ordinary shell usage.
|
|
1547
2012
|
*/
|
|
1548
|
-
function hasUnnameableCommand(command) {
|
|
1549
|
-
for (const words of tokenizeSegments(command)) {
|
|
2013
|
+
function hasUnnameableCommand(command, dialect = 'posix') {
|
|
2014
|
+
for (const words of tokenizeSegments(command, dialect)) {
|
|
1550
2015
|
const head = effectiveCommandWord(words);
|
|
1551
2016
|
if (head !== null && /[$`]/.test(head))
|
|
1552
2017
|
return true;
|
|
@@ -1592,8 +2057,8 @@ const SYNTHETIC_CLASSES = Object.assign(Object.create(null), {
|
|
|
1592
2057
|
'session:open': 'destructive',
|
|
1593
2058
|
});
|
|
1594
2059
|
/** The first word, tokenised — the synthetic verb when there is one. */
|
|
1595
|
-
function syntheticVerb(command) {
|
|
1596
|
-
return tokenizeSegments(command)[0]?.[0] ?? '';
|
|
2060
|
+
function syntheticVerb(command, dialect = 'posix') {
|
|
2061
|
+
return tokenizeSegments(command, dialect)[0]?.[0] ?? '';
|
|
1597
2062
|
}
|
|
1598
2063
|
/**
|
|
1599
2064
|
* The class of a command, and of everything it carries.
|
|
@@ -1607,8 +2072,20 @@ function syntheticVerb(command) {
|
|
|
1607
2072
|
* Taking the maximum is what makes the scan unable to lower anything.
|
|
1608
2073
|
*/
|
|
1609
2074
|
export function classifyCommand(command, depth = 0) {
|
|
2075
|
+
// One string, two dialects, and the class is the worse of the two readings:
|
|
2076
|
+
// the classifier cannot know whether the host shell reads a backslash as an
|
|
2077
|
+
// escape or as a path separator, so policy must hold on whichever host
|
|
2078
|
+
// receives the command (GHSA-972x-g47g-3922). A command without a backslash
|
|
2079
|
+
// is byte-identical under both dialects, so it keeps the single pass.
|
|
2080
|
+
if (!command.includes('\\'))
|
|
2081
|
+
return classifyCommandInDialect(command, depth, 'posix');
|
|
2082
|
+
const posix = classifyCommandInDialect(command, depth, 'posix');
|
|
2083
|
+
const windows = classifyCommandInDialect(command, depth, 'windows');
|
|
2084
|
+
return CLASS_RANK[windows.class] > CLASS_RANK[posix.class] ? windows : posix;
|
|
2085
|
+
}
|
|
2086
|
+
function classifyCommandInDialect(command, depth, dialect) {
|
|
1610
2087
|
const trimmed = command.trim();
|
|
1611
|
-
const outer = classifyOuter(trimmed);
|
|
2088
|
+
const outer = classifyOuter(trimmed, dialect);
|
|
1612
2089
|
if (depth >= MAX_NESTING_DEPTH) {
|
|
1613
2090
|
// Nesting this deep is not something an operator writes, and we have stopped
|
|
1614
2091
|
// reading. Refusing to guess is the only answer consistent with the rest of this
|
|
@@ -1619,34 +2096,46 @@ export function classifyCommand(command, depth = 0) {
|
|
|
1619
2096
|
// A floor, not a verdict. Returning the synthetic class outright would put it above
|
|
1620
2097
|
// the elevation and never-allowed checks, so `sftp:upload /tmp/x; sudo id` would
|
|
1621
2098
|
// record `destructive` where the command is `privileged`.
|
|
1622
|
-
const verb = syntheticVerb(trimmed);
|
|
2099
|
+
const verb = syntheticVerb(trimmed, dialect);
|
|
1623
2100
|
const floor = SYNTHETIC_CLASSES[verb];
|
|
1624
2101
|
if (floor !== undefined && CLASS_RANK[floor] > CLASS_RANK[highest.class]) {
|
|
1625
2102
|
highest = { binary: verb, fullCommand: trimmed, class: floor };
|
|
1626
2103
|
}
|
|
1627
|
-
for (const inner of nestedCommands(trimmed)) {
|
|
1628
|
-
const parsed =
|
|
2104
|
+
for (const inner of nestedCommands(trimmed, true, dialect)) {
|
|
2105
|
+
const parsed = classifyCommandInDialect(inner, depth + 1, dialect);
|
|
1629
2106
|
// `binary` follows the winning side deliberately: it is what the audit record and
|
|
1630
2107
|
// the refusal message name, and naming the outer `echo` would describe the wrong
|
|
1631
2108
|
// process as the one that ran as root.
|
|
1632
2109
|
if (CLASS_RANK[parsed.class] > CLASS_RANK[highest.class])
|
|
1633
2110
|
highest = parsed;
|
|
1634
2111
|
}
|
|
1635
|
-
|
|
2112
|
+
// `readOnlyRejection` only ever describes the outer command's own grammar
|
|
2113
|
+
// rejection, and only means anything when that is also what decided the
|
|
2114
|
+
// final class. `highest === outer` (reference equality) is exactly that
|
|
2115
|
+
// condition: the synthetic floor and the nested-command loop above only
|
|
2116
|
+
// ever replace `highest` with something of strictly higher rank than
|
|
2117
|
+
// `outer`'s, so if either one fired, `highest` is no longer `outer` and the
|
|
2118
|
+
// rejection — which was never a fact about the synthetic verb or the nested
|
|
2119
|
+
// command anyway — is correctly left off.
|
|
2120
|
+
const result = { binary: highest.binary, fullCommand: trimmed, class: highest.class };
|
|
2121
|
+
if (highest === outer && outer.readOnlyRejection) {
|
|
2122
|
+
result.readOnlyRejection = outer.readOnlyRejection;
|
|
2123
|
+
}
|
|
2124
|
+
return result;
|
|
1636
2125
|
}
|
|
1637
2126
|
/** The class of the command itself, reading none of what it carries. */
|
|
1638
|
-
function classifyOuter(trimmed) {
|
|
1639
|
-
const binary = extractBinary(trimmed);
|
|
2127
|
+
function classifyOuter(trimmed, dialect) {
|
|
2128
|
+
const binary = extractBinary(trimmed, dialect);
|
|
1640
2129
|
const fullCommand = trimmed;
|
|
1641
2130
|
// `binary` names the subject of the class. For everything below it is the
|
|
1642
2131
|
// leading command; here it is the one that runs as root, which are the same
|
|
1643
2132
|
// thing only when the prefix leads.
|
|
1644
|
-
const elevated = elevatedBinaryOf(trimmed);
|
|
2133
|
+
const elevated = elevatedBinaryOf(trimmed, dialect);
|
|
1645
2134
|
if (elevated !== null) {
|
|
1646
2135
|
return { binary: elevated, fullCommand, class: 'privileged' };
|
|
1647
2136
|
}
|
|
1648
|
-
if (hasUnreadableProgram(trimmed) || hasDangerousAwk(trimmed)
|
|
1649
|
-
|| isDestructive(trimmed) || hasDisqualifyingArgs(trimmed)) {
|
|
2137
|
+
if (hasUnreadableProgram(trimmed, dialect) || hasDangerousAwk(trimmed, dialect)
|
|
2138
|
+
|| isDestructive(trimmed, dialect) || hasDisqualifyingArgs(trimmed, dialect)) {
|
|
1650
2139
|
return { binary, fullCommand, class: 'destructive' };
|
|
1651
2140
|
}
|
|
1652
2141
|
// Below this point every branch assumes the command word names something we
|
|
@@ -1657,17 +2146,77 @@ function classifyOuter(trimmed) {
|
|
|
1657
2146
|
// `destructive` rather than `privileged`: this is "we cannot tell", not "this is
|
|
1658
2147
|
// root". It gates on approval instead of refusing outright, which keeps
|
|
1659
2148
|
// `$PREFIX/bin/tool` usable for a role that holds `destructive` on the tier.
|
|
1660
|
-
if (hasUnnameableCommand(trimmed)) {
|
|
2149
|
+
if (hasUnnameableCommand(trimmed, dialect)) {
|
|
1661
2150
|
return { binary, fullCommand, class: 'destructive' };
|
|
1662
2151
|
}
|
|
1663
|
-
const
|
|
2152
|
+
const words = tokenizeSegments(fullCommand, dialect)[0] ?? [];
|
|
2153
|
+
const twoWordPrefix = words.slice(0, 2).join(' ');
|
|
1664
2154
|
if (READ_ONLY_ALLOWLIST.has(binary) || READ_ONLY_ALLOWLIST.has(twoWordPrefix)
|
|
1665
2155
|
|| READ_ONLY_SYNTHETIC.has(binary)) {
|
|
1666
2156
|
if (SHELL_CONTROL_CHARS.test(trimmed)) {
|
|
1667
2157
|
return { binary, fullCommand, class: 'safe' };
|
|
1668
2158
|
}
|
|
2159
|
+
// The SFTP verbs are synthesised by the tool layer, never typed by a caller,
|
|
2160
|
+
// so there is no argv for a grammar to read — READ_ONLY_SYNTHETIC stays
|
|
2161
|
+
// exactly as it was.
|
|
2162
|
+
if (READ_ONLY_SYNTHETIC.has(binary)) {
|
|
2163
|
+
return { binary, fullCommand, class: 'read-only' };
|
|
2164
|
+
}
|
|
2165
|
+
// Whichever key matched decides both which entry answers and how many
|
|
2166
|
+
// leading words are the command name rather than an argument: a two-word
|
|
2167
|
+
// entry's grammar reads words[2..], a one-word entry's reads words[1..].
|
|
2168
|
+
const matchedTwoWord = READ_ONLY_ALLOWLIST.has(twoWordPrefix);
|
|
2169
|
+
const readerName = matchedTwoWord ? twoWordPrefix : binary;
|
|
2170
|
+
const entry = READERS[readerName];
|
|
2171
|
+
const argWords = words.slice(matchedTwoWord ? 2 : 1);
|
|
2172
|
+
// An unquoted glob word is not a word this grammar can vouch for: the
|
|
2173
|
+
// shell replaces it with however many filenames it matches, and the
|
|
2174
|
+
// grammar's operand count — the thing it was about to prove — is false
|
|
2175
|
+
// before the binary runs. `'any'` entries are exempt: their claim is that
|
|
2176
|
+
// no option or operand of the binary writes or executes, so extra
|
|
2177
|
+
// operands from an expansion are still data.
|
|
2178
|
+
if (entry.grammar.args !== 'any') {
|
|
2179
|
+
const globWord = firstUnquotedGlobWord(fullCommand, true, dialect);
|
|
2180
|
+
if (globWord !== null) {
|
|
2181
|
+
return {
|
|
2182
|
+
binary,
|
|
2183
|
+
fullCommand,
|
|
2184
|
+
class: 'safe',
|
|
2185
|
+
readOnlyRejection: { binary: readerName, word: globWord },
|
|
2186
|
+
};
|
|
2187
|
+
}
|
|
2188
|
+
}
|
|
2189
|
+
const match = matchesGrammar(argWords, entry.grammar);
|
|
2190
|
+
if (!match.ok) {
|
|
2191
|
+
return {
|
|
2192
|
+
binary,
|
|
2193
|
+
fullCommand,
|
|
2194
|
+
class: 'safe',
|
|
2195
|
+
readOnlyRejection: { binary: readerName, word: match.word },
|
|
2196
|
+
};
|
|
2197
|
+
}
|
|
1669
2198
|
return { binary, fullCommand, class: 'read-only' };
|
|
1670
2199
|
}
|
|
1671
2200
|
return { binary, fullCommand, class: 'safe' };
|
|
1672
2201
|
}
|
|
1673
|
-
|
|
2202
|
+
/**
|
|
2203
|
+
* The sentence a refusal appends when it carries a `readOnlyRejection`, worded
|
|
2204
|
+
* so the reason names the binary that would have qualified and the argv word
|
|
2205
|
+
* that cost it: "is not accepted there" rather than "is not one of them",
|
|
2206
|
+
* because most rejections are operand rules (`hostname NAME`, `sort /data`) or
|
|
2207
|
+
* a listed value flag missing its value, where "is not one of them" would be
|
|
2208
|
+
* false — it may be a perfectly good flag name, just not one bare or in this
|
|
2209
|
+
* position. One function so the engine's role-binding denial and
|
|
2210
|
+
* `read-command`'s `enforceClass` refusal never drift into two spellings of
|
|
2211
|
+
* the same sentence.
|
|
2212
|
+
*/
|
|
2213
|
+
function formatReadOnlyRejection(rejection) {
|
|
2214
|
+
// The rejected word can be the empty string — a quoted empty operand
|
|
2215
|
+
// (`hostname ""`) — and two bare backticks would render as a blank where
|
|
2216
|
+
// the word that cost the class should be. `""` is the shell's own spelling
|
|
2217
|
+
// of that operand.
|
|
2218
|
+
const word = rejection.word === '' ? '""' : rejection.word;
|
|
2219
|
+
return `\`${rejection.binary}\` is read-only only with the options and operands its grammar lists; ` +
|
|
2220
|
+
`\`${word}\` is not accepted there.`;
|
|
2221
|
+
}
|
|
2222
|
+
export { READ_ONLY_ALLOWLIST, READERS, READ_ONLY_SYNTHETIC, isDestructive, formatReadOnlyRejection, };
|