facett-security/src/lib.rs
//! **facett-security** — a software-supply-chain **security report** view: the
//! **verdict** (Pass / Warn / Block), the **findings** list (id · severity ·
//! package · advisory · fix) with **severity / EPSS / KEV** columns, and a
//! secondary **CVE / advisory** reference table. It is the UI *twin* of
//! [modgunn](https://git.gunnar.rs/mikronova/modgunn)'s scan output — the gate that
//! decides whether an artifact may be served / promoted.
//!
//! ## Generic on purpose (no modgunn dependency)
//! facett MUST NOT couple to the modgunn crate. So this crate carries its **own**
//! plain, serializable structs — [`Verdict`], [`Severity`], [`Finding`],
//! [`Artifact`], [`SecurityReport`] — whose shape **mirrors `modgunn::core`** so a
//! consumer (nornir-viz / holger-ui) maps the two **1:1** with no glue:
//!
//! | facett-security | modgunn::core |
//! |------------------------|-------------------------------------------------|
//! | [`Verdict`] (Pass/Warn/Block) | `Decision` (Pass/Warn/Block) — same `Ord` |
//! | [`Severity`] (Info…Critical) | `Severity` (Info…Critical) — same `Ord` |
//! | [`Finding`] `{id,severity,…}` | `Finding` `{id,severity,summary}` (superset) |
//! | [`Artifact`] | `ArtifactRef` |
//! | [`SecurityReport`] | `Verdict` (the whole cacheable result) |
//!
//! [`Finding`] is a **superset**: modgunn's three fields map straight in, and the
//! richer scan columns the UI wants (`package`, `advisory`, `fix`, `epss`, `kev`)
//! are optional, so a 1:1 mapper fills what it has and leaves the rest defaulted.
//!
//! ## FC compliance (the component contract)
//! - **FC-1 / FC-3** — all observable state lives in one serializable
//! [`SecurityState`] (`Serialize`/`Deserialize`/`Clone`/`PartialEq`), read back
//! via [`SecurityView::state`].
//! - **FC-2 / FC-9** — every input is a [`Msg`]; mutation only ever happens in
//! [`SecurityView::update`]. The [`render`](SecurityView::render) pass is a pure
//! function of `&self` that paints and *returns* the [`Msg`]s it produced; the
//! immediate-mode [`Facet::ui`] just applies them. The view holds no hidden
//! state.
//! - **FC-4** — the painted verdict pill emits an AccessKit node; every finding
//! row / CVE row is a selectable, labelled, value-carrying node.
//! - **FC-5** — rows are keyed off the finding **id** (a stable domain key), never
//! a loop index.
//! - **FC-6** — pure egui + painter, renders headlessly (the deck robot path).
//! - **FC-7** — no ambient clock / RNG / animation: the scan time is an *injected*
//! host-stamped string ([`SecurityReport::scanned_at`]).
//! - **FC-8** — the view does no I/O; [`SecurityView::is_idle`] is always true and
//! [`SecurityView::update`] returns an (empty) `Vec<Effect>`.
use facett_core::{Facet, FacetCaps, Semantics, theme};
use serde::{Deserialize, Serialize};
/// The warm red used across facett for failure/blocking (the base palette has no
/// semantic red) — shared by the Block verdict + the Critical severity ramp.
pub const RED: egui::Color32 = facett_core::look::RED;
/// The amber used for the Warn verdict + the High severity band.
pub const AMBER: egui::Color32 = egui::Color32::from_rgb(230, 170, 60);
// ───────────────────────────────────────────────────────────────────────────
// Verdict — mirrors modgunn::core::Decision
// ───────────────────────────────────────────────────────────────────────────
/// What a gate should DO with the artifact, given the findings. Declaration
/// order is the strictness order — `Pass < Warn < Block` — so folding many
/// findings into one verdict is their [`Ord::max`] (the same contract
/// `modgunn::core::Decision` relies on).
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum Verdict {
/// 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 Verdict {
/// The short, greppable chip tag.
pub fn tag(self) -> &'static str {
match self {
Verdict::Pass => "PASS",
Verdict::Warn => "WARN",
Verdict::Block => "BLOCK",
}
}
/// Parse a free-text decision (case-insensitive) onto the closed vocabulary;
/// `None` for an unrecognised word.
pub fn parse(s: &str) -> Option<Verdict> {
match s.trim().to_ascii_lowercase().as_str() {
"pass" | "passed" | "ok" | "allow" => Some(Verdict::Pass),
"warn" | "warning" | "advisory" => Some(Verdict::Warn),
"block" | "blocked" | "deny" | "fail" | "failed" => Some(Verdict::Block),
_ => None,
}
}
/// The verdict chip colour on the active theme.
pub fn color(self, th: &facett_core::theme::Theme) -> egui::Color32 {
match self {
Verdict::Pass => th.point,
Verdict::Warn => AMBER,
Verdict::Block => RED,
}
}
}
// ───────────────────────────────────────────────────────────────────────────
// Severity — mirrors modgunn::core::Severity
// ───────────────────────────────────────────────────────────────────────────
/// Finding severity (declaration order = ascending, so the worst finding is the
/// `max()`). Mirrors `modgunn::core::Severity`.
#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum Severity {
Info,
Low,
Medium,
High,
Critical,
}
impl Severity {
/// The short, greppable tag.
pub fn tag(self) -> &'static str {
match self {
Severity::Info => "INFO",
Severity::Low => "LOW",
Severity::Medium => "MED",
Severity::High => "HIGH",
Severity::Critical => "CRIT",
}
}
/// Parse a free-text severity (case-insensitive); `None` if unknown.
pub fn parse(s: &str) -> Option<Severity> {
match s.trim().to_ascii_lowercase().as_str() {
"info" | "informational" | "none" => Some(Severity::Info),
"low" => Some(Severity::Low),
"medium" | "med" | "moderate" => Some(Severity::Medium),
"high" => Some(Severity::High),
"critical" | "crit" => Some(Severity::Critical),
_ => None,
}
}
/// A stable 0..=4 rank (carried into the AccessKit `numeric_value` so a robot
/// driver reads the severity back as a number).
pub fn rank(self) -> u8 {
match self {
Severity::Info => 0,
Severity::Low => 1,
Severity::Medium => 2,
Severity::High => 3,
Severity::Critical => 4,
}
}
/// The severity colour on the active theme (Critical/High are the warm
/// red/amber; the rest pick palette accents so the table re-skins per theme).
pub fn color(self, th: &facett_core::theme::Theme) -> egui::Color32 {
match self {
Severity::Critical => RED,
Severity::High => AMBER,
Severity::Medium => th.accent,
Severity::Low => th.point,
Severity::Info => th.text_dim,
}
}
}
// ───────────────────────────────────────────────────────────────────────────
// Finding / Artifact / SecurityReport — the plain, host-supplied data
// ───────────────────────────────────────────────────────────────────────────
/// One thing the scanner found. `id`, `severity`, `summary` map straight from
/// `modgunn::core::Finding`; the rest are the richer scan columns the UI shows
/// (all optional, so a 1:1 mapper fills what it has).
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct Finding {
/// Stable id (a CVE / RUSTSEC / GHSA advisory id, or a tripwire id). Also the
/// row's stable identity (FC-5).
pub id: String,
pub severity: Severity,
/// The affected package (`name` or `name@version`).
pub package: String,
/// The advisory summary / title.
pub summary: String,
/// An advisory reference (a URL or db ref), if any.
#[serde(default)]
pub advisory: Option<String>,
/// The remediation — the fixed version / upgrade target, if any.
#[serde(default)]
pub fix: Option<String>,
/// EPSS exploit-probability in `0.0..=1.0`, if scored.
#[serde(default)]
pub epss: Option<f64>,
/// Whether the id is on CISA's Known-Exploited-Vulnerabilities catalog.
#[serde(default)]
pub kev: bool,
}
impl Finding {
/// A minimal finding (the three modgunn fields); builders add the rest.
pub fn new(id: impl Into<String>, severity: Severity, package: impl Into<String>, summary: impl Into<String>) -> Self {
Self {
id: id.into(),
severity,
package: package.into(),
summary: summary.into(),
advisory: None,
fix: None,
epss: None,
kev: false,
}
}
/// Builder: set the advisory reference.
pub fn with_advisory(mut self, advisory: impl Into<String>) -> Self {
self.advisory = Some(advisory.into());
self
}
/// Builder: set the fix / remediation.
pub fn with_fix(mut self, fix: impl Into<String>) -> Self {
self.fix = Some(fix.into());
self
}
/// Builder: set the EPSS probability (clamped to `0.0..=1.0`).
pub fn with_epss(mut self, epss: f64) -> Self {
self.epss = Some(epss.clamp(0.0, 1.0));
self
}
/// Builder: mark the finding as KEV-listed.
pub fn with_kev(mut self, kev: bool) -> Self {
self.kev = kev;
self
}
}
/// The artifact a verdict is about (content-addressed). Mirrors
/// `modgunn::core::ArtifactRef`.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct Artifact {
/// Ecosystem (e.g. `"cargo"`, `"maven"`, `"npm"`).
pub ecosystem: String,
pub name: String,
pub version: String,
/// The content hash the verdict is keyed by.
pub blob_sha256: String,
}
impl Artifact {
pub fn new(ecosystem: impl Into<String>, name: impl Into<String>, version: impl Into<String>, blob_sha256: impl Into<String>) -> Self {
Self { ecosystem: ecosystem.into(), name: name.into(), version: version.into(), blob_sha256: blob_sha256.into() }
}
/// `name@version` for display.
pub fn coords(&self) -> String {
format!("{}@{}", self.name, self.version)
}
}
/// The cacheable scan result: the verdict + its findings + the keys it was
/// computed against. The plain data a host hands the view; mirrors the whole of
/// `modgunn::core::Verdict`.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct SecurityReport {
pub artifact: Artifact,
pub verdict: Verdict,
pub findings: Vec<Finding>,
/// Cache key component (policy the verdict was computed under).
pub policy_version: String,
/// Cache key component (advisory-db revision the verdict was computed under).
pub advisory_db_rev: String,
/// **Injected clock (FC-7).** A host-stamped scan time (e.g. an RFC-3339 or
/// `HH:MM:SS` string). The view never reads a clock, so it renders identically
/// headless.
pub scanned_at: String,
}
impl SecurityReport {
/// An empty/clean report for `artifact`, stamped `scanned_at`.
pub fn clean(artifact: Artifact, scanned_at: impl Into<String>) -> Self {
Self {
artifact,
verdict: Verdict::Pass,
findings: Vec::new(),
policy_version: String::new(),
advisory_db_rev: String::new(),
scanned_at: scanned_at.into(),
}
}
/// The strongest severity among the findings (`None` ⇒ clean). Mirrors
/// `modgunn::core::Verdict::max_severity`.
pub fn max_severity(&self) -> Option<Severity> {
self.findings.iter().map(|f| f.severity).max()
}
/// Per-severity finding counts `[info, low, medium, high, critical]`.
pub fn severity_counts(&self) -> [usize; 5] {
let mut c = [0usize; 5];
for f in &self.findings {
c[f.severity.rank() as usize] += 1;
}
c
}
/// How many findings are on the KEV catalog.
pub fn kev_count(&self) -> usize {
self.findings.iter().filter(|f| f.kev).count()
}
}
// ───────────────────────────────────────────────────────────────────────────
// State / Msg / Effect — FC-1..FC-3, FC-8, FC-9
// ───────────────────────────────────────────────────────────────────────────
/// **All** observable state for the view (FC-1 / FC-3): the report plus the UI
/// state (the drilled-in finding, the severity floor filter, and the zoom).
/// Fully serializable so a caller reads it back at any frame boundary.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct SecurityState {
pub report: SecurityReport,
/// The drilled-in finding id (its CVE row highlights), if any.
#[serde(default)]
pub selected: Option<String>,
/// Severity floor — only findings `>= min_severity` show (`None` = all).
#[serde(default)]
pub min_severity: Option<Severity>,
/// Uniform zoom (1.0 = native), driving the text size.
#[serde(default = "one")]
pub scale: f32,
}
fn one() -> f32 {
1.0
}
impl SecurityState {
/// Wrap a report in fresh UI state (no selection, no filter, native scale).
pub fn new(report: SecurityReport) -> Self {
Self { report, selected: None, min_severity: None, scale: 1.0 }
}
/// The findings that pass the [`min_severity`](Self::min_severity) filter,
/// **sorted worst-first** (severity desc, then id) — the order the table and
/// the CVE list paint, so a driver reads rows in the same order it sees them.
pub fn visible_findings(&self) -> Vec<&Finding> {
let mut v: Vec<&Finding> = self
.report
.findings
.iter()
.filter(|f| self.min_severity.is_none_or(|m| f.severity >= m))
.collect();
v.sort_by(|a, b| b.severity.cmp(&a.severity).then_with(|| a.id.cmp(&b.id)));
v
}
}
/// Every input the view accepts (FC-2). The **only** legal way to mutate
/// [`SecurityState`]; derives `Debug` + `Clone`.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum Msg {
/// Drill into a finding by id (`None` clears the selection).
Select(Option<String>),
/// Set the severity floor filter (`None` shows all).
SetMinSeverity(Option<Severity>),
/// Set the zoom (clamped to `0.25..=4.0`).
SetScale(f32),
}
/// Side work as data (FC-8). The view does no I/O, so this is uninhabited — it
/// exists to make the "no pending effects" contract explicit and type-checked.
#[derive(Clone, Debug, PartialEq)]
pub enum Effect {}
// ───────────────────────────────────────────────────────────────────────────
// SecurityView — the Facet driver
// ───────────────────────────────────────────────────────────────────────────
/// The [`Facet`] over a [`SecurityReport`]: renders the verdict banner, the
/// findings table (severity / EPSS / KEV columns), and the CVE / advisory table.
pub struct SecurityView {
title: String,
state: SecurityState,
}
impl SecurityView {
/// New view titled `title` over `report`.
pub fn new(title: impl Into<String>, report: SecurityReport) -> Self {
Self { title: title.into(), state: SecurityState::new(report) }
}
/// Re-title (the deck keys facets off [`Facet::title`]).
pub fn set_title(&mut self, title: impl Into<String>) {
self.title = title.into();
}
/// **FC-3** — read the complete observable state at any frame boundary.
pub fn state(&self) -> &SecurityState {
&self.state
}
/// The live report.
pub fn report(&self) -> &SecurityReport {
&self.state.report
}
/// The current verdict.
pub fn verdict(&self) -> Verdict {
self.state.report.verdict
}
/// The drilled-in finding id, if any.
pub fn selected(&self) -> Option<&str> {
self.state.selected.as_deref()
}
/// **FC-8** — the view does no I/O, so it is always idle.
pub fn is_idle(&self) -> bool {
true
}
/// **FC-2** — the single mutation path. Apply one [`Msg`]; returns the
/// (always empty) effects the caller should run.
pub fn update(&mut self, msg: Msg) -> Vec<Effect> {
match msg {
Msg::Select(id) => {
// Toggle: re-selecting the open finding clears it.
self.state.selected = if self.state.selected == id { None } else { id };
}
Msg::SetMinSeverity(s) => self.state.min_severity = s,
Msg::SetScale(s) => self.state.scale = s.clamp(0.25, 4.0),
}
Vec::new()
}
/// **Discovery-contract `local()`** — a realistic **Block** report (a Critical
/// KEV CVE + a High + a Medium + an Info advisory) so the pane renders real,
/// non-trivial rows with no host attached.
pub fn local() -> Self {
let report = SecurityReport {
artifact: Artifact::new("cargo", "leftpad-rs", "2.1.0", "9f86d081884c7d65"),
verdict: Verdict::Block,
findings: vec![
Finding::new("CVE-2024-31337", Severity::Critical, "openssl-sys@0.9.90", "Heap buffer overflow in ASN.1 parsing")
.with_advisory("https://nvd.nist.gov/vuln/detail/CVE-2024-31337")
.with_fix("0.9.92")
.with_epss(0.94)
.with_kev(true),
Finding::new("RUSTSEC-2023-0071", Severity::High, "rsa@0.9.2", "Marvin timing side-channel (key recovery)")
.with_advisory("https://rustsec.org/advisories/RUSTSEC-2023-0071")
.with_fix("0.9.6")
.with_epss(0.41),
Finding::new("GHSA-wxr5-93ch-7f92", Severity::Medium, "regex@1.5.4", "ReDoS via crafted pattern")
.with_advisory("https://github.com/advisories/GHSA-wxr5-93ch-7f92")
.with_fix("1.5.5")
.with_epss(0.07),
Finding::new("RUSTSEC-2024-0006", Severity::Info, "leftpad-rs@2.1.0", "Crate is unmaintained")
.with_advisory("https://rustsec.org/advisories/RUSTSEC-2024-0006"),
],
policy_version: "nordisk-policy-7".into(),
advisory_db_rev: "advdb-2024.06.30".into(),
scanned_at: "2026-06-30T12:00:00Z".into(),
};
Self::new("security", report)
}
/// **Discovery-contract `remote()`** — same surface, re-titled.
pub fn remote() -> Self {
let mut v = Self::local();
v.title = "security (remote)".into();
v
}
// ── render (FC-9): a PURE function of `&self` that paints and RETURNS the
// `Msg`s it produced. `Facet::ui` applies them through `update`. ──────────
fn render(&self, ui: &mut egui::Ui) -> Vec<Msg> {
let mut out = Vec::new();
let th = theme(ui);
let st = &self.state;
let rep = &st.report;
// Scale the text styles for the whole pane (restored at the end).
let prev = ui.style().text_styles.clone();
if (st.scale - 1.0).abs() > f32::EPSILON {
ui.style_mut().text_styles.iter_mut().for_each(|(_, f)| f.size *= st.scale);
}
// ── verdict banner: a painter-drawn pill + an explicit AccessKit node ──
verdict_pill(ui, rep.verdict, rep.findings.len(), &th);
ui.add_space(2.0);
ui.horizontal_wrapped(|ui| {
ui.label(egui::RichText::new(rep.artifact.coords()).color(th.text).strong().monospace());
ui.label(egui::RichText::new(format!("· {}", rep.artifact.ecosystem)).color(th.text_dim));
ui.label(egui::RichText::new(format!("· sha256:{}…", short_sha(&rep.artifact.blob_sha256))).color(th.text_dim).monospace());
});
ui.label(
egui::RichText::new(format!(
"policy {} · advisory-db {} · scanned {}",
rep.policy_version, rep.advisory_db_rev, rep.scanned_at
))
.color(th.text_dim)
.small(),
);
// ── severity summary strip: per-severity chips + a facett-stat severity
// histogram (the [info,low,med,high,crit] distribution drawn through the
// SHARED stat sparkline lane — `facett_stat::sparkline` — so the count
// shape rides the same primitive the stat grid uses). ──
let counts = rep.severity_counts();
ui.horizontal_wrapped(|ui| {
for sev in [Severity::Critical, Severity::High, Severity::Medium, Severity::Low, Severity::Info] {
let n = counts[sev.rank() as usize];
ui.label(egui::RichText::new(format!("{} {n}", sev.tag())).color(sev.color(&th)).monospace());
}
if rep.kev_count() > 0 {
ui.label(egui::RichText::new(format!("KEV {}", rep.kev_count())).color(RED).strong());
}
});
severity_histogram(ui, &counts, &th);
ui.separator();
let visible = st.visible_findings();
// ── findings table: id · severity · package · EPSS · KEV · fix ──
ui.label(egui::RichText::new(format!("Findings ({})", visible.len())).color(th.text).strong());
if visible.is_empty() {
ui.label(egui::RichText::new("no findings").color(th.point));
} else {
let base = ui.id().with("findings");
egui::Grid::new("facett_security_findings")
.num_columns(6)
.striped(true)
.spacing([14.0, 4.0])
.show(ui, |ui| {
for h in ["ID", "SEV", "PACKAGE", "EPSS", "KEV", "FIX"] {
ui.label(egui::RichText::new(h).color(th.text_dim).small().strong());
}
ui.end_row();
for f in &visible {
let selected = st.selected.as_deref() == Some(f.id.as_str());
// The ID cell is the selectable, value-carrying node, keyed
// on the finding id (stable domain key — FC-5).
let id_resp = ui
.push_id(facett_core::stable_id(base, &f.id), |ui| {
ui.selectable_label(
selected,
egui::RichText::new(&f.id).color(th.accent).monospace(),
)
})
.inner;
// Carry the severity rank as the node's numeric_value so a
// robot driver reads the severity back as a number (FC-4).
{
let (id, sev, sel) = (f.id.clone(), f.severity, selected);
id_resp.widget_info(move || {
Semantics::list_item(format!("{id}: {}", sev.tag()), sel)
.value(sev.rank() as f64)
.widget_info()
});
}
if id_resp.clicked() {
out.push(Msg::Select(Some(f.id.clone())));
}
ui.label(egui::RichText::new(f.severity.tag()).color(f.severity.color(&th)).strong());
ui.label(egui::RichText::new(&f.package).color(th.text).monospace());
match f.epss {
Some(p) => ui.label(egui::RichText::new(format!("{:.0}%", p * 100.0)).color(epss_color(p, &th)).monospace()),
None => ui.label(egui::RichText::new("—").color(th.text_dim)),
};
if f.kev {
ui.label(egui::RichText::new("KEV").color(RED).strong());
} else {
ui.label(egui::RichText::new("—").color(th.text_dim));
}
match &f.fix {
Some(fix) => ui.label(egui::RichText::new(fix).color(th.point).monospace()),
None => ui.label(egui::RichText::new("none").color(th.text_dim)),
};
ui.end_row();
}
});
// ── CVE / advisory reference table: id · advisory · summary ──
ui.add_space(6.0);
ui.label(egui::RichText::new("CVE / advisories").color(th.text).strong());
let abase = ui.id().with("advisories");
egui::Grid::new("facett_security_advisories")
.num_columns(3)
.striped(true)
.spacing([14.0, 4.0])
.show(ui, |ui| {
for h in ["ID", "ADVISORY", "SUMMARY"] {
ui.label(egui::RichText::new(h).color(th.text_dim).small().strong());
}
ui.end_row();
for f in &visible {
ui.push_id(facett_core::stable_id(abase, &f.id), |ui| {
ui.label(egui::RichText::new(&f.id).color(th.text_dim).monospace());
});
match &f.advisory {
Some(url) => {
ui.hyperlink_to(egui::RichText::new("link").color(th.accent), url);
}
None => {
ui.label(egui::RichText::new("—").color(th.text_dim));
}
}
ui.label(egui::RichText::new(&f.summary).color(th.text));
ui.end_row();
}
});
}
ui.style_mut().text_styles = prev;
#[cfg(feature = "testmatrix")]
facett_core::testmatrix::emit(
"facett-security::SecurityView::ui",
"ui_render",
true,
&format!("verdict={} findings={} visible={}", rep.verdict.tag(), rep.findings.len(), visible.len()),
);
out
}
}
impl Facet for SecurityView {
fn title(&self) -> &str {
&self.title
}
fn ui(&mut self, ui: &mut egui::Ui) {
facett_core::navgraph::declare_own_draw_order(ui); // atoms follow the data order (navgraph §9)
// FC-9: the view is pure (paints + emits Msgs); FC-2: mutation only via
// `update`. The immediate-mode trait just bridges the two.
let msgs = self.render(ui);
for m in msgs {
self.update(m);
}
}
/// The robot / headless-drive seam (FC-2): parse one serde-tagged [`Msg`] and
/// apply it through the single [`update`](SecurityView::update) mutation path.
/// Malformed / unknown JSON is ignored (never a panic), so a headless robot or
/// the shared `probe_and_emit` harness drives the finding drill-in / severity
/// floor / zoom without the concrete `Msg` type at the call site.
fn update_json(&mut self, msg_json: &str) {
if let Ok(msg) = serde_json::from_str::<Msg>(msg_json) {
let _ = self.update(msg);
}
}
fn state_json(&self) -> serde_json::Value {
let st = &self.state;
let rep = &st.report;
let counts = rep.severity_counts();
serde_json::json!({
"title": self.title,
"verdict": rep.verdict.tag(),
"scale": st.scale,
"selected": st.selected,
"min_severity": st.min_severity.map(|s| s.tag()),
"artifact": {
"ecosystem": rep.artifact.ecosystem,
"name": rep.artifact.name,
"version": rep.artifact.version,
"coords": rep.artifact.coords(),
"blob_sha256": rep.artifact.blob_sha256,
},
"policy_version": rep.policy_version,
"advisory_db_rev": rep.advisory_db_rev,
"scanned_at": rep.scanned_at,
"max_severity": rep.max_severity().map(|s| s.tag()),
"kev_count": rep.kev_count(),
"severity_counts": {
"info": counts[0], "low": counts[1], "medium": counts[2], "high": counts[3], "critical": counts[4],
},
"findings_total": rep.findings.len(),
"findings_visible": st.visible_findings().len(),
// The visible findings, in the SAME worst-first order the table paints.
"findings": st.visible_findings().iter().map(|f| serde_json::json!({
"id": f.id,
"severity": f.severity.tag(),
"severity_rank": f.severity.rank(),
"package": f.package,
"summary": f.summary,
"advisory": f.advisory,
"fix": f.fix,
"epss": f.epss,
"kev": f.kev,
})).collect::<Vec<_>>(),
})
}
fn caps(&self) -> FacetCaps {
FacetCaps::NONE.selectable().copyable().themeable().scalable().resizable()
}
fn scale(&self) -> f32 {
self.state.scale
}
fn set_scale(&mut self, scale: f32) {
self.update(Msg::SetScale(scale));
}
fn selection_json(&self) -> serde_json::Value {
match self.state.selected.as_deref() {
Some(id) => match self.state.report.findings.iter().find(|f| f.id == id) {
Some(f) => serde_json::json!({
"id": f.id, "severity": f.severity.tag(), "package": f.package, "summary": f.summary,
"advisory": f.advisory, "fix": f.fix, "epss": f.epss, "kev": f.kev,
}),
None => serde_json::Value::Null,
},
None => serde_json::Value::Null,
}
}
/// Facet-level copy: the whole report (the live state) as pretty JSON.
fn copy(&mut self) -> Option<String> {
serde_json::to_string_pretty(&self.state.report).ok().filter(|s| !s.is_empty())
}
}
// ── FC-2/FC-9 canonical trait: SecurityView IS the reference `Elm` component ──
//
// `SecurityView` already hand-rolls the Model/Msg/Effect/pure-view split, so it
// implements `facett_core::Elm` verbatim by delegating to its existing inherent
// methods — no behavior change. This is the proof the shared infra fits the worked
// reference. NOTE: the hand-written `impl Facet` above is intentionally KEPT (not
// replaced by `impl_facet_via_elm!`) because this view publishes a **richer**
// `state_json` (verdict tag, severity counts, worst-first findings) than the
// macro's plain `serde(state())` default; swapping it would change that observable
// dump and break the `state_json` contract tests. Downstream components with no
// custom `state_json` use the macro instead.
impl facett_core::Elm for SecurityView {
type Model = SecurityState;
type Msg = Msg;
type Effect = Effect;
fn title(&self) -> &str {
&self.title
}
fn state(&self) -> &SecurityState {
&self.state
}
fn update(&mut self, msg: Msg) -> Vec<Effect> {
SecurityView::update(self, msg)
}
fn view(&self, ui: &mut egui::Ui) -> Vec<Msg> {
self.render(ui)
}
}
// ── painter helpers ─────────────────────────────────────────────────────────
/// The verdict banner pill (painter-drawn) + its AccessKit node (FC-4). The node
/// carries the verdict tag as its label and the finding count as numeric_value.
fn verdict_pill(ui: &mut egui::Ui, verdict: Verdict, findings: usize, th: &facett_core::theme::Theme) {
let col = verdict.color(th);
let text = format!("{} · {findings} finding{}", verdict.tag(), if findings == 1 { "" } else { "s" });
let galley = ui.painter().layout_no_wrap(text.clone(), egui::FontId::proportional(18.0), col);
let pad = egui::vec2(14.0, 7.0);
let (rect, _) = ui.allocate_exact_size(galley.size() + pad * 2.0, egui::Sense::hover());
let fill = egui::Color32::from_rgba_unmultiplied(col.r(), col.g(), col.b(), 38);
let cr = egui::CornerRadius::same(8);
ui.painter().rect_filled(rect, cr, fill);
ui.painter().rect_stroke(rect, cr, egui::Stroke::new(2.0_f32, col), egui::StrokeKind::Inside);
ui.painter().galley(rect.min + pad, galley, col);
// The value-bearing node over the painted pill: label = "verdict: BLOCK",
// numeric_value = finding count. Stable key (not a loop index — FC-5).
let base = ui.id();
let sem = Semantics::new(egui::WidgetType::Label, format!("verdict: {}", verdict.tag())).value(findings as f64);
let _ = facett_core::a11y::node(ui, base, ("security-verdict", verdict.tag()), egui::Sense::hover(), rect, sem);
}
/// The severity histogram — the `[info, low, medium, high, critical]` finding
/// counts drawn through facett's **shared** stat sparkline lane
/// ([`facett_stat::sparkline`]), so the severity distribution rides the same
/// primitive the stat grid uses (FC: reuse over re-paint). A value-bearing
/// AccessKit node carries the total finding count so a robot reads the histogram
/// back as a number (FC-4); the rank with the most findings is its label.
fn severity_histogram(ui: &mut egui::Ui, counts: &[usize; 5], th: &facett_core::theme::Theme) {
let total: usize = counts.iter().sum();
if total == 0 {
return;
}
let series: Vec<f64> = counts.iter().map(|&c| c as f64).collect();
let (rect, _) = ui.allocate_exact_size(egui::vec2(120.0, 16.0), egui::Sense::hover());
facett_stat::sparkline(ui.painter(), th, &series, rect.shrink(2.0));
// The histogram's AccessKit node: label names the modal severity, value = the
// total finding count. Stable key (FC-5).
let peak = (0..5).max_by_key(|&i| counts[i]).unwrap_or(0) as u8;
let modal = match peak {
4 => "critical",
3 => "high",
2 => "medium",
1 => "low",
_ => "info",
};
let sem = Semantics::new(egui::WidgetType::Image, format!("severity histogram · {total} findings · modal {modal}")).value(total as f64);
let _ = facett_core::a11y::node(ui, ui.id(), "security-severity-histogram", egui::Sense::hover(), rect, sem);
}
/// EPSS colour ramp: high exploit-probability is the warm red, mid is amber.
fn epss_color(p: f64, th: &facett_core::theme::Theme) -> egui::Color32 {
if p >= 0.5 {
RED
} else if p >= 0.1 {
AMBER
} else {
th.text
}
}
/// First 12 hex chars of a sha, for the compact artifact line.
fn short_sha(sha: &str) -> &str {
let n = sha.len().min(12);
&sha[..n]
}
#[cfg(test)]
mod tests;