@coo-quack/sensitive-canary 0.7.0 → 0.8.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/.claude-plugin/plugin.json +1 -1
- package/CHANGELOG.md +791 -0
- package/README.md +142 -45
- package/dist/lib/bash-commands.js +405 -0
- package/dist/lib/command-tables.js +462 -0
- package/dist/lib/default-config.json +570 -0
- package/dist/lib/encoding.js +123 -0
- package/dist/lib/fail-closed.js +31 -0
- package/dist/lib/inspector.js +0 -0
- package/dist/lib/rules.js +399 -0
- package/dist/lib/shapes.js +161 -0
- package/dist/lib/shell.js +436 -0
- package/dist/lib/tool-inputs.js +217 -0
- package/dist/lib/transcript.js +115 -0
- package/dist/lib/validators.js +435 -0
- package/dist/pre-tool-use-hook.js +773 -0
- package/dist/user-prompt-submit-hook.js +105 -0
- package/hooks/hooks.json +1 -1
- package/package.json +25 -11
- package/src/lib/bash-commands.ts +455 -0
- package/src/lib/command-tables.ts +518 -0
- package/src/lib/default-config.json +155 -46
- package/src/lib/encoding.ts +135 -0
- package/src/lib/fail-closed.ts +36 -0
- package/src/lib/inspector.ts +0 -0
- package/src/lib/rules.ts +202 -365
- package/src/lib/shapes.ts +175 -0
- package/src/lib/shell.ts +512 -0
- package/src/lib/tool-inputs.ts +235 -0
- package/src/lib/transcript.ts +142 -0
- package/src/lib/validators.ts +435 -0
- package/src/pre-tool-use-hook.ts +774 -198
- package/src/user-prompt-submit-hook.ts +60 -18
- package/src/__tests__/pre-tool-use-hook.test.ts +0 -779
- package/src/__tests__/user-prompt-submit-hook.test.ts +0 -297
- package/src/lib/__tests__/inspector.test.ts +0 -289
- package/src/lib/__tests__/rules.test.ts +0 -1370
|
@@ -0,0 +1,518 @@
|
|
|
1
|
+
// What this hook knows about how each command treats its operands.
|
|
2
|
+
//
|
|
3
|
+
// The tables are the knowledge and nothing here is logic: which commands print
|
|
4
|
+
// a file they are handed, which take a pattern first, which run another
|
|
5
|
+
// command. A command missing from a table is a file that goes unscanned, so
|
|
6
|
+
// every table has a case per entry in the suite and an equality assertion
|
|
7
|
+
// beside it — a generated case cannot notice a deletion.
|
|
8
|
+
|
|
9
|
+
import type { ShellToken } from "./shell.ts";
|
|
10
|
+
|
|
11
|
+
// Commands that write the contents of every non-flag argument to stdout.
|
|
12
|
+
// `wc` is deliberately absent: it reports counts, never the bytes themselves.
|
|
13
|
+
export const FILE_READ_COMMANDS = new Set([
|
|
14
|
+
"cat",
|
|
15
|
+
"head",
|
|
16
|
+
"tail",
|
|
17
|
+
"less",
|
|
18
|
+
"more",
|
|
19
|
+
"bat",
|
|
20
|
+
// `view` is read-only vi and `vimdiff` opens two files at once; both print
|
|
21
|
+
// the contents the way `less` does.
|
|
22
|
+
"view",
|
|
23
|
+
"vimdiff",
|
|
24
|
+
"nl",
|
|
25
|
+
"tac",
|
|
26
|
+
"rev",
|
|
27
|
+
"strings",
|
|
28
|
+
"xxd",
|
|
29
|
+
"od",
|
|
30
|
+
"hexdump",
|
|
31
|
+
"base64",
|
|
32
|
+
"cut",
|
|
33
|
+
"sort",
|
|
34
|
+
"uniq",
|
|
35
|
+
"shuf",
|
|
36
|
+
"column",
|
|
37
|
+
"paste",
|
|
38
|
+
"fold",
|
|
39
|
+
"fmt",
|
|
40
|
+
"pr",
|
|
41
|
+
"expand",
|
|
42
|
+
"unexpand",
|
|
43
|
+
"iconv",
|
|
44
|
+
"zcat",
|
|
45
|
+
"gzcat",
|
|
46
|
+
"bzcat",
|
|
47
|
+
"xzcat",
|
|
48
|
+
"zstdcat",
|
|
49
|
+
"lz4cat",
|
|
50
|
+
"lzcat",
|
|
51
|
+
"zless",
|
|
52
|
+
"zmore",
|
|
53
|
+
"bzless",
|
|
54
|
+
"xzless",
|
|
55
|
+
"diff",
|
|
56
|
+
"comm",
|
|
57
|
+
"join",
|
|
58
|
+
"look",
|
|
59
|
+
]);
|
|
60
|
+
|
|
61
|
+
// Commands that read a file but report only measurements of it. Their stdin is
|
|
62
|
+
// not echoed either, so a redirection into one of them is not a read.
|
|
63
|
+
// The rest of the digest family, and the BSD spellings, were missing. Naming a
|
|
64
|
+
// file, that costs nothing — an unclassified command's operands are not
|
|
65
|
+
// collected either. Over `<` it did: stdin is collected for any command not
|
|
66
|
+
// known to print no contents, so `sha512sum < secrets` was scanned while
|
|
67
|
+
// `sha256sum < secrets` was not. The wrong direction is only a false block, but
|
|
68
|
+
// the two spellings disagreeing is not something to leave in a table.
|
|
69
|
+
export const COUNT_ONLY_COMMANDS = new Set([
|
|
70
|
+
"wc",
|
|
71
|
+
"cksum",
|
|
72
|
+
"sum",
|
|
73
|
+
"md5",
|
|
74
|
+
"md5sum",
|
|
75
|
+
"shasum",
|
|
76
|
+
"sha1sum",
|
|
77
|
+
"sha224sum",
|
|
78
|
+
"sha256sum",
|
|
79
|
+
"sha384sum",
|
|
80
|
+
"sha512sum",
|
|
81
|
+
"b2sum",
|
|
82
|
+
]);
|
|
83
|
+
|
|
84
|
+
// Commands whose first non-flag argument is a pattern, expression or script,
|
|
85
|
+
// and whose remaining non-flag arguments are files written to stdout.
|
|
86
|
+
// General-purpose runtimes (`python`, `node`, `deno`, `bun`, and `perl` and
|
|
87
|
+
// `ruby` when they run a program file) are absent: they execute their first
|
|
88
|
+
// argument rather than print it, and the files named after it are argv, not
|
|
89
|
+
// output. Their inline code (`-c`, `-e`) is still scanned via
|
|
90
|
+
// INLINE_CODE_COMMANDS.
|
|
91
|
+
export const PATTERN_OR_SCRIPT_FIRST_COMMANDS = new Set([
|
|
92
|
+
"sed",
|
|
93
|
+
"awk",
|
|
94
|
+
"gawk",
|
|
95
|
+
"grep",
|
|
96
|
+
"egrep",
|
|
97
|
+
"fgrep",
|
|
98
|
+
"rg",
|
|
99
|
+
"ag",
|
|
100
|
+
"jq",
|
|
101
|
+
"yq",
|
|
102
|
+
"zgrep",
|
|
103
|
+
"zegrep",
|
|
104
|
+
"zfgrep",
|
|
105
|
+
"ack",
|
|
106
|
+
"ugrep",
|
|
107
|
+
]);
|
|
108
|
+
|
|
109
|
+
// Flags that hand a pattern-first command its pattern or script, so no operand
|
|
110
|
+
// is standing in for it. Without this, `grep --regexp=aws secrets` and
|
|
111
|
+
// `sed --expression='s/a/b/' secrets` consumed the file as the pattern and never
|
|
112
|
+
// scanned it. The separate-value forms name a pattern or a pattern file, neither
|
|
113
|
+
// of which is printed, so their value is skipped rather than collected.
|
|
114
|
+
export const PATTERN_SUPPLYING_FLAGS = new Set([
|
|
115
|
+
"-e",
|
|
116
|
+
"--regexp",
|
|
117
|
+
"--expression",
|
|
118
|
+
"-f",
|
|
119
|
+
"--file",
|
|
120
|
+
"--from-file",
|
|
121
|
+
]);
|
|
122
|
+
|
|
123
|
+
// Whether a flag token supplies the pattern, and whether its value is already
|
|
124
|
+
// attached to it.
|
|
125
|
+
//
|
|
126
|
+
// Three spellings carry a value: separate (`-e aws`, `--regexp aws`), attached
|
|
127
|
+
// after `=` (`--regexp=aws`), and attached directly to a short flag
|
|
128
|
+
// (`-eaws`, `sed -e's/a/b/'`). Only the first two were recognised, so an attached
|
|
129
|
+
// short value left `patternSkipped` unset and the file that followed was eaten as
|
|
130
|
+
// the pattern — `grep -eaws secrets` and `sed -e's/a/b/' secrets` scanned nothing.
|
|
131
|
+
export function patternSupplyingFlag(
|
|
132
|
+
token: string,
|
|
133
|
+
): "attached" | "separate" | null {
|
|
134
|
+
const equals = token.indexOf("=");
|
|
135
|
+
const beforeEquals = equals === -1 ? token : token.slice(0, equals);
|
|
136
|
+
if (PATTERN_SUPPLYING_FLAGS.has(beforeEquals)) {
|
|
137
|
+
return equals === -1 ? "separate" : "attached";
|
|
138
|
+
}
|
|
139
|
+
// A short flag with its value written against it. Long flags are excluded:
|
|
140
|
+
// `--file-name` is not `--file` with `-name` attached.
|
|
141
|
+
if (!token.startsWith("--") && token.length > 2) {
|
|
142
|
+
if (PATTERN_SUPPLYING_FLAGS.has(token.slice(0, 2))) return "attached";
|
|
143
|
+
}
|
|
144
|
+
return null;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// Flags of a read command whose separate value names a file to write, not one to
|
|
148
|
+
// read: `sort -o out.txt in.txt` prints nothing of `out.txt`, and scanning it
|
|
149
|
+
// blocked a command that only ever writes there.
|
|
150
|
+
//
|
|
151
|
+
// Keyed per command rather than by flag name, because the same letter means
|
|
152
|
+
// different things: `-o` is an output file for these three but an octal-format
|
|
153
|
+
// flag taking no value for `od` and `hexdump`, so a shared list would swallow
|
|
154
|
+
// the operand of `od -o secrets` and miss the read.
|
|
155
|
+
export const WRITE_TARGET_FLAGS: Record<string, Set<string>> = {
|
|
156
|
+
sort: new Set(["-o", "--output"]),
|
|
157
|
+
shuf: new Set(["-o", "--output"]),
|
|
158
|
+
iconv: new Set(["-o", "--output"]),
|
|
159
|
+
};
|
|
160
|
+
|
|
161
|
+
// Interpreters whose file operands are input to a one-liner given inline:
|
|
162
|
+
// `perl -pe 's/a/b/' f` and `ruby -pe '…' f` print f. Hand them a program file
|
|
163
|
+
// instead and the operands are argv — `perl script.pl data.txt` prints neither —
|
|
164
|
+
// so their operands count as reads only once inline code has been seen.
|
|
165
|
+
export const INLINE_CODE_READS_OPERANDS = new Set(["perl", "ruby"]);
|
|
166
|
+
|
|
167
|
+
// Commands that run another command. They are stripped so the wrapped command
|
|
168
|
+
// is classified instead: `sudo cat secrets` is treated as `cat secrets`.
|
|
169
|
+
//
|
|
170
|
+
// `timeout` and `flock` take an operand of their own first (`timeout 5 cat f`,
|
|
171
|
+
// `flock /tmp/lock cat f`) and sat in a set of their own for a while. Nothing
|
|
172
|
+
// ever asked which set a name came from — the one place that read them ORed the
|
|
173
|
+
// two — because the search does not need to know: it walks forward to the first
|
|
174
|
+
// name it can classify, which steps over an operand as readily as over a flag.
|
|
175
|
+
export const WRAPPER_COMMANDS = new Set([
|
|
176
|
+
"sudo",
|
|
177
|
+
"doas",
|
|
178
|
+
"command",
|
|
179
|
+
"builtin",
|
|
180
|
+
"exec",
|
|
181
|
+
"nohup",
|
|
182
|
+
"time",
|
|
183
|
+
"nice",
|
|
184
|
+
"ionice",
|
|
185
|
+
"stdbuf",
|
|
186
|
+
"xargs",
|
|
187
|
+
"env",
|
|
188
|
+
"timeout",
|
|
189
|
+
"flock",
|
|
190
|
+
// `eval cat secrets` runs its arguments as a command line, so the command to
|
|
191
|
+
// classify is the one after it, exactly as with the wrappers above.
|
|
192
|
+
"eval",
|
|
193
|
+
]);
|
|
194
|
+
|
|
195
|
+
// Interpreters that accept inline program text, which is scanned both as a
|
|
196
|
+
// nested command line and for quoted path literals.
|
|
197
|
+
export const INLINE_CODE_COMMANDS = new Set([
|
|
198
|
+
"sh",
|
|
199
|
+
"bash",
|
|
200
|
+
"zsh",
|
|
201
|
+
"dash",
|
|
202
|
+
"ksh",
|
|
203
|
+
"python",
|
|
204
|
+
"python3",
|
|
205
|
+
"perl",
|
|
206
|
+
"ruby",
|
|
207
|
+
"node",
|
|
208
|
+
"deno",
|
|
209
|
+
"bun",
|
|
210
|
+
"php",
|
|
211
|
+
]);
|
|
212
|
+
|
|
213
|
+
export const POSIX_SHELLS = new Set(["sh", "bash", "zsh", "dash", "ksh"]);
|
|
214
|
+
|
|
215
|
+
// git subcommands that can write file contents to stdout. Blob references such
|
|
216
|
+
// as `git show HEAD:.env` name history, not the working tree, and stay
|
|
217
|
+
// uncovered — only paths that exist on disk are scanned. `difftool` hands off
|
|
218
|
+
// to an external tool and `stash` prints no file contents, so neither is here:
|
|
219
|
+
// classifying them would push tokens like the `pop` in `git stash pop` as paths.
|
|
220
|
+
export const GIT_READ_SUBCOMMANDS = new Set([
|
|
221
|
+
"show",
|
|
222
|
+
"diff",
|
|
223
|
+
"blame",
|
|
224
|
+
"annotate",
|
|
225
|
+
"grep",
|
|
226
|
+
"cat-file",
|
|
227
|
+
]);
|
|
228
|
+
|
|
229
|
+
// `-L` is not here: it prints the lines of one named file rather than a patch of
|
|
230
|
+
// whatever operands follow, so it is handled where that file is extracted from
|
|
231
|
+
// the range spec. Listing it here as well marked every other operand of the same
|
|
232
|
+
// command as read.
|
|
233
|
+
//
|
|
234
|
+
// `git log <file>` prints who changed the file and when, never a line of it, so
|
|
235
|
+
// it belongs with the subcommands above only when a patch is asked for. Treating
|
|
236
|
+
// it as a read unconditionally blocked an everyday way of looking at history.
|
|
237
|
+
//
|
|
238
|
+
// The flags are the ones that produce a diff: `-U<n>` and `--unified=<n>` imply
|
|
239
|
+
// `--patch`, and the merge-diff forms print one too. A flag not listed here
|
|
240
|
+
// leaves the file unscanned, so the list errs towards including anything that
|
|
241
|
+
// might print contents — `git -c k=v log f` matches `-c` and is scanned, which
|
|
242
|
+
// is the harmless direction to be wrong in.
|
|
243
|
+
function gitLogPrintsFileContents(operands: ShellToken[]): boolean {
|
|
244
|
+
return operands.some(({ value }) => {
|
|
245
|
+
if (value === "-p" || value === "-u" || value === "-c" || value === "-m") {
|
|
246
|
+
return true;
|
|
247
|
+
}
|
|
248
|
+
return (
|
|
249
|
+
value.startsWith("--patch") ||
|
|
250
|
+
value.startsWith("-U") ||
|
|
251
|
+
value.startsWith("--unified") ||
|
|
252
|
+
value.startsWith("--cc") ||
|
|
253
|
+
value.startsWith("--diff-merges")
|
|
254
|
+
);
|
|
255
|
+
});
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
// The file named inside a `git log -L` argument, which carries it after the last
|
|
259
|
+
// `:` of a range spec (`-L1,10:f`, `-L:funcname:f`). Written as a path on its
|
|
260
|
+
// own it would never be found, since the range is part of the token.
|
|
261
|
+
export function gitLineRangeFile(value: string): string | null {
|
|
262
|
+
const colon = value.lastIndexOf(":");
|
|
263
|
+
if (colon === -1) return null;
|
|
264
|
+
const file = value.slice(colon + 1);
|
|
265
|
+
return file === "" ? null : file;
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
// Whether a git subcommand writes the contents of the files named after it.
|
|
269
|
+
export function gitSubcommandPrintsFiles(
|
|
270
|
+
subcommand: string,
|
|
271
|
+
operands: ShellToken[],
|
|
272
|
+
): boolean {
|
|
273
|
+
if (subcommand === "log") return gitLogPrintsFileContents(operands);
|
|
274
|
+
return GIT_READ_SUBCOMMANDS.has(subcommand);
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
// Commands whose `-i` rewrites the files it is handed instead of printing them,
|
|
278
|
+
// each with the short switches it accepts without a value.
|
|
279
|
+
//
|
|
280
|
+
// Being on this list at all is what makes `-i` mean in-place: `grep -i` matches
|
|
281
|
+
// case-insensitively and still prints. The letters are what let a bundle be read
|
|
282
|
+
// one at a time in search of the `i`.
|
|
283
|
+
//
|
|
284
|
+
// One table rather than two. A separate set of command names said the same thing
|
|
285
|
+
// as these keys, and the two could disagree — adding `awk` to the set alone made
|
|
286
|
+
// `awk -i` an in-place edit, which a test had to be written to catch. Nothing can
|
|
287
|
+
// disagree with itself.
|
|
288
|
+
//
|
|
289
|
+
// Per command, because the letters differ and sharing one set got it wrong in
|
|
290
|
+
// both directions: `E`, `r` and `z` are valueless for `sed` but absent from a
|
|
291
|
+
// set chosen for `perl`, so the everyday `sed -Ei` was read as a non-in-place
|
|
292
|
+
// command and its file scanned; while `0` was present for `perl -0777`, which
|
|
293
|
+
// made `sed -0i` — not a sed flag at all — look like an in-place edit and left
|
|
294
|
+
// its file unscanned.
|
|
295
|
+
//
|
|
296
|
+
// A letter that is not here stops the reading, whether it takes a value or the
|
|
297
|
+
// list simply does not know it. That is the fail-closed direction: the command
|
|
298
|
+
// is then treated as one that prints, and its operands are scanned.
|
|
299
|
+
// `e` is absent from every line on purpose: it introduces a script for all three,
|
|
300
|
+
// and a sed script contains `i` as its insert command, so reading past `-e` would
|
|
301
|
+
// find an `i` in the program text and call the command an in-place edit.
|
|
302
|
+
export const IN_PLACE_EDITORS: Record<string, string> = {
|
|
303
|
+
sed: "anszEru",
|
|
304
|
+
perl: "aclnpsStTuUvwWX",
|
|
305
|
+
ruby: "acdlnpsSTUvwWy",
|
|
306
|
+
};
|
|
307
|
+
|
|
308
|
+
// True when `cmd` edits in place given `value` as one of its flags: `-i`,
|
|
309
|
+
// `-i.bak`, or a bundle reaching `i` past that command's valueless switches
|
|
310
|
+
// (`-pi`, `-lpi`, `-Ei`).
|
|
311
|
+
//
|
|
312
|
+
// A command absent from the table is not an in-place editor at all, so no flag of
|
|
313
|
+
// it counts — `grep -i` and `grep --in-place` alike.
|
|
314
|
+
//
|
|
315
|
+
// `--in-place` is sed's alone. Accepting it from every command in the table meant
|
|
316
|
+
// `perl --in-place=.bak -pe 'x' secrets` and the same for `ruby` were treated as
|
|
317
|
+
// in-place edits and their files went unscanned, though neither interpreter has
|
|
318
|
+
// that flag: perl and ruby spell it `-i`, and would reject the long form.
|
|
319
|
+
export function isInPlaceFlag(cmd: string, value: string): boolean {
|
|
320
|
+
const valueless = IN_PLACE_EDITORS[cmd];
|
|
321
|
+
if (valueless === undefined) return false;
|
|
322
|
+
|
|
323
|
+
if (cmd === "sed" && value.replace(/=.*/, "") === "--in-place") return true;
|
|
324
|
+
if (!value.startsWith("-") || value.startsWith("--")) return false;
|
|
325
|
+
|
|
326
|
+
for (const ch of value.slice(1)) {
|
|
327
|
+
if (ch === "i") return true;
|
|
328
|
+
if (!valueless.includes(ch)) return false;
|
|
329
|
+
}
|
|
330
|
+
return false;
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
// `--` ends option parsing here as well: in `sed -- -i secrets`, `-i` is the
|
|
334
|
+
// script and `secrets` is a file sed prints. Read as an in-place flag, the whole
|
|
335
|
+
// command counted as writing and the file went unscanned.
|
|
336
|
+
export function editsInPlace(cmd: string, operands: ShellToken[]): boolean {
|
|
337
|
+
for (const { value } of operands) {
|
|
338
|
+
if (value === "--") return false;
|
|
339
|
+
if (isInPlaceFlag(cmd, value)) return true;
|
|
340
|
+
}
|
|
341
|
+
return false;
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
// Global git flags that carry a separate value before the subcommand
|
|
345
|
+
// (`git -C repo show f`, `git -c k=v show f`). Attached forms (`--git-dir=x`)
|
|
346
|
+
// are single flag tokens and need no entry here.
|
|
347
|
+
export const GIT_GLOBAL_FLAGS_WITH_OPERAND = new Set([
|
|
348
|
+
"-C",
|
|
349
|
+
"-c",
|
|
350
|
+
"--git-dir",
|
|
351
|
+
"--work-tree",
|
|
352
|
+
"--namespace",
|
|
353
|
+
"--exec-path",
|
|
354
|
+
"--config-env",
|
|
355
|
+
]);
|
|
356
|
+
|
|
357
|
+
// Recursion limit for inline program text: each `-c` / `-e` script inside
|
|
358
|
+
// another costs one level. Nested command substitutions are not bounded by it,
|
|
359
|
+
// since the tokenizer ends a segment at a paren and reaches the inner command
|
|
360
|
+
// without recursing. `depth` counts nesting, so the command line itself is 0 and
|
|
361
|
+
// four levels of inline text below it are inspected.
|
|
362
|
+
export const MAX_NESTING_DEPTH = 4;
|
|
363
|
+
|
|
364
|
+
// References a single Bash command makes to data the hook can inspect.
|
|
365
|
+
export interface CommandRefs {
|
|
366
|
+
// File paths whose contents the command may write to stdout.
|
|
367
|
+
paths: string[];
|
|
368
|
+
// Environment variables the command names explicitly.
|
|
369
|
+
envVars: string[];
|
|
370
|
+
// Whether the command dumps the whole environment (bare `env` / `printenv`).
|
|
371
|
+
dumpsEnvironment: boolean;
|
|
372
|
+
// Whether the command searches the working directory because it was given no
|
|
373
|
+
// file to search. `rg PATTERN` is the ordinary way to search a repository and
|
|
374
|
+
// names nothing, so there is no operand to collect and the whole tree it
|
|
375
|
+
// prints from went unscanned.
|
|
376
|
+
searchesWorkingDirectory: boolean;
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
// Searchers that walk the working directory when handed no path. The `grep`
|
|
380
|
+
// family is not here: it reads stdin unless `-r` is given, and that flag is
|
|
381
|
+
// what puts it in this class for the length of one command.
|
|
382
|
+
export const RECURSIVE_BY_DEFAULT = new Set(["rg", "ag", "ack", "ugrep"]);
|
|
383
|
+
|
|
384
|
+
export const GREP_FAMILY = new Set([
|
|
385
|
+
"grep",
|
|
386
|
+
"egrep",
|
|
387
|
+
"fgrep",
|
|
388
|
+
"zgrep",
|
|
389
|
+
"zegrep",
|
|
390
|
+
"zfgrep",
|
|
391
|
+
]);
|
|
392
|
+
|
|
393
|
+
// `-r`, `-R`, `--recursive`, and the letter inside a bundle such as `-rn`. A
|
|
394
|
+
// bundle is read letter by letter because `grep -rn PATTERN` is how it is
|
|
395
|
+
// usually typed.
|
|
396
|
+
export function asksForRecursion(operands: ShellToken[]): boolean {
|
|
397
|
+
for (const tok of operands) {
|
|
398
|
+
if (tok.redirect) continue;
|
|
399
|
+
const v = tok.value;
|
|
400
|
+
if (v === "--recursive" || v === "--dereference-recursive") return true;
|
|
401
|
+
if (v === "--") break;
|
|
402
|
+
if (!v.startsWith("-") || v.startsWith("--") || v.length < 2) continue;
|
|
403
|
+
if (/[rR]/.test(v.slice(1))) return true;
|
|
404
|
+
}
|
|
405
|
+
return false;
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
// Fold one set of refs into another. A command line yields refs from several
|
|
409
|
+
// places — its substitutions, each of its segments, the inline code it carries,
|
|
410
|
+
// an `env -S` string — and combining them is the same three lines each time,
|
|
411
|
+
// which is how it came to be written out three times with a closure alongside.
|
|
412
|
+
export function mergeRefs(into: CommandRefs, refs: CommandRefs): void {
|
|
413
|
+
into.paths.push(...refs.paths);
|
|
414
|
+
into.envVars.push(...refs.envVars);
|
|
415
|
+
into.dumpsEnvironment = into.dumpsEnvironment || refs.dumpsEnvironment;
|
|
416
|
+
into.searchesWorkingDirectory =
|
|
417
|
+
into.searchesWorkingDirectory || refs.searchesWorkingDirectory;
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
// What this hook knows about how a command treats its operands.
|
|
421
|
+
export interface CommandBehaviour {
|
|
422
|
+
// Every non-flag operand is a file written to stdout.
|
|
423
|
+
printsOperands: boolean;
|
|
424
|
+
// The first non-flag operand is a pattern or script name, the rest are files.
|
|
425
|
+
firstOperandIsPatternOrScript: boolean;
|
|
426
|
+
// -c / -e introduce inline program text.
|
|
427
|
+
takesInlineCode: boolean;
|
|
428
|
+
// Operands following inline program text are input files written to stdout.
|
|
429
|
+
inlineCodeReadsOperands: boolean;
|
|
430
|
+
// Reads a file but prints only a measurement of it, and does not echo stdin.
|
|
431
|
+
printsNoFileContents: boolean;
|
|
432
|
+
// `git <subcommand> [paths]`.
|
|
433
|
+
isGit: boolean;
|
|
434
|
+
// `dd if=<file>` names its input in an assignment-style operand.
|
|
435
|
+
isDd: boolean;
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
// Single place where a command name becomes a behaviour, so a name added to one
|
|
439
|
+
// list cannot silently disagree with another.
|
|
440
|
+
//
|
|
441
|
+
// Not every per-command decision belongs here, and two deliberately stay out.
|
|
442
|
+
// isClassifiableCommand below reads this record generically — any truthy field
|
|
443
|
+
// means "operands understood" — which is what lets a new field be added without
|
|
444
|
+
// updating it, and also what a field has to respect to live here:
|
|
445
|
+
//
|
|
446
|
+
// - `WRITE_TARGET_FLAGS[cmd]` would arrive as a Set for the three commands
|
|
447
|
+
// that have one, and a Set is truthy whatever it holds. That would make
|
|
448
|
+
// `sort`, `shuf` and `iconv` classifiable on the strength of having an
|
|
449
|
+
// output flag, which says nothing about whether their operands are
|
|
450
|
+
// understood. (It is `undefined` for every other command, so the reach is
|
|
451
|
+
// those three, not all of them.)
|
|
452
|
+
// - `cmd === "env"` for the `-S` string would make `env` classifiable, and
|
|
453
|
+
// `env` is left out on purpose: it is a wrapper as often as a command.
|
|
454
|
+
//
|
|
455
|
+
// Both are therefore read from their tables at the point of use rather than
|
|
456
|
+
// folded in here. Anything added to this record must be a boolean that means
|
|
457
|
+
// "this hook understands what the command does with its operands".
|
|
458
|
+
export function classifyCommand(cmd: string): CommandBehaviour {
|
|
459
|
+
return {
|
|
460
|
+
printsOperands: FILE_READ_COMMANDS.has(cmd),
|
|
461
|
+
firstOperandIsPatternOrScript: PATTERN_OR_SCRIPT_FIRST_COMMANDS.has(cmd),
|
|
462
|
+
takesInlineCode: INLINE_CODE_COMMANDS.has(cmd),
|
|
463
|
+
inlineCodeReadsOperands: INLINE_CODE_READS_OPERANDS.has(cmd),
|
|
464
|
+
printsNoFileContents: COUNT_ONLY_COMMANDS.has(cmd),
|
|
465
|
+
isGit: cmd === "git",
|
|
466
|
+
isDd: cmd === "dd",
|
|
467
|
+
};
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
// Commands whose operands this hook knows how to interpret. Derived from
|
|
471
|
+
// classifyCommand so the two cannot silently disagree. `env` and `printenv`
|
|
472
|
+
// are absent on purpose: they are wrappers as often as they are commands, and
|
|
473
|
+
// inspectEnvironmentCommand handles the cases where they print the environment.
|
|
474
|
+
export function isClassifiableCommand(name: string): boolean {
|
|
475
|
+
const behaviour = classifyCommand(name);
|
|
476
|
+
// Any behaviour at all means the operands are understood. Read from the object
|
|
477
|
+
// rather than listed field by field, so adding a field cannot leave this
|
|
478
|
+
// function silently disagreeing with classifyCommand.
|
|
479
|
+
return Object.values(behaviour).some(Boolean);
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
// Commands a wrapper may hand off to. Beyond the classifiable ones, `env` and
|
|
483
|
+
// `printenv` count here — and only here — so wrapper operands do not hide an
|
|
484
|
+
// environment dump: `sudo -u root printenv` must still find printenv.
|
|
485
|
+
export function isWrapperTarget(name: string): boolean {
|
|
486
|
+
return isClassifiableCommand(name) || name === "env" || name === "printenv";
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
// Commands that write their operands out without ever opening them.
|
|
490
|
+
//
|
|
491
|
+
// These matter only to the wrapper search below, as the point where it has to
|
|
492
|
+
// stop: everything after one of them is its own argument list. Without that
|
|
493
|
+
// stop, `sudo echo cat secrets` walked past `echo` looking for a name it could
|
|
494
|
+
// classify, found `cat` among echo's arguments, and blocked a command that
|
|
495
|
+
// reads nothing.
|
|
496
|
+
//
|
|
497
|
+
// The set is deliberately short and cannot be complete — any command this hook
|
|
498
|
+
// does not classify might be the one a wrapper handed off to, and the search
|
|
499
|
+
// still walks past those. That is the direction to be wrong in: it collects
|
|
500
|
+
// paths that are not read rather than missing a read, and `sudo -u root cat f`
|
|
501
|
+
// depends on it, because `root` is not distinguishable from a command name.
|
|
502
|
+
//
|
|
503
|
+
// Treat this as a stopgap rather than a list to grow. Each name added buys one
|
|
504
|
+
// more false positive and leaves the general case untouched, and a list that
|
|
505
|
+
// accumulates one report at a time is the shape of a rule nobody wrote down. The
|
|
506
|
+
// real fix is to model each wrapper's own arguments — the flags that take a
|
|
507
|
+
// value, and the leading operand of `timeout` and `flock` — so the command
|
|
508
|
+
// position is determinate and no allowlist is needed. That was not done here
|
|
509
|
+
// because an incomplete table of those flags fails the other way, missing reads
|
|
510
|
+
// instead of over-reporting them. If a third false positive of this shape turns
|
|
511
|
+
// up, do that instead of adding a sixth name.
|
|
512
|
+
export const ARGUMENT_ONLY_COMMANDS = new Set([
|
|
513
|
+
"echo",
|
|
514
|
+
"printf",
|
|
515
|
+
"true",
|
|
516
|
+
"false",
|
|
517
|
+
":",
|
|
518
|
+
]);
|