//! Deterministic security check engine.
//!
//! Why this module exists: a security verdict is a trust asset. ADR 0040 is
//! blunt about it, if a failing security grade can be talked out of by a
//! server that narrates safety, or gamed by an attack that fools a judge, the
//! grade is worthless. So no model decides a security pass or fail. Every
//! check here is a deterministic predicate (regex or structural) over the
//! parsed tool, prompt, and resource definitions in a `tools/list`-style
//! snapshot.
//!
//! This module owns the check model ([`SecurityCheck`]), the engine that runs
//! a set of checks over a snapshot ([`SecurityEngine`]), the finding shape
//! ([`SecurityFinding`]), and the SARIF and pretty/JSON reporters. The
//! deterministic verdict is the union of several lanes: the tool-surface
//! static-analysis family in [`surface`], the capability-tier toxic-flow family
//! in [`toxic_flow`], the cross-server [`namespace`] family, and the
//! baseline-vs-current [`integrity`] family. The relational lanes live outside
//! the per-definition engine because their checks compare two tools, two
//! servers, or two points in time at once. [`run`] orchestrates them into one
//! report so the `--fail-on` gate sees every deterministic finding. The
//! [`posture`] map is a coverage signal that rides alongside the verdict
//! without gating.
//!
//! Two further relational lanes cover the protocol-level trust gaps from
//! "Breaking the Protocol" (PROTOAMP, AttestMCP): [`trust_propagation`] flags a
//! cross-server flow where one server's tool output reaches another server's
//! tool input unmodified across a boundary the suite never declared safe, and
//! [`capability_negotiation`] flags a request that silently widens its
//! capability claims beyond the negotiated baseline under the 2026-07-28
//! stateless model. Both are objective black-box predicates, so they gate like
//! every other deterministic lane.
//!
//! The [`advisory`] lane runs an LLM judge for the semantic threats a regex
//! cannot see. It is advisory only (ADR 0041): its findings live in a separate
//! report with no gating method, so no model output can move the verdict.
//!
//! The fixed-corpus exploitability lane [`redteam`] (Layer C1) is wired into
//! the live `mcptest security redteam` command: it drives the corpus
//! through the agent loop against a running server and scores the captured
//! traces. Its output is a per-model exploitability signal, never the server's
//! security grade (ADR 0041).
//!
//! The adaptive attacker [`adaptive`] (Layer C2) is wired into the same command
//! behind `--adaptive`: it mutates attack seeds with deterministic
//! converters, drives each variant through the agent loop, and folds the
//! results into the same per-model exploitability report. Like C1, its output
//! is advisory only (ADR 0041).
//!
//! The external-scanner [`supplement`] lane (Layer D) is wired into the
//! `mcptest security import` command: it normalizes a scanner's SARIF or JSON
//! output into the finding vocabulary, dedups it against the bundled catalog,
//! and folds it into the same report. The active [`probes`] lane (SEC-017..032)
//! runs when a snapshot carries a `probe_evidence` block; it stays doc-hidden
//! because the analyzer is reached through the `security` command, not directly.
//!
//! The check IDs follow the ADR 0015 rule-ID standard under the `SEC` prefix.

/// Layer C2 adaptive attacker, wired into `mcptest security redteam --adaptive`
///. Mutates attack seeds with deterministic converters and folds the
/// results into the same per-model exploitability report C1 produces.
pub mod adaptive;
pub mod advisory;
/// Binary-identity fingerprint for the integrity lane (SEC-065). Records the
/// resolved launch identity of a stdio or URL server into the baseline so a
/// binary-swap rug pull (CVE-2025-54136 class) is caught when the fingerprint
/// drifts, even though the catalog itself is unchanged.
pub mod binary_pin;
pub mod capability_negotiation;
/// Inline description-poisoning lint (WOR-1273), the subset of the surface
/// rules the agent verbs run over introspection results as flag-only
/// warnings, so a poisoned tool description is flagged mid-loop rather than
/// only when someone remembers to run `mcptest security`.
pub mod description_lint;
pub mod findings_report;
pub mod integrity;
pub mod namespace;
/// Output-poisoning lane (SEC-063/SEC-064): scans recorded tool-call results and
/// error bodies for prompt injection and hidden unicode, wired into
/// `mcptest security cassette`.
pub mod output;
pub mod owasp;
pub mod owasp_agentic;
pub mod posture;
/// Active-probe analyzers (SEC-017..032). They run over the `probe_evidence`
/// block of a snapshot, wired into the security run by
/// [`run::probe_evidence_from_snapshot`]. Doc-hidden: callers reach them through
/// the `security` command, not this module directly.
#[doc(hidden)]
pub mod probes;
pub mod redteam;
pub mod report;
pub mod run;
pub mod sarif;
pub mod skill_scan;
pub mod source_proof;
/// External-scanner supplement (Layer D), wired into `mcptest security import`.
/// Normalizes a scanner's SARIF or JSON output into the finding vocabulary and
/// dedups it against the bundled catalog.
pub mod supplement;
pub mod surface;
pub mod toxic_flow;
pub mod trust_propagation;

use serde::{Deserialize, Serialize};
use serde_json::Value;

pub use adaptive::{
    default_converter_ladder, render_adaptive, run_seed_campaign, AdaptiveCampaign, AttackAttempt,
    AttackSeed, Converter, SeedCampaign,
};
pub use advisory::{advisory_findings, AdvisoryFinding, AdvisoryReport, AdvisoryThreat};
pub use binary_pin::{FingerprintChange, ServerFingerprint};
pub use capability_negotiation::{
    capability_negotiation_findings, NegotiationTrace, RequestCapabilityClaim, STATELESS_TARGET,
};
pub use description_lint::{lint_tool_descriptions, DescriptionWarning};
pub use integrity::{binary_pin_findings, integrity_findings};
pub use namespace::namespace_findings;
pub use output::{cassette_call_count, cassette_output_findings, scan_output_text};
pub use posture::{assess_posture, AuthPostureKind, PostureInputs, PostureReport, PostureSignal};
/// Active-probe analyzer entry points, run over a snapshot's `probe_evidence`
/// block by the security run. Doc-hidden: reached through the `security` command.
#[doc(hidden)]
pub use probes::{probe_findings, ProbeEvidence};
pub use redteam::{
    assess_scenario, classify_scenario, evaluate_safety_checks, render_pretty as render_redteam,
    AttackClass, ModelExploitability, SafetyCheck, SafetyMatcher, ScenarioOutcome,
};
pub use report::{
    render_json, render_pretty, render_pretty_styled, render_run_json, render_run_pretty,
    render_run_pretty_styled,
};
pub use run::{
    probe_evidence_from_snapshot, run_cassette, run_deterministic, run_import, run_with_advisory,
    servers_from_snapshot, SecurityRun, SecurityRunOptions,
};
pub use sarif::render_sarif;
pub use supplement::{
    dedup_against_catalog, normalize_generic_json, normalize_sarif, normalize_snyk, FindingSource,
    SupplementFinding,
};
pub use surface::surface_checks;
pub use toxic_flow::{classify_tool, toxic_flow_findings, CapabilityTier};
pub use trust_propagation::{
    cross_server_trust_findings, extract_cross_server_flows, CrossServerFlow,
    TrustBoundaryAssertion,
};

/// Severity of a security finding.
///
/// This is the security axis (critical down to info) and is deliberately
/// separate from the compliance `RuleSeverity` (Must / Should / May), which
/// is a normative-requirement axis. A security finding is graded by how badly
/// it can hurt, not by whether the spec says MUST. ADR 0040 fixes these five
/// levels.
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum SecuritySeverity {
    /// Lowest. An informational posture signal, not a defect.
    Info,
    /// Low impact or low confidence; worth noting, rarely a blocker.
    Low,
    /// Medium impact; should be reviewed before trusting the server.
    Medium,
    /// High impact; a real attack surface a black-box client can see.
    High,
    /// Highest. Direct compromise vector; treat as a release blocker.
    Critical,
}

impl SecuritySeverity {
    /// Lowercase wire string, matching the serde representation.
    pub fn as_str(self) -> &'static str {
        match self {
            SecuritySeverity::Info => "info",
            SecuritySeverity::Low => "low",
            SecuritySeverity::Medium => "medium",
            SecuritySeverity::High => "high",
            SecuritySeverity::Critical => "critical",
        }
    }

    /// Parse a lowercase severity label, the inverse of [`as_str`].
    ///
    /// Returns `None` for anything outside the five levels, so a caller can
    /// fall back to its own mapping (the supplement reader uses this to honor a
    /// scanner's explicit severity before falling back to the SARIF level).
    ///
    /// [`as_str`]: SecuritySeverity::as_str
    pub fn parse(label: &str) -> Option<SecuritySeverity> {
        match label {
            "info" => Some(SecuritySeverity::Info),
            "low" => Some(SecuritySeverity::Low),
            "medium" => Some(SecuritySeverity::Medium),
            "high" => Some(SecuritySeverity::High),
            "critical" => Some(SecuritySeverity::Critical),
            _ => None,
        }
    }
}

/// Which lane of the catalog a check belongs to.
///
/// Kept as a small enum rather than a free string so the engine cannot be
/// fed a typo and so reporters can group findings by lane. ADR 0040 calls
/// these "surface" families; the static surface, cross-server namespace, and
/// baseline-vs-current integrity lanes ship here.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum CheckCategory {
    /// Static analysis of tool, prompt, and resource definitions.
    Surface,
    /// Relational analysis across a multi-server snapshot (shadowing,
    /// squatting, ambiguous resolution).
    Namespace,
    /// Relational analysis across time, comparing a current catalog against a
    /// previously approved baseline (rug pulls, schema drift, version-stamp
    /// posture).
    Integrity,
    /// Active transport-level probes (TLS, Origin, SSRF, error leaks).
    Transport,
    /// Active authentication and authorization posture probes.
    Auth,
    /// Static analysis of a local stdio server's launch configuration.
    Local,
    /// Relational capability-tier analysis: classifying each tool's latent
    /// capability and flagging a dangerous source-to-sink pairing across the
    /// catalog (the toxic-flow family).
    Capability,
}

impl CheckCategory {
    /// Lowercase label used in catalog rows and reporter output.
    pub fn as_str(self) -> &'static str {
        match self {
            CheckCategory::Surface => "surface",
            CheckCategory::Namespace => "namespace",
            CheckCategory::Integrity => "integrity",
            CheckCategory::Transport => "transport",
            CheckCategory::Auth => "auth",
            CheckCategory::Local => "local",
            CheckCategory::Capability => "capability",
        }
    }
}

/// A definition surface the engine inspects within a snapshot.
///
/// The engine flattens tools, prompts, and resources into one list of
/// [`Definition`] values so a check written for "any named definition with a
/// description" runs against all three without re-walking the snapshot. The
/// `kind` lets a check scope itself when it only makes sense for one kind
/// (for example, the destructive-tool annotation check only fires on tools).
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Definition {
    /// Whether this came from `tools`, `prompts`, or `resources`.
    pub kind: DefinitionKind,
    /// The raw JSON object as it appeared in the snapshot. Checks read
    /// `name`, `description`, `inputSchema`, `annotations`, and so on
    /// directly off this value so they see exactly what an agent would.
    pub raw: Value,
}

impl Definition {
    /// Best-effort name for reporting. Falls back to a placeholder rather
    /// than failing, because a missing name is itself a finding some checks
    /// will want to surface and the reporter still needs a label.
    pub fn name(&self) -> String {
        self.raw
            .get("name")
            .and_then(|v| v.as_str())
            .unwrap_or("<unnamed>")
            .to_string()
    }

    /// Description text, or the empty string when absent.
    pub fn description(&self) -> &str {
        self.raw
            .get("description")
            .and_then(|v| v.as_str())
            .unwrap_or("")
    }
}

/// Which top-level array a [`Definition`] was drawn from.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum DefinitionKind {
    /// An entry from the `tools` array.
    Tool,
    /// An entry from the `prompts` array.
    Prompt,
    /// An entry from the `resources` array.
    Resource,
}

/// One security finding from one check against one definition.
///
/// Serializable so the JSON reporter and any downstream collector get a
/// stable shape. The `evidence` string is the concrete substring or value
/// that fired the matcher, so a human reading the report can see *why*
/// without re-running the engine.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct SecurityFinding {
    /// Stable rule ID, for example `SEC-001`.
    pub rule_id: String,
    /// Short check name, for example `description-injection`.
    pub name: String,
    /// Severity on the security axis.
    pub severity: SecuritySeverity,
    /// Catalog lane the firing check belongs to.
    pub category: CheckCategory,
    /// Name of the definition the finding is about.
    pub definition: String,
    /// Which kind of definition this was.
    pub kind: DefinitionKind,
    /// Human-readable explanation of what fired and why it matters.
    pub message: String,
    /// The concrete evidence (matched substring or value) that fired the
    /// matcher. Empty when the finding is structural rather than textual.
    pub evidence: String,
}

impl crate::waivers::Waivable for SecurityFinding {
    fn rule_id(&self) -> &str {
        &self.rule_id
    }
    fn target(&self) -> &str {
        &self.definition
    }
}

/// A declarative security check.
///
/// Each implementation carries its catalog metadata (id, name, severity,
/// category, references) and a deterministic `check` over one definition.
/// Keeping the metadata on the trait rather than in a side table means the
/// catalog row and the matcher cannot drift apart: they live in one struct.
pub trait SecurityCheck: Send + Sync {
    /// Stable `SEC-NNN` rule ID.
    fn id(&self) -> &'static str;
    /// Short kebab-case check name.
    fn name(&self) -> &'static str;
    /// Severity this check assigns when it fires.
    fn severity(&self) -> SecuritySeverity;
    /// Catalog lane.
    fn category(&self) -> CheckCategory;
    /// External references (OWASP item, MCP spec requirement, CWE). Used by
    /// the SARIF rule definition so consumers can pivot to the source.
    fn references(&self) -> &'static [&'static str];
    /// Run the deterministic matcher against one definition. Returns every
    /// finding it produces; most checks return zero or one.
    fn check(&self, def: &Definition) -> Vec<SecurityFinding>;
}

/// Aggregate result of running an engine over a snapshot.
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct SecurityReport {
    /// Findings in definition-then-check order.
    pub findings: Vec<SecurityFinding>,
    /// Number of definitions examined (tools + prompts + resources).
    pub definitions_checked: usize,
    /// Findings folded in from an external scanner (Layer D), carried alongside
    /// the bundled findings with their provenance and dedup metadata. Empty for
    /// an ordinary scan; populated by `security import`. A `#[serde(default)]`
    /// so a report serialized before this field existed still deserializes.
    #[serde(default)]
    pub supplement: Vec<SupplementFinding>,
}

impl SecurityReport {
    /// True when at least one finding is at or above `threshold`.
    ///
    /// This is the gate the CLI uses for `--fail-on`: a run is a failure when
    /// any bundled finding, or any supplement finding that counts toward the
    /// grade (not advisory, not a duplicate of a bundled rule), meets or exceeds
    /// the requested severity floor. Advisory supplement findings never gate
    /// (ADR 0041).
    pub fn fails_at(&self, threshold: SecuritySeverity) -> bool {
        self.findings.iter().any(|f| f.severity >= threshold)
            || self
                .supplement
                .iter()
                .any(|f| f.counts_toward_grade() && f.severity >= threshold)
    }

    /// Count findings at exactly `severity`.
    pub fn count_at(&self, severity: SecuritySeverity) -> usize {
        self.findings
            .iter()
            .filter(|f| f.severity == severity)
            .count()
    }

    /// Highest severity present, or `None` when the report is clean.
    pub fn max_severity(&self) -> Option<SecuritySeverity> {
        self.findings.iter().map(|f| f.severity).max()
    }
}

/// Runs a set of [`SecurityCheck`]s over a parsed snapshot.
///
/// The engine is a thin orchestrator: it flattens the snapshot into
/// [`Definition`]s, runs every check against every definition, and collects
/// the findings. All the security logic lives in the checks; the engine adds
/// no verdict of its own, which keeps the determinism guarantee local to each
/// matcher.
pub struct SecurityEngine {
    checks: Vec<Box<dyn SecurityCheck>>,
}

impl SecurityEngine {
    /// Build an engine from an explicit check set.
    pub fn new(checks: Vec<Box<dyn SecurityCheck>>) -> Self {
        Self { checks }
    }

    /// Build an engine with every bundled check (currently the surface lane).
    pub fn with_bundled_checks() -> Self {
        Self::new(surface_checks())
    }

    /// Run every check against every definition in `snapshot`.
    ///
    /// `snapshot` is a parsed `tools/list`-style JSON object that may carry
    /// `tools`, `prompts`, and `resources` arrays. Missing arrays are treated
    /// as empty, so a tools-only snapshot is valid input.
    pub fn run(&self, snapshot: &Value) -> SecurityReport {
        let defs = flatten_definitions(snapshot);
        let mut findings = Vec::new();
        for def in &defs {
            for check in &self.checks {
                findings.extend(check.check(def));
            }
        }
        SecurityReport {
            findings,
            definitions_checked: defs.len(),
            supplement: Vec::new(),
        }
    }

    /// Borrow the configured checks, for SARIF rule-definition emission.
    pub fn checks(&self) -> &[Box<dyn SecurityCheck>] {
        &self.checks
    }
}

/// Flatten the three definition arrays of a snapshot into one list.
///
/// Each of `tools`, `prompts`, and `resources` is optional; an absent or
/// non-array value contributes nothing rather than erroring, so partial
/// snapshots (a tools-only export, say) run cleanly.
pub fn flatten_definitions(snapshot: &Value) -> Vec<Definition> {
    let mut out = Vec::new();
    collect(snapshot, "tools", DefinitionKind::Tool, &mut out);
    collect(snapshot, "prompts", DefinitionKind::Prompt, &mut out);
    collect(snapshot, "resources", DefinitionKind::Resource, &mut out);
    out
}

fn collect(snapshot: &Value, key: &str, kind: DefinitionKind, out: &mut Vec<Definition>) {
    if let Some(arr) = snapshot.get(key).and_then(|v| v.as_array()) {
        for raw in arr {
            out.push(Definition {
                kind,
                raw: raw.clone(),
            });
        }
    }
}

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

    #[test]
    fn severity_orders_info_below_critical() {
        assert!(SecuritySeverity::Critical > SecuritySeverity::High);
        assert!(SecuritySeverity::High > SecuritySeverity::Medium);
        assert!(SecuritySeverity::Medium > SecuritySeverity::Low);
        assert!(SecuritySeverity::Low > SecuritySeverity::Info);
    }

    #[test]
    fn flatten_collects_tools_prompts_and_resources() {
        let snapshot = json!({
            "tools": [{ "name": "a" }],
            "prompts": [{ "name": "p" }],
            "resources": [{ "name": "r" }],
        });
        let defs = flatten_definitions(&snapshot);
        assert_eq!(defs.len(), 3);
        assert_eq!(defs[0].kind, DefinitionKind::Tool);
        assert_eq!(defs[1].kind, DefinitionKind::Prompt);
        assert_eq!(defs[2].kind, DefinitionKind::Resource);
    }

    #[test]
    fn flatten_tolerates_missing_arrays() {
        let snapshot = json!({ "tools": [{ "name": "only" }] });
        let defs = flatten_definitions(&snapshot);
        assert_eq!(defs.len(), 1);
    }

    #[test]
    fn engine_runs_bundled_checks_over_snapshot() {
        let snapshot = json!({
            "tools": [{
                "name": "search",
                "description": "Search the index for a query string.",
                "inputSchema": { "type": "object", "properties": {} }
            }]
        });
        let report = SecurityEngine::with_bundled_checks().run(&snapshot);
        assert_eq!(report.definitions_checked, 1);
        assert!(report.findings.is_empty());
    }

    #[test]
    fn report_fails_at_threshold_when_finding_meets_floor() {
        let report = SecurityReport {
            findings: vec![SecurityFinding {
                rule_id: "SEC-001".into(),
                name: "description-injection".into(),
                severity: SecuritySeverity::High,
                category: CheckCategory::Surface,
                definition: "x".into(),
                kind: DefinitionKind::Tool,
                message: "m".into(),
                evidence: "e".into(),
            }],
            definitions_checked: 1,
            supplement: Vec::new(),
        };
        assert!(report.fails_at(SecuritySeverity::High));
        assert!(report.fails_at(SecuritySeverity::Medium));
        assert!(!report.fails_at(SecuritySeverity::Critical));
    }

    #[test]
    fn fails_at_counts_novel_supplement_but_not_advisory_or_duplicates() {
        let supplement = |advisory: bool, duplicate: Option<&str>| SupplementFinding {
            rule_id: "ext.poison".into(),
            name: "ext.poison".into(),
            severity: SecuritySeverity::Critical,
            message: "m".into(),
            evidence: "e".into(),
            source: FindingSource::External {
                tool: "scanner".into(),
            },
            advisory,
            duplicate_of: duplicate.map(str::to_string),
        };
        // A novel, non-advisory external critical gates.
        let novel = SecurityReport {
            findings: vec![],
            definitions_checked: 0,
            supplement: vec![supplement(false, None)],
        };
        assert!(novel.fails_at(SecuritySeverity::High));
        // An advisory external critical never gates.
        let advisory = SecurityReport {
            supplement: vec![supplement(true, None)],
            ..Default::default()
        };
        assert!(!advisory.fails_at(SecuritySeverity::Critical));
        // A duplicate of a bundled rule is already counted by the bundled engine.
        let duplicate = SecurityReport {
            supplement: vec![supplement(false, Some("SEC-001"))],
            ..Default::default()
        };
        assert!(!duplicate.fails_at(SecuritySeverity::Critical));
    }

    #[test]
    fn report_max_severity_is_none_when_clean() {
        let report = SecurityReport {
            findings: vec![],
            definitions_checked: 0,
            supplement: Vec::new(),
        };
        assert_eq!(report.max_severity(), None);
    }

    #[test]
    fn definition_name_falls_back_when_absent() {
        let def = Definition {
            kind: DefinitionKind::Tool,
            raw: json!({ "description": "no name here" }),
        };
        assert_eq!(def.name(), "<unnamed>");
    }
}
