use std::sync::LazyLock;
use tree_sitter::{Node, Query, QueryCursor, StreamingIterator};
use crate::security::detect::bash::ast::{get_command_name, language, CurrentAst};
use crate::security::detect::bash::utils::{
collect_args, cluster_has_flag, normalize_target, PREFIX_COMMANDS,
};
use crate::security::detect::utils::name_normalize;
use crate::security::detect::{EvaluateResult, Rule, RuleMetadata, Severity, ShellContext};
const CRITICAL_CORE_DIRS: &[&str] = &[
"/", "/etc", "/usr", "/bin", "/sbin", "/lib", "/lib64", "/boot", "/var", "/dev", "/proc",
"/sys", "/root",
];
const HIGH_KEY_DIRS: &[&str] = &[
"/home", "/opt", "/srv", "/mnt", "/media", "/run", "/tmp", "/var/log",
];
pub struct RuleDestructiveRm;
impl RuleDestructiveRm {
fn get_meta() -> &'static RuleMetadata {
static META: LazyLock<RuleMetadata> = LazyLock::new(|| RuleMetadata {
name: "bash_destructive_rm".to_string(),
description:
"Detects destructive rm (recursive or not) targeting system-critical directories \
or user home directories"
.to_string(),
default_severity: Severity::Low,
});
&META
}
fn get_query() -> &'static Query {
static QUERY: LazyLock<Query> =
LazyLock::new(|| Query::new(&language(), "(command) @cmd").expect("invalid query"));
&QUERY
}
}
#[async_trait::async_trait]
impl Rule for RuleDestructiveRm {
fn meta(&self) -> &RuleMetadata {
Self::get_meta()
}
async fn evaluate(
&self,
_data: &str,
ctx: &ShellContext,
) -> anyhow::Result<EvaluateResult> {
let current = ctx
.extensions
.get::<CurrentAst>()
.ok_or_else(|| anyhow::anyhow!("CurrentAst missing"))?;
let blocks = current.blocks.read().await;
let query = Self::get_query();
let mut best: Option<(Severity, String)> = None;
for block in blocks.iter() {
let source_bytes = block.source.as_bytes();
let mut cursor = QueryCursor::new();
let mut matches = cursor.matches(query, block.tree.root_node(), source_bytes);
while let Some(m) = tree_sitter::StreamingIterator::next(&mut matches) {
for capture in m.captures {
if let Some((sev, evidence)) = analyze_command(&capture.node, source_bytes)
&& best.as_ref().is_none_or(|(b, _)| sev > *b)
{
best = Some((sev, evidence));
}
}
}
}
Ok(match best {
Some((sev, evidence)) => EvaluateResult::hit_with_severity(evidence, sev),
None => EvaluateResult::Miss,
})
}
}
fn resolve_rm<'a>(cmd_name: &str, args: &'a [&'a str]) -> Option<&'a [&'a str]> {
if cmd_name == "rm" {
return Some(args);
}
if PREFIX_COMMANDS.contains(&cmd_name) && let Some(idx) = args.iter().position(|a| *a == "rm") {
return Some(&args[idx + 1..]);
}
None
}
fn parse_rm_args(args: &[&str]) -> (bool, bool, Vec<String>) {
let mut recursive = false;
let mut no_preserve_root = false;
let mut targets = Vec::new();
let mut after_dashdash = false;
for a in args {
if after_dashdash {
targets.push(a.to_string());
continue;
}
match *a {
"--" => after_dashdash = true,
"--recursive" => recursive = true,
"--no-preserve-root" => no_preserve_root = true,
"-r" | "-R" => recursive = true,
_ => {
if a.starts_with('-') && a.len() > 1 {
if cluster_has_flag(a, 'r') || cluster_has_flag(a, 'R') {
recursive = true;
}
} else {
targets.push(a.to_string());
}
}
}
}
(recursive, no_preserve_root, targets)
}
fn classify_target(t: &str) -> Option<Severity> {
let p = normalize_target(t);
if p.is_empty() {
return None;
}
if p == "~" || p.starts_with("~/") {
return Some(Severity::High);
}
let dir = match p.find(['*', '?', '[']) {
Some(idx) => {
let trimmed = p[..idx].trim_end_matches('/');
if trimmed.is_empty() {
"/"
} else {
trimmed
}
}
None => &p[..],
};
classify_dir(dir)
}
fn classify_dir(dir: &str) -> Option<Severity> {
if CRITICAL_CORE_DIRS.contains(&dir) {
return Some(Severity::Critical);
}
for core in CRITICAL_CORE_DIRS {
if *core != "/" && dir.starts_with(*core) && dir[core.len()..].starts_with('/') {
return Some(Severity::High);
}
}
for key in HIGH_KEY_DIRS {
if dir == *key || (dir.starts_with(*key) && dir[key.len()..].starts_with('/')) {
return Some(Severity::High);
}
}
None
}
fn is_exact_core_dir(t: &str) -> bool {
let p = normalize_target(t);
CRITICAL_CORE_DIRS.contains(&p.as_str())
}
fn determine_severity(
recursive: bool,
no_preserve_root: bool,
targets: &[String],
) -> Option<(Severity, String)> {
if targets.is_empty() {
return None;
}
let mut effective: Vec<(String, Severity)> = Vec::new();
for t in targets {
if let Some(s) = classify_target(t) {
effective.push((t.clone(), s));
}
}
if !recursive {
effective.retain(|(p, s)| !(*s == Severity::Critical && is_exact_core_dir(p)));
}
let sev = if no_preserve_root {
Severity::Critical
} else if effective.is_empty() {
return None;
} else {
effective.iter().map(|(_, s)| *s).max().unwrap()
};
if !recursive && sev == Severity::Critical && effective.is_empty() {
return None;
}
let final_sev = if !recursive {
sev.min(Severity::High)
} else {
sev
};
let detail: Vec<String> = if effective.is_empty() {
targets.iter().map(|p| format!("{}={:?}", p, Severity::Critical)).collect()
} else {
effective
.iter()
.map(|(p, s)| format!("{}={:?}", p, s))
.collect()
};
let evidence = format!(
"recursive={} no_preserve_root={} targets=[{}]",
recursive,
no_preserve_root,
detail.join(", ")
);
Some((final_sev, evidence))
}
fn analyze_command(node: &Node, source: &[u8]) -> Option<(Severity, String)> {
let raw_cmd = get_command_name(node, source)?;
let cmd_name = name_normalize(raw_cmd).ok()?;
let args = collect_args(node, source);
let rm_args = resolve_rm(&cmd_name, &args)?;
let (recursive, no_preserve_root, targets) = parse_rm_args(rm_args);
let (sev, evidence) = determine_severity(recursive, no_preserve_root, &targets)?;
let orig = node.utf8_text(source).ok().unwrap_or("").to_string();
Some((sev, format!("command={:?} {}", orig, evidence)))
}
#[cfg(test)]
mod test {
use std::collections::HashMap;
use super::*;
use crate::security::detect::bash::BashDetector;
use crate::security::detect::{DetectResult, Detector, ShellContext};
fn get_detector() -> BashDetector {
let ctx = ShellContext::new("/bin/bash", HashMap::new(), 100);
BashDetector::new(ctx, 4096)
}
async fn expect_hit(detector: &BashDetector, payload: &str, expected: Severity) {
let res = detector.detect(payload.to_string(), false, true).await;
match &res {
DetectResult::ThreatDetected(hits) => {
let hit = hits
.iter()
.find(|h| h.rule_meta.name == "bash_destructive_rm")
.unwrap_or_else(|| {
panic!("payload {:?} did not trigger bash_destructive_rm: {:#?}", payload, res)
});
assert_eq!(
hit.final_severity, expected,
"payload {:?}, evidence {:?}",
payload, hit.evidence
);
}
_ => panic!("payload {:?} expected ThreatDetected, got {:#?}", payload, res),
}
}
async fn expect_safe(detector: &BashDetector, payload: &str) {
let res = detector.detect(payload.to_string(), false, true).await;
let hit = match &res {
DetectResult::ThreatDetected(hits) => {
hits.iter().find(|h| h.rule_meta.name == "bash_destructive_rm")
}
_ => None,
};
assert!(
hit.is_none(),
"payload {:?} should not trigger bash_destructive_rm, got {:#?}",
payload, res
);
}
#[tokio::test]
async fn test_rm_critical() {
let d = get_detector();
expect_hit(&d, "rm -rf /", Severity::Critical).await;
expect_hit(&d, "rm -rf /etc", Severity::Critical).await;
expect_hit(&d, "rm -rf /usr", Severity::Critical).await;
expect_hit(&d, "rm -rfv /etc", Severity::Critical).await;
expect_hit(&d, "rm -rfv /var", Severity::Critical).await;
expect_hit(&d, "rm -r -f /boot", Severity::Critical).await;
expect_hit(&d, "rm -rf /*", Severity::Critical).await;
expect_hit(&d, "rm -rf /etc/*", Severity::Critical).await;
}
#[tokio::test]
async fn test_rm_with_sudo_prefix() {
let d = get_detector();
expect_hit(&d, "sudo rm -rf /", Severity::Critical).await;
expect_hit(&d, "sudo -u root rm -rf /usr", Severity::Critical).await;
expect_hit(&d, "sudo -- rm -rf /etc", Severity::Critical).await;
expect_hit(&d, "env -i rm -rf --no-preserve-root /", Severity::Critical).await;
expect_hit(&d, "nohup rm -rf /", Severity::Critical).await;
expect_hit(&d, "time rm -rf /home", Severity::High).await;
}
#[tokio::test]
async fn test_rm_no_preserve_root() {
let d = get_detector();
expect_hit(&d, "rm -rf --no-preserve-root /data", Severity::Critical).await;
}
#[tokio::test]
async fn test_rm_high() {
let d = get_detector();
expect_hit(&d, "rm -rf /home", Severity::High).await;
expect_hit(&d, "rm -rf /home/user/*", Severity::High).await;
expect_hit(&d, "rm -rf ~/foo", Severity::High).await;
expect_hit(&d, "rm -rf /tmp", Severity::High).await;
expect_hit(&d, "rm -rf /etc/nginx", Severity::High).await;
expect_hit(&d, "rm -rf /usr/local", Severity::High).await;
expect_hit(&d, "rm -rf /opt /tmp", Severity::High).await;
}
#[tokio::test]
async fn test_rm_non_recursive_capped() {
let d = get_detector();
expect_hit(&d, "rm /etc/passwd", Severity::High).await;
expect_hit(&d, "rm -f /home/user/.bashrc", Severity::High).await;
expect_hit(&d, "sudo rm /etc/shadow", Severity::High).await;
}
#[tokio::test]
async fn test_rm_multiple_targets_max() {
let d = get_detector();
expect_hit(&d, "rm -rf /etc /home/u", Severity::Critical).await;
}
#[tokio::test]
async fn test_rm_obfuscated() {
let d = get_detector();
expect_hit(&d, "\\rm -rf /", Severity::Critical).await;
expect_hit(&d, "r\\m -rf /etc", Severity::Critical).await;
}
#[tokio::test]
async fn test_rm_safe() {
let d = get_detector();
expect_safe(&d, "echo 'rm -rf /'").await;
expect_safe(&d, "rm file.txt").await;
expect_safe(&d, "rm /etc").await;
expect_safe(&d, "sudo rm /etc").await;
expect_safe(&d, "sudo rm -rf /some/random/dir").await;
expect_safe(&d, "ls -la /dev/tcp/").await;
expect_safe(&d, "rm -rf data").await;
}
}