Gunnar

mikronova/modgunn

src/core.rs

//! Shared verdict types — the contract both engines emit and consumers read.
//!
//! A [`Verdict`] is content-addressed-cacheable: keyed by
//! `(blob_sha256, policy_version, advisory_db_rev)` so a clean result is computed
//! once and reused (holger caches it on the gate / promotion path).

use serde::{Deserialize, Serialize};

/// What a gate should DO with an artifact, given the findings.
///
/// `Ord` follows declaration order — `Pass < Warn < Block` — so the *strictest*
/// decision across a set of findings is their `max()` (Engine A relies on this to
/// fold many findings into one verdict).
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
pub enum Decision {
    /// Serve / promote freely. The neutral default (the identity for `max()`).
    #[default]
    Pass,
    /// Serve, but surface the findings (advisory).
    Warn,
    /// Refuse to serve / promote.
    Block,
}

impl Decision {
    /// The canonical label — the single source of the `Pass`/`Warn`/`Block`
    /// strings. Consumers (holger's gate, holger-ui, nornir-viz Security pane)
    /// render THIS instead of hand-rolling their own match, so the label set
    /// lives in exactly one place.
    pub fn as_str(self) -> &'static str {
        match self {
            Decision::Pass => "Pass",
            Decision::Warn => "Warn",
            Decision::Block => "Block",
        }
    }
}

impl std::fmt::Display for Decision {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        f.write_str(self.as_str())
    }
}

/// Finding severity.
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
pub enum Severity {
    Info,
    Low,
    Medium,
    High,
    Critical,
}

impl Severity {
    /// The canonical label — the single source of the severity strings, so
    /// consumers render THIS instead of relying on `{:?}` Debug formatting.
    pub fn as_str(self) -> &'static str {
        match self {
            Severity::Info => "Info",
            Severity::Low => "Low",
            Severity::Medium => "Medium",
            Severity::High => "High",
            Severity::Critical => "Critical",
        }
    }

    /// Inverse of [`as_str`](Severity::as_str): parse a canonical label back to a
    /// `Severity` (case-insensitive). Used when reloading a severity persisted as a
    /// string from the warehouse. Unknown labels ⇒ `None` (never a silent default —
    /// a caller decides whether to skip the row or fall back explicitly).
    pub fn from_label(s: &str) -> Option<Self> {
        match s.trim().to_ascii_lowercase().as_str() {
            "info" => Some(Severity::Info),
            "low" => Some(Severity::Low),
            "medium" => Some(Severity::Medium),
            "high" => Some(Severity::High),
            "critical" => Some(Severity::Critical),
            _ => None,
        }
    }
}

impl std::fmt::Display for Severity {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        f.write_str(self.as_str())
    }
}

/// The artifact a verdict is about (content-addressed).
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ArtifactRef {
    /// Ecosystem the coords belong to (e.g. `"cargo"`, `"maven"`, `"npm"`).
    pub ecosystem: String,
    /// Human coords (`name`, `version`) for display.
    pub name: String,
    pub version: String,
    /// The content hash the verdict is keyed by.
    pub blob_sha256: String,
}

/// One thing Móðguðr found.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Finding {
    pub severity: Severity,
    /// Stable id (e.g. a CVE / advisory id, or a tripwire id).
    pub id: String,
    pub summary: String,
}

/// The cacheable result: the decision + its findings + the keys it was computed
/// against (so a consumer can invalidate when policy / advisory db moves).
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Verdict {
    pub artifact: ArtifactRef,
    pub decision: Decision,
    pub findings: Vec<Finding>,
    /// Cache key components.
    pub policy_version: String,
    pub advisory_db_rev: String,
}

impl Verdict {
    /// The content-addressed cache key: `(blob_sha256, policy_version, advisory_db_rev)`.
    pub fn cache_key(&self) -> (String, String, String) {
        (
            self.artifact.blob_sha256.clone(),
            self.policy_version.clone(),
            self.advisory_db_rev.clone(),
        )
    }

    /// The strongest severity among the findings (`None` ⇒ clean).
    pub fn max_severity(&self) -> Option<Severity> {
        self.findings.iter().map(|f| f.severity).max()
    }
}

/// Shannon entropy (bits/symbol) of a string's bytes — the obfuscation metric the
/// SCIP A17 tripwire keys on (minified / base64-encoded / packed identifiers sit
/// near-maximal entropy, English identifiers ~3–4). Lives HERE (always compiled, no
/// feature gate) so BOTH the `scip` Engine-B obfuscation tripwire and the `scan`
/// Engine-A A23 behaviour-feature extractor consume the SAME metric — one
/// definition, never a per-feature twin (L5). Byte-based (not char-based) so a
/// multi-byte-UTF-8 blob still reads as high-entropy.
pub fn shannon_entropy(s: &str) -> f64 {
    if s.is_empty() {
        return 0.0;
    }
    let mut counts = [0usize; 256];
    for &b in s.as_bytes() {
        counts[b as usize] += 1;
    }
    let len = s.len() as f64;
    counts
        .iter()
        .filter(|&&c| c > 0)
        .map(|&c| {
            let p = c as f64 / len;
            -p * p.log2()
        })
        .sum()
}

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

    #[test]
    fn shannon_entropy_ranks_random_above_repetitive() {
        assert!(shannon_entropy("aaaaaaaa") < 0.01);
        assert!(shannon_entropy("x1Yb3Kf9Qm2Wp7Zr4Ns8Lc6Vd0") > 4.0);
        assert!(shannon_entropy("initializeAllServicesAndConnections") < 4.0);
        assert_eq!(shannon_entropy(""), 0.0);
    }

    #[test]
    fn verdict_cache_key_and_severity() {
        let v = Verdict {
            artifact: ArtifactRef {
                ecosystem: "cargo".into(),
                name: "serde".into(),
                version: "1.0.0".into(),
                blob_sha256: "abc123".into(),
            },
            decision: Decision::Warn,
            findings: vec![
                Finding {
                    severity: Severity::Low,
                    id: "RUSTSEC-0".into(),
                    summary: "x".into(),
                },
                Finding {
                    severity: Severity::High,
                    id: "CVE-1".into(),
                    summary: "y".into(),
                },
            ],
            policy_version: "p1".into(),
            advisory_db_rev: "db42".into(),
        };
        assert_eq!(v.cache_key(), ("abc123".into(), "p1".into(), "db42".into()));
        assert_eq!(v.max_severity(), Some(Severity::High));
    }

    #[test]
    fn decision_labels_are_canonical_and_display_agrees() {
        // The single-source labels consumers (holger gate/ui, nornir-viz) render.
        assert_eq!(Decision::Pass.as_str(), "Pass");
        assert_eq!(Decision::Warn.as_str(), "Warn");
        assert_eq!(Decision::Block.as_str(), "Block");
        // Display delegates to as_str — one source, no drift.
        for d in [Decision::Pass, Decision::Warn, Decision::Block] {
            assert_eq!(d.to_string(), d.as_str());
        }
    }

    #[test]
    fn severity_labels_are_canonical_and_display_agrees() {
        assert_eq!(Severity::Info.as_str(), "Info");
        assert_eq!(Severity::Low.as_str(), "Low");
        assert_eq!(Severity::Medium.as_str(), "Medium");
        assert_eq!(Severity::High.as_str(), "High");
        assert_eq!(Severity::Critical.as_str(), "Critical");
        for s in [
            Severity::Info,
            Severity::Low,
            Severity::Medium,
            Severity::High,
            Severity::Critical,
        ] {
            assert_eq!(s.to_string(), s.as_str());
        }
    }
}