vtcode_safety/command_safety/
shell_parser.rs1#![expect(
2 clippy::indexing_slicing,
3 clippy::string_slice,
4 reason = "Shell parsing tracks byte offsets at character boundaries while validating operator pairs."
5)]
6
7use std::sync::Mutex;
23use std::sync::OnceLock;
24
25use anyhow::Result;
26
27static BASH_PARSER: OnceLock<Result<Mutex<tree_sitter::Parser>, String>> = OnceLock::new();
29
30pub fn contains_dynamic_shell_syntax(command: &str) -> bool {
40 enum ShellQuote {
41 Single,
42 Double,
43 }
44
45 let mut quote: Option<ShellQuote> = None;
46 let mut characters = command.chars();
47
48 while let Some(character) = characters.next() {
49 match quote {
50 Some(ShellQuote::Single) => {
51 if character == '\'' {
52 quote = None;
53 }
54 }
55 Some(ShellQuote::Double) => match character {
56 '"' => quote = None,
57 '$' | '`' => return true,
58 '\\'
59 if characters.next().is_none() => {
65 return true;
66 }
67 _ => {}
68 },
69 None => match character {
70 '\'' => quote = Some(ShellQuote::Single),
71 '"' => quote = Some(ShellQuote::Double),
72 '\\' | '$' | '`' | '{' | '}' | '*' | '?' | '[' | ']' => return true,
73 _ => {}
74 },
75 }
76 }
77
78 quote.is_some()
79}
80
81pub fn contains_dynamic_find_syntax(script: &str) -> bool {
84 if let Ok(commands) = parse_shell_commands_tree_sitter(script)
85 && commands.iter().any(|command| {
86 command
87 .first()
88 .map(|program| base_command_name(program) == "find")
89 .unwrap_or(false)
90 && command.iter().any(|word| contains_dynamic_shell_syntax(word))
91 })
92 {
93 return true;
94 }
95
96 let has_find_word = script.split_whitespace().any(|word| {
100 let command = word.trim_matches(|character: char| !character.is_ascii_alphanumeric() && character != '/');
101 base_command_name(command) == "find"
102 });
103 has_find_word && contains_dynamic_shell_syntax(script)
104}
105
106fn get_bash_parser() -> Result<&'static Mutex<tree_sitter::Parser>, String> {
108 BASH_PARSER
109 .get_or_init(|| {
110 let mut parser = tree_sitter::Parser::new();
111 let lang: tree_sitter::Language = tree_sitter_bash::LANGUAGE.into();
112 parser
113 .set_language(&lang)
114 .map_err(|e| format!("Failed to load bash grammar: {e}"))?;
115 Ok(Mutex::new(parser))
116 })
117 .as_ref()
118 .map_err(Clone::clone)
119}
120
121pub fn prewarm_bash_parser() -> Result<(), String> {
123 let _ = get_bash_parser()?;
124 Ok(())
125}
126
127pub fn parse_shell_commands(script: &str) -> Result<Vec<Vec<String>>, String> {
138 match parse_with_tree_sitter(script, false) {
140 Ok(commands) if !commands.is_empty() => return Ok(commands),
141 Ok(_) => {} Err(e) => {
143 tracing::debug!("Tree-sitter bash parsing failed: {}, falling back to basic tokenization", e);
144 }
145 }
146
147 parse_with_basic_tokenization(script)
149}
150
151pub fn parse_shell_commands_tree_sitter(script: &str) -> Result<Vec<Vec<String>>, String> {
155 parse_with_tree_sitter(script, true)
156}
157
158pub fn has_only_output_redirections(script: &str) -> bool {
162 if contains_dynamic_shell_syntax(script) {
163 return false;
164 }
165 if contains_background_operator(script) {
166 return false;
167 }
168
169 let Ok(parser) = get_bash_parser() else {
170 return false;
171 };
172 let Ok(mut parser) = parser.lock() else {
173 return false;
174 };
175 let Some(tree) = parser.parse(script, None) else {
176 return false;
177 };
178 if tree.root_node().has_error() {
179 return false;
180 }
181
182 let mut saw_redirection = false;
183 if !collect_output_redirections(tree.root_node(), script, &mut saw_redirection) {
184 return false;
185 }
186 saw_redirection
187}
188
189pub(crate) fn validate_redirection_paths(script: &str) -> Result<()> {
192 use anyhow::{Context, anyhow, ensure};
193 if !script.contains(['<', '>']) {
194 return Ok(());
195 }
196 let parser = get_bash_parser().map_err(anyhow::Error::msg)?;
197 let mut parser = parser.lock().map_err(|error| anyhow!("shell parser lock poisoned: {error}"))?;
198 let tree = parser.parse(script, None).context("failed to parse shell redirections")?;
199 ensure!(!tree.root_node().has_error(), "cannot validate malformed shell redirections");
200 let mut pending = vec![tree.root_node()];
201 while let Some(node) = pending.pop() {
202 if node.kind() == "file_redirect" {
203 let text = node.utf8_text(script.as_bytes()).context("invalid shell redirection text")?;
204 let operator = text.trim_start_matches(|character: char| character.is_ascii_digit());
205 let descriptor_route = operator.starts_with(">&") || operator.starts_with("<&");
206 let mut cursor = node.walk();
207 let destinations = node.children_by_field_name("destination", &mut cursor);
208 for destination in destinations {
209 let raw = destination
210 .utf8_text(script.as_bytes())
211 .context("invalid redirection destination")?;
212 ensure!(!contains_dynamic_shell_syntax(raw), "dynamic redirection destination is not allowed");
213 let words = shell_words::split(raw).context("invalid quoted redirection destination")?;
214 ensure!(words.len() == 1, "redirection destination must be one literal path");
215 let path = words.first().context("missing redirection destination")?;
216 if descriptor_route && (path == "-" || path.chars().all(|character| character.is_ascii_digit())) {
217 continue;
218 }
219 ensure!(!path.starts_with('~'), "home-directory redirection destinations are not allowed");
224 if path == "/dev/null" {
226 continue;
227 }
228 vtcode_commons::paths::validate_path_safety(path)
229 .with_context(|| format!("unsafe shell redirection destination: {path}"))?;
230 }
231 }
232 let mut cursor = node.walk();
233 pending.extend(node.named_children(&mut cursor));
234 }
235 Ok(())
236}
237
238fn contains_background_operator(script: &str) -> bool {
239 let chars = script.chars().collect::<Vec<_>>();
240 let mut index = 0;
241 let mut in_single_quote = false;
242 let mut in_double_quote = false;
243
244 while index < chars.len() {
245 let character = chars[index];
246 if character == '\'' && !in_double_quote {
247 in_single_quote = !in_single_quote;
248 index += 1;
249 continue;
250 }
251 if character == '"' && !in_single_quote {
252 in_double_quote = !in_double_quote;
253 index += 1;
254 continue;
255 }
256 if in_single_quote || in_double_quote {
257 index += 1;
258 continue;
259 }
260
261 if character == '&' {
262 let previous = index.checked_sub(1).and_then(|position| chars.get(position));
263 let next = chars.get(index + 1);
264 if next == Some(&'&') {
265 index += 2;
266 continue;
267 }
268 let part_of_allowed_operator = next == Some(&'&')
269 || next == Some(&'>')
270 || previous == Some(&'>')
271 || previous == Some(&'|')
272 || previous == Some(&'<');
273 if !part_of_allowed_operator {
274 return true;
275 }
276 }
277 index += 1;
278 }
279
280 false
281}
282
283fn collect_output_redirections(node: tree_sitter::Node, source: &str, saw_redirection: &mut bool) -> bool {
284 match node.kind() {
285 "file_redirect" => {
286 *saw_redirection = true;
287 let Ok(text) = node.utf8_text(source.as_bytes()) else {
288 return false;
289 };
290 if !is_output_redirection(text) {
291 return false;
292 }
293 }
294 "heredoc_redirect" | "herestring_redirect" => return false,
295 _ => {}
296 }
297
298 let mut cursor = node.walk();
299 node.children(&mut cursor)
300 .all(|child| collect_output_redirections(child, source, saw_redirection))
301}
302
303fn is_output_redirection(text: &str) -> bool {
304 let redirect = text.trim_start_matches(|character: char| character.is_ascii_digit());
305 if redirect.starts_with("&>") {
306 return !redirect.starts_with("&>-");
307 }
308 if let Some(destination) = redirect.strip_prefix(">&") {
309 return destination.trim().chars().all(|character| character.is_ascii_digit());
310 }
311
312 redirect.starts_with('>') && !redirect.starts_with(">&-")
313}
314
315fn parse_with_tree_sitter(script: &str, reject_syntax_errors: bool) -> Result<Vec<Vec<String>>, String> {
317 let parser_guard = get_bash_parser()?;
318 let mut parser = parser_guard.lock().map_err(|e| format!("Failed to lock parser: {e}"))?;
319
320 let tree = parser.parse(script, None).ok_or_else(|| "Failed to parse script".to_string())?;
321
322 let mut commands = Vec::new();
323 let root = tree.root_node();
324 if reject_syntax_errors && root.has_error() {
325 return Err("Shell script contains syntax errors".to_string());
326 }
327
328 collect_commands_from_node(root, script, &mut commands);
332
333 Ok(commands)
334}
335
336fn collect_commands_from_node(node: tree_sitter::Node, source: &str, commands: &mut Vec<Vec<String>>) {
337 match node.kind() {
338 "command" | "simple_command" => {
339 if let Some(cmd) = extract_command_from_node(node, source)
340 && !cmd.is_empty()
341 {
342 commands.push(cmd);
343 }
344 }
345 _ => {
346 let mut cursor = node.walk();
347 for child in node.children(&mut cursor) {
348 collect_commands_from_node(child, source, commands);
349 }
350 }
351 }
352}
353
354fn extract_command_from_node(node: tree_sitter::Node, source: &str) -> Option<Vec<String>> {
356 let mut command = Vec::new();
357 let mut cursor = node.walk();
358
359 if node.kind() == "pipeline" {
361 for child in node.children(&mut cursor) {
362 if child.kind() == "command" || child.kind() == "simple_command" {
363 return extract_command_from_node(child, source);
364 }
365 }
366 }
367
368 for child in node.children(&mut cursor) {
370 if child.kind() == "command_name" {
371 if let Ok(arg) = child.utf8_text(source.as_bytes()) {
372 let trimmed = arg.trim();
373 if !trimmed.is_empty() {
374 command.push(trimmed.to_string());
375 }
376 }
377 continue;
378 }
379
380 if matches!(
381 child.kind(),
382 "word" | "string" | "raw_string" | "ansi_c_string" | "simple_expansion" | "variable_expansion"
383 ) {
384 let text = child.utf8_text(source.as_bytes());
385 if let Ok(arg) = text {
386 let trimmed = arg.trim();
387 if !trimmed.is_empty() {
388 command.push(trimmed.to_string());
389 }
390 }
391 }
392 }
393
394 if command.is_empty() { None } else { Some(command) }
395}
396
397fn parse_with_basic_tokenization(script: &str) -> Result<Vec<Vec<String>>, String> {
399 let mut commands = Vec::new();
400 let mut current_command = String::new();
401 let mut in_quotes = false;
402 let mut quote_char = ' ';
403 let mut escaped = false;
404
405 for ch in script.chars() {
406 if escaped {
407 current_command.push(ch);
408 escaped = false;
409 continue;
410 }
411
412 match ch {
413 '\\' => {
414 escaped = true;
415 }
416 '\'' | '"' if !in_quotes => {
417 in_quotes = true;
418 quote_char = ch;
419 }
420 c if c == quote_char && in_quotes => {
421 in_quotes = false;
422 }
423 '&' | '|' | ';' if !in_quotes => {
424 if !current_command.trim().is_empty()
425 && let Ok(cmd) = tokenize_command(¤t_command)
426 {
427 commands.push(cmd);
428 }
429 current_command.clear();
430 }
431 _ => current_command.push(ch),
432 }
433 }
434
435 if !current_command.trim().is_empty()
436 && let Ok(cmd) = tokenize_command(¤t_command)
437 {
438 commands.push(cmd);
439 }
440
441 Ok(commands)
442}
443
444fn tokenize_command(cmd: &str) -> Result<Vec<String>, String> {
447 shell_words::split(cmd).map_err(|err| format!("failed to tokenize command: {err}"))
448}
449
450pub fn parse_bash_lc_commands(command: &[String]) -> Option<Vec<Vec<String>>> {
458 if command.is_empty() {
459 return None;
460 }
461
462 let cmd_name = command[0].as_str();
463 let base_cmd = std::path::Path::new(cmd_name)
464 .file_name()
465 .and_then(|osstr| osstr.to_str())
466 .unwrap_or("");
467
468 if base_cmd != "bash" && base_cmd != "zsh" && base_cmd != "sh" {
469 return None;
470 }
471
472 for window in command.windows(2) {
474 if matches!(window[0].as_str(), "-lc" | "-c" | "-il" | "-ic") {
475 let script = &window[1];
476 return parse_shell_commands(script).ok();
477 }
478 }
479
480 None
481}
482
483#[cfg(test)]
484mod tests {
485 use super::*;
486
487 #[test]
488 fn tokenize_simple_command() {
489 let cmd = "git status";
490 let tokens = tokenize_command(cmd).unwrap();
491 assert_eq!(tokens, vec!["git", "status"]);
492 }
493
494 #[test]
495 fn tokenize_quoted_arguments() {
496 let cmd = r#"echo "hello world""#;
497 let tokens = tokenize_command(cmd).unwrap();
498 assert_eq!(tokens, vec!["echo", "hello world"]);
499 }
500
501 #[test]
502 fn parse_single_command() {
503 let script = "git status";
504 let commands = parse_shell_commands(script).unwrap();
505 assert_eq!(commands.len(), 1);
506 assert_eq!(commands[0][0], "git");
507 }
508
509 #[test]
510 fn parse_chained_commands_with_and() {
511 let script = "git status && cargo check";
512 let commands = parse_shell_commands(script).unwrap();
513 assert_eq!(commands.len(), 2);
514 assert_eq!(commands[0][0], "git");
515 assert_eq!(commands[1][0], "cargo");
516 }
517
518 #[test]
519 fn parse_loop_body_commands() {
520 let script = "cd crates/codegen/vtcode-core/src/tools/registry && for f in *.rs; do echo \"=== $f ===\"; grep -nE '^(pub )?(struct|enum|fn)' \"$f\" | head -50; done";
521 let commands = parse_shell_commands(script).unwrap();
522
523 assert_eq!(commands[0], vec!["cd", "crates/codegen/vtcode-core/src/tools/registry"]);
524 assert!(
525 commands
526 .iter()
527 .any(|command| command.first().is_some_and(|name| name == "echo"))
528 );
529 assert!(
530 commands
531 .iter()
532 .any(|command| command.first().is_some_and(|name| name == "grep"))
533 );
534 assert!(
535 commands
536 .iter()
537 .any(|command| command.first().is_some_and(|name| name == "head"))
538 );
539 }
540
541 #[test]
542 fn parse_chained_commands_with_semicolon() {
543 let script = "git status; cargo check";
544 let commands = parse_shell_commands(script).unwrap();
545 assert_eq!(commands.len(), 2);
546 }
547
548 #[test]
549 fn parse_bash_lc_git_status() {
550 let cmd = vec!["bash".to_string(), "-lc".to_string(), "git status".to_string()];
551 let commands = parse_bash_lc_commands(&cmd);
552 assert!(commands.is_some());
553 let commands = commands.unwrap();
554 assert_eq!(commands.len(), 1);
555 assert_eq!(commands[0][0], "git");
556 }
557
558 #[test]
559 fn parse_bash_lc_chained() {
560 let cmd = vec![
561 "bash".to_string(),
562 "-lc".to_string(),
563 "git status && cargo check".to_string(),
564 ];
565 let commands = parse_bash_lc_commands(&cmd);
566 assert!(commands.is_some());
567 let commands = commands.unwrap();
568 assert_eq!(commands.len(), 2);
569 }
570
571 #[test]
572 fn parse_non_bash_command_returns_none() {
573 let cmd = vec!["echo".to_string(), "hello".to_string()];
574 let commands = parse_bash_lc_commands(&cmd);
575 assert!(commands.is_none());
576 }
577
578 #[test]
579 fn parse_bash_without_lc_returns_none() {
580 let cmd = vec!["bash".to_string(), "script.sh".to_string()];
581 let commands = parse_bash_lc_commands(&cmd);
582 assert!(commands.is_none());
583 }
584
585 #[test]
588 fn parse_complex_pipeline() {
589 let script = "cat file.txt | grep -i pattern | sort";
590 let commands = parse_shell_commands(script).unwrap();
591 assert!(!commands.is_empty());
592 }
593
594 #[test]
595 fn parse_with_pipes_and_redirects() {
596 let script = "ls -la | grep file > output.txt";
597 let commands = parse_shell_commands(script).unwrap();
598 assert!(!commands.is_empty());
599 }
600
601 #[test]
602 fn parse_command_substitution_fallback() {
603 let script = "echo $(git status)";
604 let commands = parse_shell_commands(script).unwrap();
605 assert!(!commands.is_empty());
606 }
607
608 #[test]
609 fn parse_escaped_quotes() {
610 let script = r#"echo "hello \"world\"""#;
611 let commands = parse_shell_commands(script).unwrap();
612 assert!(!commands.is_empty());
613 }
614
615 #[test]
616 fn parse_tree_sitter_preserves_command_name_with_quoted_args() {
617 let script = r#"echo "fish and chips""#;
618 let commands = parse_shell_commands_tree_sitter(script).unwrap();
619 assert!(!commands.is_empty());
620 assert_eq!(commands[0][0], "echo");
621 }
622
623 #[test]
624 fn parse_tree_sitter_preserves_single_and_ansi_quoted_args() {
625 let script = r#"printf '\n' && git diff '--output=out.txt' && printf $'\n'"#;
626 let commands = parse_shell_commands_tree_sitter(script).unwrap();
627 assert!(
628 commands
629 .iter()
630 .any(|command| command.iter().any(|word| word.contains("--output=out.txt")))
631 );
632 assert!(commands.iter().any(|command| command.iter().any(|word| word.contains("\\n"))));
633 }
634
635 #[test]
636 fn dynamic_syntax_allows_literal_escapes_inside_double_quoted_arguments() {
637 assert!(!contains_dynamic_shell_syntax(r#"rg -n "\[profile|lto|codegen-units|strip" Cargo.toml"#));
638 assert!(!contains_dynamic_shell_syntax(r#"printf "\nTop-level:\n""#));
639 assert!(!contains_dynamic_shell_syntax(r#"printf "quoted: \"value\"""#));
640 assert!(contains_dynamic_shell_syntax(r#"echo "safe\"$(id)""#));
641 }
642
643 #[test]
644 fn dynamic_syntax_rejects_unquoted_escapes() {
645 assert!(contains_dynamic_shell_syntax(r"rg -n \[profile Cargo.toml"));
646 }
647
648 #[test]
649 fn output_redirection_guard_rejects_input_and_heredoc_shapes() {
650 assert!(has_only_output_redirections("cargo check > build.log 2>&1"));
651 assert!(has_only_output_redirections("cargo check | head -40 > build.log"));
652 assert!(has_only_output_redirections("cargo check &> build.log"));
653 assert!(has_only_output_redirections("cargo check &>> build.log"));
654 assert!(!has_only_output_redirections("cargo check < build-input.log"));
655 assert!(!has_only_output_redirections("cargo check <<'EOF'\ninput\nEOF"));
656 assert!(!has_only_output_redirections("cargo check > $(printf build.log)"));
657 assert!(!has_only_output_redirections("cargo check > build.log &"));
658 assert!(!has_only_output_redirections("cargo check 2>&-"));
659 }
660
661 #[test]
662 fn strict_tree_sitter_parser_rejects_incomplete_shell_syntax() {
663 assert!(parse_shell_commands_tree_sitter("cargo check &&").is_err());
664 assert!(parse_shell_commands_tree_sitter("echo '").is_err());
665 }
666
667 #[test]
668 fn parse_bash_lc_with_pipe() {
669 let cmd = vec!["bash".to_string(), "-lc".to_string(), "ls -la | head -5".to_string()];
670 let commands = parse_bash_lc_commands(&cmd);
671 assert!(commands.is_some());
672 let cmds = commands.unwrap();
673 assert!(!cmds.is_empty());
674 }
675
676 #[test]
677 fn parse_dangerous_shell_command() {
678 let script = "rm -rf /; echo done";
679 let commands = parse_shell_commands(script).unwrap();
680 assert_eq!(commands.len(), 2);
681 assert_eq!(commands[0][0], "rm");
682 }
683
684 #[test]
685 fn prewarm_bash_parser_initializes_successfully() {
686 prewarm_bash_parser().expect("bash parser should initialize");
687 }
688
689 #[test]
690 fn dynamic_find_syntax_is_detected_without_rejecting_quoted_globs() {
691 assert!(contains_dynamic_find_syntax("find src -maxdepth 0 -exe$''c touch /tmp/VT_BYPASS_POC {} +"));
692 assert!(!contains_dynamic_find_syntax("find src -type f -name '*.rs'"));
693 }
694}
695
696use anyhow::bail;
699
700pub(crate) fn quoted_heredoc_skip_len(rest: &str) -> Option<usize> {
706 let mut idx = 0usize;
707 let bytes = rest.as_bytes();
708 if bytes.first() == Some(&b'-') {
709 idx += 1;
710 }
711 while idx < bytes.len() && (bytes[idx] == b' ' || bytes[idx] == b'\t') {
712 idx += 1;
713 }
714 let quote = match bytes.get(idx) {
715 Some(b'\'') | Some(b'"') => bytes[idx],
716 _ => return None,
717 };
718 idx += 1;
719 let delim_start = idx;
720 while idx < bytes.len() && bytes[idx] != quote {
721 idx += 1;
722 }
723 if idx >= bytes.len() || idx == delim_start {
724 return None;
725 }
726 let delim = &rest[delim_start..idx];
727 idx += 1; while idx < bytes.len() && rest.as_bytes()[idx] != b'\n' {
730 idx += 1;
731 }
732 if idx < bytes.len() {
733 idx += 1; }
735 while idx <= bytes.len() {
737 let line_end = rest[idx..].find('\n').map(|offset| idx + offset).unwrap_or(bytes.len());
738 let line = rest[idx..line_end].trim_end_matches('\r');
739 let next = if line_end < bytes.len() {
740 line_end + 1
741 } else {
742 bytes.len()
743 };
744 if line == delim {
745 return Some(next);
746 }
747 if line_end >= bytes.len() {
748 return None;
749 }
750 idx = next;
751 }
752 None
753}
754
755pub(crate) fn skip_quoted_heredoc(chars: &mut std::iter::Peekable<std::str::CharIndices<'_>>) -> bool {
760 let mut probe = chars.clone();
762 let _ = probe.next(); let rest: String = probe.map(|(_, c)| c).collect();
764 let Some(skip) = quoted_heredoc_skip_len(&rest) else {
765 return false;
766 };
767 let _ = chars.next(); let mut consumed = 0usize;
769 while consumed < skip
770 && let Some((_, ch)) = chars.next()
771 {
772 consumed += ch.len_utf8();
773 }
774 true
775}
776
777#[derive(Clone, Copy, Eq, PartialEq)]
779enum QuoteState {
780 None,
781 Single,
782 Double,
783}
784
785pub(crate) fn split_shell_segments(command: &str) -> Result<Vec<String>> {
788 let mut segments = Vec::new();
789 let mut state = QuoteState::None;
790 let mut escaped = false;
791 let mut segment_start = 0usize;
792 let mut chars = command.char_indices().peekable();
793
794 while let Some((idx, ch)) = chars.next() {
795 match state {
796 QuoteState::Single => {
797 if ch == '\'' {
798 state = QuoteState::None;
799 }
800 }
801 QuoteState::Double => {
802 if escaped {
803 escaped = false;
804 continue;
805 }
806
807 match ch {
808 '\\' => escaped = true,
809 '"' => state = QuoteState::None,
810 '`' => bail!("Command injection pattern detected"),
811 '$' if matches!(chars.peek(), Some((_, '('))) => {
812 bail!("Command injection pattern detected");
813 }
814 _ => {}
815 }
816 }
817 QuoteState::None => {
818 if escaped {
819 escaped = false;
820 continue;
821 }
822
823 match ch {
824 '\\' => escaped = true,
825 '\'' => state = QuoteState::Single,
826 '"' => state = QuoteState::Double,
827 '<' if matches!(chars.peek(), Some((_, '<'))) && skip_quoted_heredoc(&mut chars) => {}
831 '`' => bail!("Command injection pattern detected"),
832 '$' if matches!(chars.peek(), Some((_, '('))) => {
833 bail!("Command injection pattern detected");
834 }
835 ';' => bail!("Unquoted command chaining detected"),
836 '\n' => bail!(
837 "multi-line shell commands are not allowed; use file tools (write_file/apply_patch) for multi-line content instead of heredocs"
838 ),
839 '|' | '&' => {
840 push_segment(command, segment_start, idx, &mut segments);
841 segment_start = idx + ch.len_utf8();
842 if let Some((next_idx, next_ch)) = chars.peek().copied()
843 && next_ch == ch
844 {
845 let _next = chars.next();
846 segment_start = next_idx + next_ch.len_utf8();
847 }
848 }
849 _ => {}
850 }
851 }
852 }
853 }
854
855 push_segment(command, segment_start, command.len(), &mut segments);
856 Ok(segments)
857}
858
859fn push_segment(command: &str, start: usize, end: usize, segments: &mut Vec<String>) {
860 let segment = command[start..end].trim();
861 if !segment.is_empty() {
862 segments.push(segment.to_string());
863 }
864}
865
866pub(crate) fn additional_dangerous_pattern(segment: &str) -> Option<&'static str> {
868 let segment_lower = segment.to_ascii_lowercase();
869 if segment_lower.starts_with(":(){:|:&};:") {
870 return Some(":(){:|:&};:");
871 }
872
873 let tokens =
874 shell_words::split(segment).unwrap_or_else(|_| segment.split_whitespace().map(ToString::to_string).collect());
875 let first = tokens.first()?;
876 let command_name = base_command_name(strip_wrapping_quotes(first)).to_ascii_lowercase();
877
878 match command_name.as_str() {
879 "rmdir" => Some("rmdir"),
880 "wget" => Some("wget"),
881 "curl" => Some("curl"),
882 "chmod" if tokens.iter().skip(1).any(|arg| strip_wrapping_quotes(arg).starts_with("777")) => Some("chmod 777"),
883 "chown"
884 if tokens.iter().skip(1).any(|arg| {
885 let arg = strip_wrapping_quotes(arg).to_ascii_lowercase();
886 arg == "root" || arg.starts_with("root:")
887 }) =>
888 {
889 Some("chown root")
890 }
891 _ => None,
892 }
893}
894
895fn strip_wrapping_quotes(token: &str) -> &str {
896 token
897 .strip_prefix('\'')
898 .and_then(|token| token.strip_suffix('\''))
899 .or_else(|| token.strip_prefix('"').and_then(|token| token.strip_suffix('"')))
900 .unwrap_or(token)
901}
902
903fn base_command_name(command: &str) -> &str {
904 std::path::Path::new(command)
905 .file_name()
906 .and_then(|name| name.to_str())
907 .unwrap_or(command)
908}