//! Follow-up transport, auth, and local probe analyzers.
//!
//! These are the catalog rows  left documented but unimplemented:
//! SEC-024 through SEC-032. They follow the same evidence-analyzer style as the
//! first batch in the parent module: each reads an optional field on
//! [`ProbeEvidence`], absent or ambiguous evidence produces no finding, and the
//! slash-name is the finding `name` with the SEC id as `rule_id`. The fuzzy
//! ones (session id, scope, install source) carry a documented heuristic and
//! only flag what the evidence clearly shows, honoring the catalog's "partial"
//! detectability.

use super::finding;
use super::{CheckCategory, ProbeEvidence, SecurityFinding, SecuritySeverity};

/// Run every  follow-up probe over the evidence, in catalog order.
pub(super) fn followup_findings(evidence: &ProbeEvidence) -> Vec<SecurityFinding> {
    let mut out = Vec::new();
    out.extend(rate_limit_present(evidence));
    out.extend(capability_attestation(evidence));
    out.extend(sampling_origin_auth(evidence));
    out.extend(unbounded_response(evidence));
    out.extend(resource_indicators(evidence));
    out.extend(scope_minimization(evidence));
    out.extend(session_id_hygiene(evidence));
    out.extend(confused_deputy_posture(evidence));
    out.extend(install_source_provenance(evidence));
    out
}

/// SEC-024: a controlled burst that saw no rate limiting. Unbounded request
/// volume is an unbounded-consumption risk (LLM10).
fn rate_limit_present(evidence: &ProbeEvidence) -> Option<SecurityFinding> {
    if evidence.burst_all_succeeded == Some(true) {
        return Some(finding(
            "SEC-024",
            "transport/rate-limit-present",
            SecuritySeverity::Low,
            CheckCategory::Transport,
            "<transport>".to_string(),
            "a controlled burst saw no rate limiting; the server accepts unbounded request volume"
                .to_string(),
            "burst fully succeeded".to_string(),
        ));
    }
    None
}

/// SEC-025: capabilities advertised without an attestation. A client cannot
/// tell whether the advertised surface is genuine (Breaking the Protocol).
fn capability_attestation(evidence: &ProbeEvidence) -> Option<SecurityFinding> {
    if evidence.capabilities_advertised == Some(true)
        && evidence.capability_attestation_present == Some(false)
    {
        return Some(finding(
            "SEC-025",
            "transport/capability-attestation",
            SecuritySeverity::Low,
            CheckCategory::Transport,
            "<transport>".to_string(),
            "server advertises capabilities without an attestation; the surface cannot be verified"
                .to_string(),
            "capabilities advertised, no attestation".to_string(),
        ));
    }
    None
}

/// SEC-026: sampling used without authenticating the origin. A server that
/// initiates model calls without proving who it is can be impersonated.
fn sampling_origin_auth(evidence: &ProbeEvidence) -> Option<SecurityFinding> {
    if evidence.sampling_used == Some(true) && evidence.sampling_origin_authenticated == Some(false)
    {
        return Some(finding(
            "SEC-026",
            "transport/sampling-origin-auth",
            SecuritySeverity::Medium,
            CheckCategory::Transport,
            "<transport>".to_string(),
            "server uses sampling without authenticating the origin".to_string(),
            "sampling used, origin not authenticated".to_string(),
        ));
    }
    None
}

/// SEC-027: a response that can grow without a server-side bound. Fires when a
/// response was observed and the server declared no bound, or the observed size
/// exceeded the declared bound (unbounded consumption, LLM10).
fn unbounded_response(evidence: &ProbeEvidence) -> Option<SecurityFinding> {
    let observed = evidence.observed_max_response_bytes?;
    match evidence.declared_response_bound_bytes {
        None => Some(finding(
            "SEC-027",
            "transport/unbounded-response",
            SecuritySeverity::Low,
            CheckCategory::Transport,
            "<transport>".to_string(),
            "server declared no response-size bound; a response can grow without limit".to_string(),
            format!("observed {observed} bytes, no declared bound"),
        )),
        Some(bound) if observed > bound => Some(finding(
            "SEC-027",
            "transport/unbounded-response",
            SecuritySeverity::Low,
            CheckCategory::Transport,
            "<transport>".to_string(),
            "an observed response exceeded the server's declared size bound".to_string(),
            format!("observed {observed} bytes, declared bound {bound}"),
        )),
        Some(_) => None,
    }
}

/// SEC-028: the server does not advertise or honor RFC 8707 resource
/// indicators, so a token's intended audience cannot be scoped.
fn resource_indicators(evidence: &ProbeEvidence) -> Option<SecurityFinding> {
    if evidence.resource_indicators_supported == Some(false) {
        return Some(finding(
            "SEC-028",
            "auth/resource-indicators",
            SecuritySeverity::Medium,
            CheckCategory::Auth,
            "<auth>".to_string(),
            "server does not advertise or honor RFC 8707 resource indicators".to_string(),
            "resource indicators not supported".to_string(),
        ));
    }
    None
}

/// SEC-029: a wildcard or omnibus scope. The MCP spec wants scopes minimized;
/// `*` or an all-access scope grants more than any one client needs.
///
/// Heuristic: a scope is flagged when it is exactly `*`, contains a `*`
/// wildcard, or matches a known omnibus name (`admin`, `all`, `full`,
/// `full_access`, `superuser`, `root`), case-insensitive. The first matching
/// scope is reported.
fn scope_minimization(evidence: &ProbeEvidence) -> Option<SecurityFinding> {
    let flagged = evidence
        .advertised_scopes
        .iter()
        .find(|scope| is_omnibus_scope(scope))?;
    Some(finding(
        "SEC-029",
        "auth/scope-minimization",
        SecuritySeverity::Medium,
        CheckCategory::Auth,
        "<auth>".to_string(),
        "server publishes a wildcard or omnibus scope; scopes should be minimized".to_string(),
        flagged.clone(),
    ))
}

/// True for a wildcard or omnibus scope. See [`scope_minimization`] for the
/// documented heuristic.
fn is_omnibus_scope(scope: &str) -> bool {
    if scope.contains('*') {
        return true;
    }
    let lowered = scope.to_lowercase();
    const OMNIBUS: [&str; 6] = ["admin", "all", "full", "full_access", "superuser", "root"];
    OMNIBUS.contains(&lowered.as_str())
}

/// SEC-030: predictable session IDs, or session IDs used as an auth credential.
/// The MCP spec requires high-entropy, non-authenticating session IDs.
///
/// Heuristic: a single ID is predictable when it is shorter than 16 characters,
/// is all decimal digits (a counter), or is a single repeated character. A set
/// of two or more IDs is predictable when they parse as consecutive integers
/// (a sequence). Either of those, or `session_used_for_auth == Some(true)`,
/// fires the High finding.
fn session_id_hygiene(evidence: &ProbeEvidence) -> Option<SecurityFinding> {
    if evidence.session_used_for_auth == Some(true) {
        return Some(finding(
            "SEC-030",
            "auth/session-id-hygiene",
            SecuritySeverity::High,
            CheckCategory::Auth,
            "<auth>".to_string(),
            "session ID is used as an authentication credential; the spec forbids this".to_string(),
            "session used for auth".to_string(),
        ));
    }
    if let Some(weak) = evidence
        .observed_session_ids
        .iter()
        .find(|id| is_low_entropy_session_id(id))
    {
        return Some(finding(
            "SEC-030",
            "auth/session-id-hygiene",
            SecuritySeverity::High,
            CheckCategory::Auth,
            "<auth>".to_string(),
            "session ID is low entropy and predictable".to_string(),
            weak.clone(),
        ));
    }
    if session_ids_are_sequential(&evidence.observed_session_ids) {
        return Some(finding(
            "SEC-030",
            "auth/session-id-hygiene",
            SecuritySeverity::High,
            CheckCategory::Auth,
            "<auth>".to_string(),
            "session IDs form a predictable sequence".to_string(),
            evidence.observed_session_ids.join(", "),
        ));
    }
    None
}

/// True for a single low-entropy session ID. See [`session_id_hygiene`] for the
/// documented heuristic.
fn is_low_entropy_session_id(id: &str) -> bool {
    if id.chars().count() < 16 {
        return true;
    }
    if !id.is_empty() && id.chars().all(|c| c.is_ascii_digit()) {
        return true;
    }
    let mut chars = id.chars();
    match chars.next() {
        Some(first) => chars.all(|c| c == first),
        None => true,
    }
}

/// True when two or more IDs parse as consecutive integers (a counter
/// sequence). A non-numeric ID makes the set not a sequence.
fn session_ids_are_sequential(ids: &[String]) -> bool {
    if ids.len() < 2 {
        return false;
    }
    let mut values = Vec::with_capacity(ids.len());
    for id in ids {
        match id.parse::<u128>() {
            Ok(v) => values.push(v),
            Err(_) => return false,
        }
    }
    values.windows(2).all(|pair| pair[1] == pair[0] + 1)
}

/// SEC-031: a proxy server that lacks per-client consent or exact
/// `redirect_uri` matching is open to a confused-deputy attack. Either gap,
/// when the evidence shows it, fires the Medium finding.
fn confused_deputy_posture(evidence: &ProbeEvidence) -> Option<SecurityFinding> {
    let no_consent = evidence.proxy_per_client_consent == Some(false);
    let loose_redirect = evidence.proxy_exact_redirect_uri_match == Some(false);
    if !no_consent && !loose_redirect {
        return None;
    }
    let gap = match (no_consent, loose_redirect) {
        (true, true) => "no per-client consent and no exact redirect_uri match",
        (true, false) => "no per-client consent",
        (false, true) => "no exact redirect_uri match",
        (false, false) => return None,
    };
    Some(finding(
        "SEC-031",
        "auth/confused-deputy-posture",
        SecuritySeverity::Medium,
        CheckCategory::Auth,
        "<auth>".to_string(),
        format!("proxy is exposed to a confused-deputy attack: {gap}"),
        gap.to_string(),
    ))
}

/// SEC-032: an install source with no pinned version or provenance. An unpinned
/// source can be swapped under the consumer (supply chain, LLM03).
///
/// Heuristic: the source is flagged when it carries no version pin and no
/// provenance marker. A version pin is an `@<version>` suffix (npm), a `==`
/// constraint (pip), or a `#`-ref or `sha256:`/digest in a URL or git source. A
/// `latest` tag counts as unpinned. Ambiguous or empty sources do not fire.
fn install_source_provenance(evidence: &ProbeEvidence) -> Option<SecurityFinding> {
    let source = evidence.install_source.as_deref()?;
    let trimmed = source.trim();
    if trimmed.is_empty() {
        return None;
    }
    if install_source_is_pinned(trimmed) {
        return None;
    }
    Some(finding(
        "SEC-032",
        "local/install-source-provenance",
        SecuritySeverity::Low,
        CheckCategory::Local,
        trimmed.to_string(),
        "install source has no pinned version or provenance; it can be swapped under you"
            .to_string(),
        trimmed.to_string(),
    ))
}

/// True when an install source is version-pinned or provenance-bearing. See
/// [`install_source_provenance`] for the documented heuristic.
fn install_source_is_pinned(source: &str) -> bool {
    let lowered = source.to_lowercase();
    if lowered.contains("@latest") {
        return false;
    }
    if lowered.contains("sha256:") || lowered.contains("sha512:") {
        return true;
    }
    // pip-style exact constraint.
    if source.contains("==") {
        return true;
    }
    // A URL or git ref pinned by fragment (#tag or #commit).
    if source.contains("://") && source.contains('#') {
        return true;
    }
    // npm-style `pkg@1.2.3`: an `@` that is not the leading scope marker and is
    // followed by a digit. `@scope/pkg` alone (scope, no version) is unpinned.
    if let Some(idx) = source.rfind('@') {
        if idx > 0 {
            if let Some(next) = source[idx + 1..].chars().next() {
                if next.is_ascii_digit() {
                    return true;
                }
            }
        }
    }
    false
}

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

    fn has(findings: &[SecurityFinding], id: &str) -> bool {
        findings.iter().any(|f| f.rule_id == id)
    }

    #[test]
    fn rate_limit_present_fires_when_burst_fully_succeeded() {
        let ev = ProbeEvidence {
            burst_all_succeeded: Some(true),
            ..Default::default()
        };
        assert!(has(&probe_findings(&ev), "SEC-024"));
    }

    #[test]
    fn rate_limit_present_silent_when_evidence_absent() {
        assert!(!has(&probe_findings(&ProbeEvidence::default()), "SEC-024"));
    }

    #[test]
    fn rate_limit_present_silent_when_burst_was_throttled() {
        let ev = ProbeEvidence {
            burst_all_succeeded: Some(false),
            ..Default::default()
        };
        assert!(!has(&probe_findings(&ev), "SEC-024"));
    }

    #[test]
    fn capability_attestation_fires_on_advertised_without_proof() {
        let ev = ProbeEvidence {
            capabilities_advertised: Some(true),
            capability_attestation_present: Some(false),
            ..Default::default()
        };
        assert!(has(&probe_findings(&ev), "SEC-025"));
    }

    #[test]
    fn capability_attestation_silent_when_attested() {
        let ev = ProbeEvidence {
            capabilities_advertised: Some(true),
            capability_attestation_present: Some(true),
            ..Default::default()
        };
        assert!(!has(&probe_findings(&ev), "SEC-025"));
    }

    #[test]
    fn capability_attestation_silent_when_evidence_absent() {
        assert!(!has(&probe_findings(&ProbeEvidence::default()), "SEC-025"));
    }

    #[test]
    fn sampling_origin_auth_fires_when_origin_unauthenticated() {
        let ev = ProbeEvidence {
            sampling_used: Some(true),
            sampling_origin_authenticated: Some(false),
            ..Default::default()
        };
        let f = probe_findings(&ev)
            .into_iter()
            .find(|f| f.rule_id == "SEC-026")
            .expect("sampling finding");
        assert_eq!(f.severity, SecuritySeverity::Medium);
    }

    #[test]
    fn sampling_origin_auth_silent_when_authenticated() {
        let ev = ProbeEvidence {
            sampling_used: Some(true),
            sampling_origin_authenticated: Some(true),
            ..Default::default()
        };
        assert!(!has(&probe_findings(&ev), "SEC-026"));
    }

    #[test]
    fn sampling_origin_auth_silent_when_evidence_absent() {
        assert!(!has(&probe_findings(&ProbeEvidence::default()), "SEC-026"));
    }

    #[test]
    fn unbounded_response_fires_when_no_bound_declared() {
        let ev = ProbeEvidence {
            observed_max_response_bytes: Some(4_096),
            declared_response_bound_bytes: None,
            ..Default::default()
        };
        assert!(has(&probe_findings(&ev), "SEC-027"));
    }

    #[test]
    fn unbounded_response_fires_when_observed_exceeds_bound() {
        let ev = ProbeEvidence {
            observed_max_response_bytes: Some(8_192),
            declared_response_bound_bytes: Some(4_096),
            ..Default::default()
        };
        assert!(has(&probe_findings(&ev), "SEC-027"));
    }

    #[test]
    fn unbounded_response_silent_within_declared_bound() {
        let ev = ProbeEvidence {
            observed_max_response_bytes: Some(1_000),
            declared_response_bound_bytes: Some(4_096),
            ..Default::default()
        };
        assert!(!has(&probe_findings(&ev), "SEC-027"));
    }

    #[test]
    fn unbounded_response_silent_when_nothing_observed() {
        assert!(!has(&probe_findings(&ProbeEvidence::default()), "SEC-027"));
    }

    #[test]
    fn resource_indicators_fires_when_unsupported() {
        let ev = ProbeEvidence {
            resource_indicators_supported: Some(false),
            ..Default::default()
        };
        assert!(has(&probe_findings(&ev), "SEC-028"));
    }

    #[test]
    fn resource_indicators_silent_when_supported() {
        let ev = ProbeEvidence {
            resource_indicators_supported: Some(true),
            ..Default::default()
        };
        assert!(!has(&probe_findings(&ev), "SEC-028"));
    }

    #[test]
    fn resource_indicators_silent_when_evidence_absent() {
        assert!(!has(&probe_findings(&ProbeEvidence::default()), "SEC-028"));
    }

    #[test]
    fn scope_minimization_flags_a_wildcard_scope() {
        let ev = ProbeEvidence {
            advertised_scopes: vec!["read:profile".to_string(), "write:*".to_string()],
            ..Default::default()
        };
        let f = probe_findings(&ev)
            .into_iter()
            .find(|f| f.rule_id == "SEC-029")
            .expect("scope finding");
        assert_eq!(f.evidence, "write:*");
    }

    #[test]
    fn scope_minimization_flags_an_omnibus_scope() {
        let ev = ProbeEvidence {
            advertised_scopes: vec!["Admin".to_string()],
            ..Default::default()
        };
        assert!(has(&probe_findings(&ev), "SEC-029"));
    }

    #[test]
    fn scope_minimization_silent_on_narrow_scopes() {
        let ev = ProbeEvidence {
            advertised_scopes: vec!["read:profile".to_string(), "write:notes".to_string()],
            ..Default::default()
        };
        assert!(!has(&probe_findings(&ev), "SEC-029"));
    }

    #[test]
    fn scope_minimization_silent_when_no_scopes_advertised() {
        assert!(!has(&probe_findings(&ProbeEvidence::default()), "SEC-029"));
    }

    #[test]
    fn session_id_hygiene_flags_a_short_id() {
        let ev = ProbeEvidence {
            observed_session_ids: vec!["abc123".to_string()],
            ..Default::default()
        };
        let f = probe_findings(&ev)
            .into_iter()
            .find(|f| f.rule_id == "SEC-030")
            .expect("session finding");
        assert_eq!(f.severity, SecuritySeverity::High);
    }

    #[test]
    fn session_id_hygiene_flags_a_numeric_counter() {
        let ev = ProbeEvidence {
            observed_session_ids: vec!["1000000000000000000".to_string()],
            ..Default::default()
        };
        assert!(has(&probe_findings(&ev), "SEC-030"));
    }

    #[test]
    fn session_id_hygiene_flags_a_consecutive_sequence() {
        let ev = ProbeEvidence {
            observed_session_ids: vec![
                "40000000000000001".to_string(),
                "40000000000000002".to_string(),
                "40000000000000003".to_string(),
            ],
            ..Default::default()
        };
        assert!(has(&probe_findings(&ev), "SEC-030"));
    }

    #[test]
    fn session_id_hygiene_flags_session_used_for_auth() {
        let ev = ProbeEvidence {
            session_used_for_auth: Some(true),
            ..Default::default()
        };
        assert!(has(&probe_findings(&ev), "SEC-030"));
    }

    #[test]
    fn session_id_hygiene_silent_on_a_high_entropy_random_id() {
        let ev = ProbeEvidence {
            observed_session_ids: vec!["a1f4c9e8b27d6035fe91ac42".to_string()],
            ..Default::default()
        };
        assert!(!has(&probe_findings(&ev), "SEC-030"));
    }

    #[test]
    fn session_id_hygiene_silent_when_evidence_absent() {
        assert!(!has(&probe_findings(&ProbeEvidence::default()), "SEC-030"));
    }

    #[test]
    fn confused_deputy_posture_fires_when_consent_missing() {
        let ev = ProbeEvidence {
            proxy_per_client_consent: Some(false),
            ..Default::default()
        };
        assert!(has(&probe_findings(&ev), "SEC-031"));
    }

    #[test]
    fn confused_deputy_posture_fires_when_redirect_loose() {
        let ev = ProbeEvidence {
            proxy_exact_redirect_uri_match: Some(false),
            ..Default::default()
        };
        assert!(has(&probe_findings(&ev), "SEC-031"));
    }

    #[test]
    fn confused_deputy_posture_silent_when_both_present() {
        let ev = ProbeEvidence {
            proxy_per_client_consent: Some(true),
            proxy_exact_redirect_uri_match: Some(true),
            ..Default::default()
        };
        assert!(!has(&probe_findings(&ev), "SEC-031"));
    }

    #[test]
    fn confused_deputy_posture_silent_when_evidence_absent() {
        assert!(!has(&probe_findings(&ProbeEvidence::default()), "SEC-031"));
    }

    #[test]
    fn install_source_provenance_flags_an_unpinned_npm_package() {
        let ev = ProbeEvidence {
            install_source: Some("@modelcontextprotocol/server-filesystem".to_string()),
            ..Default::default()
        };
        assert!(has(&probe_findings(&ev), "SEC-032"));
    }

    #[test]
    fn install_source_provenance_flags_a_latest_tag() {
        let ev = ProbeEvidence {
            install_source: Some("some-mcp-server@latest".to_string()),
            ..Default::default()
        };
        assert!(has(&probe_findings(&ev), "SEC-032"));
    }

    #[test]
    fn install_source_provenance_silent_on_a_pinned_version() {
        let ev = ProbeEvidence {
            install_source: Some("server-filesystem@1.2.3".to_string()),
            ..Default::default()
        };
        assert!(!has(&probe_findings(&ev), "SEC-032"));
    }

    #[test]
    fn install_source_provenance_silent_on_a_pip_constraint() {
        let ev = ProbeEvidence {
            install_source: Some("mcp-server==0.4.1".to_string()),
            ..Default::default()
        };
        assert!(!has(&probe_findings(&ev), "SEC-032"));
    }

    #[test]
    fn install_source_provenance_silent_on_a_digest_pin() {
        let ev = ProbeEvidence {
            install_source: Some("ghcr.io/acme/mcp@sha256:deadbeef".to_string()),
            ..Default::default()
        };
        assert!(!has(&probe_findings(&ev), "SEC-032"));
    }

    #[test]
    fn install_source_provenance_silent_when_evidence_absent() {
        assert!(!has(&probe_findings(&ProbeEvidence::default()), "SEC-032"));
    }

    #[test]
    fn install_source_provenance_silent_on_empty_source() {
        let ev = ProbeEvidence {
            install_source: Some("   ".to_string()),
            ..Default::default()
        };
        assert!(!has(&probe_findings(&ev), "SEC-032"));
    }
}
