terminal-mcp 0.1.6

Model Context Protocol (MCP) server for long-lived shell execution.
// src/security/detect/bash/rules/destructive_rm.rs

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};

/// 系统核心目录:递归删除整目录直接定级 Critical。
const CRITICAL_CORE_DIRS: &[&str] = &[
    "/", "/etc", "/usr", "/bin", "/sbin", "/lib", "/lib64", "/boot", "/var", "/dev", "/proc",
    "/sys", "/root",
];

/// 系统关键目录 + 用户家目录:定级 High。
const HIGH_KEY_DIRS: &[&str] = &[
    "/home", "/opt", "/srv", "/mnt", "/media", "/run", "/tmp", "/var/log",
];

/// 检测针对系统核心目录 / 用户家目录的破坏性 `rm` 调用(含 `sudo rm`)。
///
/// 设计要点:
/// - 只消费 `CurrentAst` 中反混淆归一化后的 blocks,`r\m -rf /`、`\rm -rf /`
///   等混淆形式在进入本规则前已被还原为 `rm -rf /`;
/// - `bash -c 'rm -rf /'` / `$(rm -rf /)` 由 deobf Phase B 拆成独立子块后命中;
/// - `echo 'rm -rf /'` 这类字符串内容不是 `command` 节点,天然 Miss。
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,
        })
    }
}

/// 定位真实的 `rm` 及其参数。
///
/// - 命令名本身是 `rm`:全部参数即 rm 的参数;
/// - 命令名是前缀白名单(sudo/env/...):在参数中查找字面 token `rm`,
///   其后的 tokens 视为 rm 的参数(避免 `sudo -u root rm` 把 `root` 误当命令);
/// - 其它命令(echo 等):返回 None。
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
}

/// 解析 rm 参数:递归标志、--no-preserve-root、目标列表。
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 {
                    // 短选项簇:-rf / -fr / -Rfv / -vRf ...
                    if cluster_has_flag(a, 'r') || cluster_has_flag(a, 'R') {
                        recursive = true;
                    }
                } else {
                    targets.push(a.to_string());
                }
            }
        }
    }

    (recursive, no_preserve_root, targets)
}

/// 对单个目标路径定级;无法判定(未知路径)返回 None。
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);
    }
    // 通配符:按第一个通配符前的根组件定级,例如 /* -> /、/etc/* -> /etc、/home/* -> /home
    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);
    }
    // 核心目录的子路径(/etc/nginx、/usr/local...)→ High
    for core in CRITICAL_CORE_DIRS {
        if *core != "/" && dir.starts_with(*core) && dir[core.len()..].starts_with('/') {
            return Some(Severity::High);
        }
    }
    // 关键目录及其子路径 → High
    for key in HIGH_KEY_DIRS {
        if dir == *key || (dir.starts_with(*key) && dir[key.len()..].starts_with('/')) {
            return Some(Severity::High);
        }
    }
    None
}

/// 目标是否为"精确命中核心目录"(非递归 rm 删不了目录,属无效攻击)。
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 {
        // 无 -r/-R:剔除"精确命中核心目录"这一无效目标
        effective.retain(|(p, s)| !(*s == Severity::Critical && is_exact_core_dir(p)));
    }

    let sev = if no_preserve_root {
        // --no-preserve-root 是强威胁信号:只要还有待删目标即强制 Critical
        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() {
        // 非递归 + --no-preserve-root,但所有目标都被判定为无效(无可删目标)→ 不告警
        return None;
    }

    let final_sev = if !recursive {
        // 非递归删除(如 rm /etc/passwd)封顶 High
        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))
}

/// 分析单个 `command` 节点是否为破坏性 rm,返回 (最终严重度, 证据)。
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();
        // 即便目标本身不危险,显式 --no-preserve-root 也定级 Critical
        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;
    }
}