terminal-mcp 0.1.6

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

use tree_sitter::Node;

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

/// 绑定 shell / 监听器工具。
const BIND_TOOLS: &[&str] = &["nc", "netcat", "ncat", "socat"];

/// 检测绑定 shell 与网络监听器(nc/ncat/socat),
/// 与反向 shell 规则互补,覆盖 `nc -lvnp -e /bin/bash`、`socat TCP-LISTEN` 等。
pub struct RuleBindShell;

impl RuleBindShell {
    sec_bash_detector_rule_metadata!(
        "bash_bind_shell",
        "Detects bind shells and network listeners (nc/ncat/socat) used to open \
         remote access to the host",
        Severity::Low
    );
}

fn is_shell_target(prog: &str) -> bool {
    let first = prog
        .split(|c: char| c.is_whitespace() || c == ',')
        .next()
        .unwrap_or(prog);
    shell_is_unix(first)
}

fn analyze_nc(raw_cmd: &str, args: &[&str]) -> Option<(Severity, String)> {
    let mut listen = false;
    let mut exec: Option<&str> = None;

    let mut i = 0;
    while i < args.len() {
        let a = args[i];
        if a == "--listen" || a == "--listen-port" || a == "--keep-open" {
            listen = true;
        } else if a.starts_with('-') && a.len() > 1 {
            if a.contains('l') || a.contains('L') {
                listen = true;
            }
            if (a == "-c"
                || a == "--exec"
                || a == "--sh-exec"
                || a == "--exec-cmd"
                || cluster_has_flag(a, 'e'))
                && let Some(next) = args.get(i + 1)
            {
                exec = Some(next);
                i += 1;
            }
        }
        i += 1;
    }

    if let Some(ex) = exec {
        let sev = if is_shell_target(ex) {
            Severity::Critical
        } else {
            Severity::High
        };
        return Some((
            sev,
            format!("command={:?} bind/exec: exec={:?}", raw_cmd, ex),
        ));
    }
    if listen {
        return Some((
            Severity::High,
            format!("command={:?} listener: args={:?}", raw_cmd, args),
        ));
    }
    None
}

fn analyze_socat(raw_cmd: &str, args: &[&str]) -> Option<(Severity, String)> {
    let mut listen = false;
    let mut exec: Option<&str> = None;

    for a in args {
        if a.to_uppercase().contains("LISTEN:") {
            listen = true;
        }
        if let Some(rest) = a.strip_prefix("EXEC:").or_else(|| a.strip_prefix("SYSTEM:")) {
            exec = Some(rest);
        }
    }

    if let Some(ex) = exec {
        let sev = if is_shell_target(ex) {
            Severity::Critical
        } else {
            Severity::High
        };
        return Some((
            sev,
            format!("command={:?} bind/exec: exec={:?}", raw_cmd, ex),
        ));
    }
    if listen {
        return Some((
            Severity::High,
            format!("command={:?} listener: args={:?}", raw_cmd, args),
        ));
    }
    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, BIND_TOOLS)?;
    if !BIND_TOOLS.contains(&real_cmd) {
        return None;
    }

    match real_cmd {
        "nc" | "netcat" | "ncat" => analyze_nc(raw_cmd, real_args),
        "socat" => analyze_socat(raw_cmd, real_args),
        _ => None,
    }
}

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

    #[tokio::test]
    async fn test_bind_shell_exec_critical() {
        let d = get_detector();
        expect_hit(&d, "nc -lvnp 4444 -e /bin/bash", Severity::Critical).await;
        expect_hit(&d, "nc -l 4444 -c /bin/sh", Severity::Critical).await;
        expect_hit(&d, "ncat -lvnp 4444 --exec /bin/bash", Severity::Critical).await;
        expect_hit(&d, "socat TCP-LISTEN:4444 EXEC:/bin/sh", Severity::Critical).await;
        expect_hit(&d, "socat TCP-LISTEN:4444 SYSTEM:bash", Severity::Critical).await;
        expect_hit(&d, "sudo nc -lvnp 4444 -e /bin/sh", Severity::Critical).await;
    }

    #[tokio::test]
    async fn test_bind_listener_high() {
        let d = get_detector();
        expect_hit(&d, "nc -lvnp 4444", Severity::High).await;
        expect_hit(&d, "nc -l 1234", Severity::High).await;
        expect_hit(&d, "socat TCP-LISTEN:8080,fork TCP:127.0.0.1:80", Severity::High).await;
        expect_hit(&d, "nc -lvnp 4444 -e /bin/ls", Severity::High).await;
    }

    #[tokio::test]
    async fn test_bind_safe() {
        let d = get_detector();
        expect_safe(&d, "nc -z -v 10.0.0.1 22").await;
        expect_safe(&d, "nc 10.0.0.1 80").await;
        expect_safe(&d, "curl http://example.com").await;
        expect_safe(&d, "echo 'nc -l 4444'").await;
    }
}