terminal-mcp 0.1.6

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

use tree_sitter::Node;

use crate::security::detect::bash::ast::get_command_name;
use crate::security::detect::bash::utils::{
    best_hit, collect_args, command_basename, path_has_marker, redirect_target, unwrap_command,
};
use crate::sec_bash_detector_rule_metadata;
use crate::security::detect::{EvaluateResult, Rule, Severity, ShellContext};

/// cron / at 写入目标路径标记。
const CRON_DIR_MARKERS: &[&str] = &[
    "/etc/cron.d",
    "cron.daily",
    "cron.hourly",
    "cron.weekly",
    "cron.monthly",
    "/var/spool/cron",
    "etc/crontab",
];

/// 检测 cron / at 持久化:crontab 篡改、向 cron 目录写入定时任务。
pub struct RuleCronPersistence;

impl RuleCronPersistence {
    sec_bash_detector_rule_metadata!(
        "bash_cron_persistence",
        "Detects cron / at persistence: crontab mutation or writing to cron directories \
         to schedule recurring malicious commands",
        Severity::Low
    );
}

fn analyze_crontab(raw_cmd: &str, args: &[&str]) -> Option<(Severity, String)> {
    // 只读列举 / 删除不告警
    if args.iter().any(|a| *a == "-l" || *a == "--list" || *a == "-r" || *a == "--remove") {
        return None;
    }
    // 无参数会打开默认编辑器;-e/-i 编辑;- 从 stdin 读取 → 都属篡改
    let sev = if args.is_empty()
        || args.iter()
            .any(|a| *a == "-e" || *a == "--edit" || *a == "-i" || *a == "-")
    {
        Severity::High
    } else {
        Severity::Medium
    };
    Some((
        sev,
        format!("command={:?} crontab mutation args={:?}", raw_cmd, args),
    ))
}

fn analyze_command(node: &Node, source: &[u8]) -> Option<(Severity, String)> {
    let raw_cmd = get_command_name(node, source)?;
    let cmd_name = command_basename(raw_cmd);
    let args = collect_args(node, source);
    let (real_cmd, real_args) = unwrap_command(&cmd_name, &args, &["crontab", "at"])?;

    match real_cmd {
        "crontab" => analyze_crontab(raw_cmd, real_args),
        "at" => {
            if real_args.is_empty() {
                return None;
            }
            Some((
                Severity::Medium,
                format!("command={:?} at job scheduling args={:?}", raw_cmd, real_args),
            ))
        }
        _ => None,
    }
}

fn analyze_redirect(node: &Node, source: &[u8]) -> Option<(Severity, String)> {
    let (_, dest) = redirect_target(node, source)?;
    if path_has_marker(dest, CRON_DIR_MARKERS) {
        return Some((
            Severity::High,
            format!("write to cron directory: {:?}", dest),
        ));
    }
    None
}

fn analyze(node: &Node, source: &[u8]) -> Option<(Severity, String)> {
    match node.kind() {
        "command" => analyze_command(node, source),
        "file_redirect" => analyze_redirect(node, source),
        _ => None,
    }
}

#[async_trait::async_trait]
impl Rule for RuleCronPersistence {
    fn meta(&self) -> &crate::security::detect::RuleMetadata {
        Self::get_meta()
    }

    async fn evaluate(
        &self,
        _data: &str,
        ctx: &ShellContext,
    ) -> anyhow::Result<EvaluateResult> {
        let best = best_hit(ctx, analyze).await?;
        Ok(match best {
            Some((sev, evidence)) => EvaluateResult::hit_with_severity(evidence, sev),
            None => EvaluateResult::Miss,
        })
    }
}

#[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_cron_persistence")
                    .unwrap_or_else(|| {
                        panic!("payload {:?} did not trigger bash_cron_persistence: {:#?}", 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_cron_persistence")
            }
            _ => None,
        };
        assert!(
            hit.is_none(),
            "payload {:?} should not trigger bash_cron_persistence, got {:#?}",
            payload, res
        );
    }

    #[tokio::test]
    async fn test_cron_mutation() {
        let d = get_detector();
        expect_hit(&d, "(crontab -l; echo '*/1 * * * * /tmp/x') | crontab -", Severity::High).await;
        expect_hit(&d, "crontab -e", Severity::High).await;
        expect_hit(&d, "crontab", Severity::High).await;
        expect_hit(&d, "echo '* * * * * /bin/sh -i' | crontab -", Severity::High).await;
        expect_hit(&d, "sudo crontab -e", Severity::High).await;
    }

    #[tokio::test]
    async fn test_cron_file_write() {
        let d = get_detector();
        expect_hit(&d, "echo '*/1 * * * * /tmp/x' > /etc/cron.d/evil", Severity::High).await;
        expect_hit(&d, "echo hi >> /etc/crontab", Severity::High).await;
        expect_hit(&d, "printf '* * * * * root bash /tmp/x' > /var/spool/cron/root", Severity::High).await;
    }

    #[tokio::test]
    async fn test_at_schedule() {
        let d = get_detector();
        expect_hit(&d, "echo '/tmp/evil.sh' | at now", Severity::Medium).await;
        expect_hit(&d, "echo 'ls /tmp' | at midnight", Severity::Medium).await;
    }

    #[tokio::test]
    async fn test_cron_safe() {
        let d = get_detector();
        expect_safe(&d, "crontab -l").await;
        expect_safe(&d, "crontab -r").await;
        expect_safe(&d, "ls /etc/cron.d").await;
        expect_safe(&d, "echo 'crontab -e'").await;
    }
}