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());
}
}
}