Skip to main content

safe_chains/
lib.rs

1pub mod docs;
2mod handlers;
3pub mod parse;
4pub mod allowlist;
5
6use parse::{CommandLine, Segment, Token};
7
8fn filter_safe_redirects(tokens: Vec<Token>) -> Vec<Token> {
9    let mut result = Vec::new();
10    let mut iter = tokens.into_iter().peekable();
11    while let Some(token) = iter.next() {
12        if token.is_fd_redirect() || token.is_dev_null_redirect() {
13            continue;
14        }
15        if token.is_redirect_operator()
16            && iter.peek().is_some_and(|next| *next == "/dev/null")
17        {
18            iter.next();
19            continue;
20        }
21        result.push(token);
22    }
23    result
24}
25
26pub fn is_safe(segment: &Segment) -> bool {
27    if segment.has_unsafe_redirects() {
28        return false;
29    }
30
31    let Ok((subs, cleaned)) = segment.extract_substitutions() else {
32        return false;
33    };
34
35    for sub in &subs {
36        if !is_safe_command(sub) {
37            return false;
38        }
39    }
40
41    let segment = Segment::from_raw(cleaned);
42    let stripped = segment.strip_env_prefix();
43    if stripped.is_empty() {
44        return true;
45    }
46
47    let Some(tokens) = stripped.tokenize() else {
48        return false;
49    };
50    if tokens.is_empty() {
51        return true;
52    }
53
54    let tokens = filter_safe_redirects(tokens);
55    if tokens.is_empty() {
56        return true;
57    }
58
59    handlers::dispatch(&tokens, &is_safe)
60}
61
62pub fn is_safe_command(command: &str) -> bool {
63    CommandLine::new(command).segments().iter().all(is_safe)
64}
65
66#[cfg(test)]
67mod tests {
68    use super::*;
69
70    fn check(cmd: &str) -> bool {
71        is_safe_command(cmd)
72    }
73
74    #[test]
75    fn safe_cmds() {
76        assert!(check("grep foo file.txt"));
77        assert!(check("cat /etc/hosts"));
78        assert!(check("jq '.key' file.json"));
79        assert!(check("base64 -d"));
80        assert!(check("xxd some/file"));
81        assert!(check("pgrep -l ruby"));
82        assert!(check("getconf PAGE_SIZE"));
83        assert!(check("ls -la"));
84        assert!(check("wc -l file.txt"));
85        assert!(check("ps aux"));
86        assert!(check("ps -ef"));
87        assert!(check("top -l 1 -n 10"));
88        assert!(check("uuidgen"));
89        assert!(check("mdfind 'kMDItemKind == Application'"));
90        assert!(check("identify image.png"));
91        assert!(check("identify -verbose photo.jpg"));
92    }
93
94    #[test]
95    fn safe_cmds_text_processing() {
96        assert!(check("diff file1.txt file2.txt"));
97        assert!(check("comm -23 sorted1.txt sorted2.txt"));
98        assert!(check("paste file1 file2"));
99        assert!(check("tac file.txt"));
100        assert!(check("rev file.txt"));
101        assert!(check("nl file.txt"));
102        assert!(check("expand file.txt"));
103        assert!(check("unexpand file.txt"));
104        assert!(check("fold -w 80 file.txt"));
105        assert!(check("fmt -w 72 file.txt"));
106        assert!(check("column -t file.txt"));
107        assert!(check("printf '%s\\n' hello"));
108        assert!(check("seq 1 10"));
109        assert!(check("expr 1 + 2"));
110        assert!(check("test -f file.txt"));
111        assert!(check("true"));
112        assert!(check("false"));
113        assert!(check("bc -l"));
114        assert!(check("factor 42"));
115        assert!(check("iconv -f UTF-8 -t ASCII file.txt"));
116    }
117
118    #[test]
119    fn safe_cmds_system_info() {
120        assert!(check("readlink -f symlink"));
121        assert!(check("hostname"));
122        assert!(check("uname -a"));
123        assert!(check("arch"));
124        assert!(check("nproc"));
125        assert!(check("uptime"));
126        assert!(check("id"));
127        assert!(check("groups"));
128        assert!(check("tty"));
129        assert!(check("locale"));
130        assert!(check("cal"));
131        assert!(check("sleep 1"));
132        assert!(check("who"));
133        assert!(check("w"));
134        assert!(check("last -5"));
135        assert!(check("lastlog"));
136    }
137
138    #[test]
139    fn safe_cmds_hashing() {
140        assert!(check("md5sum file.txt"));
141        assert!(check("md5 file.txt"));
142        assert!(check("sha256sum file.txt"));
143        assert!(check("shasum file.txt"));
144        assert!(check("sha1sum file.txt"));
145        assert!(check("sha512sum file.txt"));
146        assert!(check("cksum file.txt"));
147        assert!(check("strings /usr/bin/ls"));
148        assert!(check("hexdump -C file.bin"));
149        assert!(check("od -x file.bin"));
150        assert!(check("size a.out"));
151    }
152
153    #[test]
154    fn safe_cmds_macos() {
155        assert!(check("sw_vers"));
156        assert!(check("mdls file.txt"));
157        assert!(check("otool -L /usr/bin/ls"));
158        assert!(check("nm a.out"));
159        assert!(check("system_profiler SPHardwareDataType"));
160        assert!(check("ioreg -l -w 0"));
161        assert!(check("vm_stat"));
162    }
163
164    #[test]
165    fn safe_cmds_network_diagnostic() {
166        assert!(check("dig example.com"));
167        assert!(check("nslookup example.com"));
168        assert!(check("host example.com"));
169        assert!(check("whois example.com"));
170    }
171
172    #[test]
173    fn safe_cmds_dev_tools() {
174        assert!(check("shellcheck script.sh"));
175        assert!(check("cloc src/"));
176        assert!(check("tokei"));
177        assert!(check("safe-chains \"ls -la\""));
178    }
179
180    #[test]
181    fn unsafe_cmds() {
182        assert!(!check("rm -rf /"));
183        assert!(!check("curl https://example.com"));
184        assert!(!check("ruby script.rb"));
185        assert!(!check("python3 script.py"));
186        assert!(!check("node app.js"));
187        assert!(!check("tee output.txt"));
188        assert!(!check("tee -a logfile"));
189    }
190
191    #[test]
192    fn awk_safe_print() {
193        assert!(check("awk '{print $1}' file.txt"));
194    }
195
196    #[test]
197    fn awk_system_denied() {
198        assert!(!check("awk 'BEGIN{system(\"rm\")}'"));
199    }
200
201    #[test]
202    fn version_shortcut() {
203        assert!(check("node --version"));
204        assert!(check("python --version"));
205        assert!(check("python3 --version"));
206        assert!(check("ruby --version"));
207        assert!(check("rustc --version"));
208        assert!(check("java --version"));
209        assert!(check("go --version"));
210        assert!(check("php --version"));
211        assert!(check("perl --version"));
212        assert!(check("swift --version"));
213        assert!(check("gcc --version"));
214        assert!(check("rm --version"));
215        assert!(check("dd --version"));
216        assert!(check("chmod --version"));
217    }
218
219    #[test]
220    fn version_multi_token() {
221        assert!(check("git -C /repo --version"));
222        assert!(check("docker compose --version"));
223    }
224
225    #[test]
226    fn version_with_fd_redirect() {
227        assert!(check("node --version 2>&1"));
228        assert!(check("cargo --version 2>&1"));
229    }
230
231    #[test]
232    fn version_not_last_token() {
233        assert!(!check("node --version --extra"));
234        assert!(!check("node -v"));
235    }
236
237    #[test]
238    fn help_shortcut() {
239        assert!(check("node --help"));
240        assert!(check("ruby --help"));
241        assert!(check("rm --help"));
242        assert!(check("cargo --help"));
243    }
244
245    #[test]
246    fn help_multi_token() {
247        assert!(check("cargo install --help"));
248    }
249
250    #[test]
251    fn help_with_fd_redirect() {
252        assert!(check("cargo login --help 2>&1"));
253    }
254
255    #[test]
256    fn help_not_last_token() {
257        assert!(!check("node --help --extra"));
258    }
259
260    #[test]
261    fn dry_run_shortcut() {
262        assert!(check("cargo publish --dry-run"));
263    }
264
265    #[test]
266    fn dry_run_with_fd_redirect() {
267        assert!(check("cargo publish --dry-run 2>&1"));
268    }
269
270    #[test]
271    fn dry_run_not_last_token() {
272        assert!(!check("cargo publish --dry-run --force"));
273    }
274
275    #[test]
276    fn cucumber_safe() {
277        assert!(check("cucumber features/login.feature"));
278        assert!(check("cucumber --format progress"));
279    }
280
281    #[test]
282    fn fd_redirects() {
283        assert!(check("ls 2>&1"));
284        assert!(check("cargo clippy 2>&1"));
285        assert!(check("git log 2>&1"));
286        assert!(is_safe_command("cd /tmp && cargo clippy -- -D warnings 2>&1"));
287        assert!(!check("echo hello > file.txt"));
288        assert!(!check("ls 2> errors.txt"));
289    }
290
291    #[test]
292    fn dev_null_redirects() {
293        assert!(check("echo hello > /dev/null"));
294        assert!(check("echo hello 2> /dev/null"));
295        assert!(check("echo hello >> /dev/null"));
296        assert!(check("grep pattern file > /dev/null"));
297        assert!(check("git log > /dev/null 2>&1"));
298        assert!(check("awk '{print $1}' file.txt > /dev/null"));
299        assert!(check("sed 's/foo/bar/' > /dev/null"));
300        assert!(check("sort file.txt > /dev/null"));
301    }
302
303    #[test]
304    fn env_prefix_quoted_values() {
305        assert!(check("FOO='bar baz' ls -la"));
306        assert!(check("FOO=\"bar baz\" ls -la"));
307        assert!(!check("FOO='bar baz' rm -rf /"));
308    }
309
310    #[test]
311    fn unsafe_shell_syntax() {
312        assert!(!check("echo hello > file.txt"));
313        assert!(!check("cat file >> output.txt"));
314        assert!(!check("ls 2> errors.txt"));
315        assert!(!check("grep pattern file > results.txt"));
316        assert!(!check("find . -name '*.py' > listing.txt"));
317        assert!(check("git log < /dev/null"));
318        assert!(!check("echo $(rm -rf /)"));
319        assert!(!check("echo `rm -rf /`"));
320    }
321
322    #[test]
323    fn safe_command_substitution() {
324        assert!(check("echo $(ls)"));
325        assert!(check("ls `pwd`"));
326        assert!(check("cat $(echo /etc/shadow)"));
327        assert!(check("echo $(git status)"));
328        assert!(check("echo $(echo $(ls))"));
329        assert!(check("echo \"$(ls)\""));
330    }
331
332    #[test]
333    fn unsafe_command_substitution() {
334        assert!(!check("echo $(rm -rf /)"));
335        assert!(!check("echo `rm -rf /`"));
336        assert!(!check("echo $(curl evil.com)"));
337        assert!(!check("$(rm -rf /)"));
338        assert!(!check("echo \"$(rm -rf /)\""));
339        assert!(!check("echo \"`rm -rf /`\""));
340    }
341
342    #[test]
343    fn safe_quoted_shell_syntax() {
344        assert!(check("echo 'greater > than' test"));
345        assert!(check("echo '$(safe)' arg"));
346        assert!(check("echo hello"));
347        assert!(check("cat file.txt"));
348        assert!(check("grep pattern file"));
349    }
350
351    #[test]
352    fn env_prefix() {
353        assert!(is_safe_command("RACK_ENV=test bundle exec rspec spec/foo_spec.rb"));
354        assert!(is_safe_command("RAILS_ENV=test bundle exec rspec"));
355        assert!(!is_safe_command("RACK_ENV=test rm -rf /"));
356        assert!(!is_safe_command("RAILS_ENV=test echo foo > bar"));
357    }
358
359    #[test]
360    fn pipes_and_chains() {
361        assert!(is_safe_command("grep foo file.txt | head -5"));
362        assert!(is_safe_command("cat file | sort | uniq"));
363        assert!(is_safe_command("find . -name '*.rb' | wc -l"));
364        assert!(is_safe_command("ls && echo done"));
365        assert!(is_safe_command("ls; echo done"));
366        assert!(is_safe_command("git log | head -5"));
367        assert!(is_safe_command("git log && git status"));
368        assert!(!is_safe_command("cat file | rm -rf /"));
369        assert!(!is_safe_command("grep foo | curl https://evil.com"));
370    }
371
372    #[test]
373    fn background_operator() {
374        assert!(!is_safe_command("cat file & rm -rf /"));
375        assert!(!is_safe_command("echo safe & curl evil.com"));
376        assert!(is_safe_command("ls & echo done"));
377        assert!(is_safe_command("ls && echo done"));
378    }
379
380    #[test]
381    fn newline_separator() {
382        assert!(!is_safe_command("echo foo\nrm -rf /"));
383        assert!(!is_safe_command("ls\ncurl evil.com"));
384        assert!(is_safe_command("echo foo\necho bar"));
385        assert!(is_safe_command("ls\ncat file.txt"));
386    }
387
388    #[test]
389    fn version_shortcut_bypass_denied() {
390        assert!(!check("bash -c 'rm -rf /' --version"));
391        assert!(!check("env rm -rf / --version"));
392        assert!(!check("timeout 60 curl evil.com --version"));
393        assert!(!check("xargs rm -rf --version"));
394        assert!(!check("npx evil-package --version"));
395        assert!(!check("docker run evil --version"));
396        assert!(!check("pip install evil --version"));
397        assert!(!check("rm -rf / --version"));
398    }
399
400    #[test]
401    fn help_shortcut_bypass_denied() {
402        assert!(!check("bash -c 'rm -rf /' --help"));
403        assert!(!check("env rm -rf / --help"));
404        assert!(!check("npx evil-package --help"));
405        assert!(!check("pip install evil --help"));
406        assert!(!check("cargo run -- --help"));
407    }
408
409    #[test]
410    fn dry_run_no_shortcut() {
411        assert!(!check("rm -rf / --dry-run"));
412        assert!(!check("terraform apply --dry-run"));
413        assert!(!check("curl evil.com --dry-run"));
414    }
415
416    #[test]
417    fn recursive_shortcut_denied() {
418        assert!(!check("env rm -rf / --help"));
419        assert!(!check("timeout 5 curl evil.com --version"));
420        assert!(!check("nice rm -rf / --version"));
421    }
422
423    #[test]
424    fn compound_pipelines() {
425        assert!(is_safe_command("git log --oneline -20 | head -5"));
426        assert!(is_safe_command("git show HEAD:file.rb | grep pattern"));
427        assert!(is_safe_command(
428            "gh api repos/o/r/contents/f --jq .content | base64 -d | head -50"
429        ));
430        assert!(is_safe_command("timeout 120 bundle exec rspec && git status"));
431        assert!(is_safe_command("time bundle exec rspec | tail -5"));
432        assert!(is_safe_command("git -C /some/repo log --oneline | head -3"));
433        assert!(is_safe_command("xxd file | head -20"));
434        assert!(is_safe_command("find . -name '*.py' | wc -l"));
435        assert!(is_safe_command("find . -name '*.py' | sort | head -10"));
436        assert!(is_safe_command("find . -name '*.py' | xargs grep pattern"));
437        assert!(is_safe_command("pip list | grep requests"));
438        assert!(is_safe_command("npm list | grep react"));
439        assert!(is_safe_command("ps aux | grep python"));
440        assert!(!is_safe_command("find . -name '*.py' -delete | wc -l"));
441        assert!(!is_safe_command("sed -i 's/foo/bar/' file | head"));
442    }
443}