terminal-mcp 0.1.5

Model Context Protocol (MCP) server for long-lived shell execution.
// src/security/detect/bash/rules/sensitive_read.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, unwrap_command,
};
use crate::sec_bash_detector_rule_metadata;
use crate::security::detect::{EvaluateResult, Rule, Severity, ShellContext};

/// 会读取文件内容的命令白名单(排除 find/sort/wc 等纯处理命令)。
const READER_COMMANDS: &[&str] = &[
    "cat", "tac", "tail", "head", "less", "more", "view", "strings", "base64", "sed", "awk",
    "grep", "egrep", "rg", "xxd", "od", "hexdump", "nl", "openssl", "vi", "vim", "nano",
];

/// 高敏目标:命中即 High。
const HIGH_SENSITIVE_MARKERS: &[&str] = &[
    "etc/shadow",
    "etc/gshadow",
    "etc/sudoers",
    "security/opasswd",
    ".ssh/id_rsa",
    ".ssh/id_ed25519",
    ".ssh/id_ecdsa",
    ".ssh/id_dsa",
    ".ssh/authorized_keys",
    ".aws/credentials",
    ".git-credentials",
    ".kube/config",
    ".netrc",
    "ssl/private",
];

/// 中敏目标:命中即 Medium。
const MEDIUM_SENSITIVE_MARKERS: &[&str] = &[
    "etc/passwd",
    "etc/group",
    ".env",
    "wp-config.php",
    ".docker/config.json",
    "config/gcloud",
];

/// 检测针对敏感系统文件的读取行为(侦察阶段常见手法)。
pub struct RuleSensitiveFileRead;

impl RuleSensitiveFileRead {
    sec_bash_detector_rule_metadata!(
        "bash_sensitive_file_read",
        "Detects reads of sensitive files (passwords, SSH keys, cloud credentials) \
         commonly targeted during reconnaissance",
        Severity::Low
    );
}

fn classify_target(t: &str) -> Option<Severity> {
    if path_has_marker(t, HIGH_SENSITIVE_MARKERS) {
        return Some(Severity::High);
    }
    if path_has_marker(t, MEDIUM_SENSITIVE_MARKERS) {
        return Some(Severity::Medium);
    }
    None
}

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, READER_COMMANDS)?;
    if !READER_COMMANDS.contains(&real_cmd) {
        return None;
    }

    let mut best: Option<Severity> = None;
    let mut evidence: Vec<String> = Vec::new();
    for arg in real_args {
        if let Some(sev) = classify_target(arg) {
            if best.is_none_or(|b| sev > b) {
                best = Some(sev);
            }
            evidence.push(format!("{:?}={:?}", arg, sev));
        }
    }

    let sev = best?;
    Some((
        sev,
        format!("command={:?} read=[{}]", raw_cmd, evidence.join(", ")),
    ))
}

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

#[async_trait::async_trait]
impl Rule for RuleSensitiveFileRead {
    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_sensitive_file_read")
                    .unwrap_or_else(|| {
                        panic!("payload {:?} did not trigger bash_sensitive_file_read: {:#?}", 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_sensitive_file_read")
            }
            _ => None,
        };
        assert!(
            hit.is_none(),
            "payload {:?} should not trigger bash_sensitive_file_read, got {:#?}",
            payload, res
        );
    }

    #[tokio::test]
    async fn test_read_high_sensitive() {
        let d = get_detector();
        expect_hit(&d, "cat /etc/shadow", Severity::High).await;
        expect_hit(&d, "cat /etc/sudoers", Severity::High).await;
        expect_hit(&d, "cat ~/.ssh/id_rsa", Severity::High).await;
        expect_hit(&d, "cat $HOME/.ssh/id_rsa", Severity::High).await;
        expect_hit(&d, "tail -n 100 /root/.aws/credentials", Severity::High).await;
        expect_hit(&d, "base64 ~/.ssh/authorized_keys", Severity::High).await;
        expect_hit(&d, "sudo cat /etc/shadow", Severity::High).await;
        expect_hit(&d, "grep root /etc/shadow", Severity::High).await;
        expect_hit(&d, "cat ~/.kube/config", Severity::High).await;
    }

    #[tokio::test]
    async fn test_read_medium_sensitive() {
        let d = get_detector();
        expect_hit(&d, "cat /etc/passwd", Severity::Medium).await;
        expect_hit(&d, "head -1 /etc/passwd", Severity::Medium).await;
        expect_hit(&d, "cat /var/www/html/wp-config.php", Severity::Medium).await;
    }

    #[tokio::test]
    async fn test_read_safe() {
        let d = get_detector();
        expect_safe(&d, "cat README.md").await;
        expect_safe(&d, "ls /etc").await;
        expect_safe(&d, "cat /dev/null").await;
        expect_safe(&d, "echo 'cat /etc/shadow'").await;
        expect_safe(&d, "grep pattern /etc/hostname").await;
    }

    #[tokio::test]
    async fn test_read_obfuscated() {
        let d = get_detector();
        expect_hit(&d, "c$9a$1t /etc/s$7hadow", Severity::High).await;
        expect_hit(&d, "\\cat /etc/shadow", Severity::High).await;
    }
}