src/viz/security_tab.rs
//! 🛡 Security tab — **workspace-driven, server-first** SBOM/vuln/license scan.
//! Lists the workspace's configured repos (no free-text path) as buttons. When
//! connected to a server it calls `Mimir.SecurityScan` (the server scans its own
//! `git/<repo>` checkout — the viz host needs no sources); otherwise it scans
//! the local `workspace_root/<repo>` via `crate::security::scan`.
//!
//! NOTE (next): the deep-scanned dependency closure already lives in the
//! warehouse (`repo=deps`); a `sbom_components`/`vuln_findings` cache would make
//! even the server side warehouse-first instead of re-running cargo metadata.
use std::path::PathBuf;
use std::sync::{Arc, Mutex};
use eframe::egui::{self, RichText};
use serde_json::Value;
use super::facett_theme::{AMBER, GREEN, RED, Theme};
use super::ops_tabs::Server;
/// The warehouse-sourced verdict the server folds from modgunn's `cve_advisories`
/// (real severity/EPSS/KEV) — the Pass/Warn/Block decision, not just a vuln count.
struct VerdictSummary {
decision: String, // "pass" | "warn" | "block"
pass: usize,
warn: usize,
block: usize,
rev: String,
}
/// A finished scan's display data (flattened + Send).
struct Snapshot {
repo: String,
components: usize,
vulns: Vec<(String, String, Vec<String>)>, // (crate, version, advisory ids)
licenses: Vec<(String, usize)>,
cache_hits: usize,
cache_misses: usize,
/// The server's warehouse-sourced verdict, when present (server-side scans).
verdict: Option<VerdictSummary>,
}
type Slot = Arc<Mutex<Option<Result<Snapshot, String>>>>;
fn snap_from_report(rep: crate::security::SecurityReport) -> Snapshot {
Snapshot {
repo: rep.repo.clone(),
components: rep.components.len(),
vulns: rep
.vulns
.iter()
.map(|v| (v.crate_name.clone(), v.version.clone(), v.ids.clone()))
.collect(),
licenses: rep.license_tally(),
cache_hits: 0,
cache_misses: rep.components.len(),
// The local fat-mode scan doesn't open the warehouse; the verdict badge is
// a server-side (warehouse) signal, so None here.
verdict: None,
}
}
/// Parse the `Mimir.SecurityScan` JSON ({repo, components, vulns[], licenses[[l,n]]}).
fn parse_report(json: &str) -> Result<Snapshot, String> {
let v: Value = serde_json::from_str(json).map_err(|e| e.to_string())?;
let vulns = v["vulns"]
.as_array()
.map(|a| {
a.iter()
.map(|x| {
(
x["crate"].as_str().unwrap_or_default().to_string(),
x["version"].as_str().unwrap_or_default().to_string(),
x["ids"]
.as_array()
.map(|i| {
i.iter()
.filter_map(|s| s.as_str().map(String::from))
.collect()
})
.unwrap_or_default(),
)
})
.collect()
})
.unwrap_or_default();
let licenses = v["licenses"]
.as_array()
.map(|a| {
a.iter()
.filter_map(|x| {
let pair = x.as_array()?;
Some((
pair.first()?.as_str()?.to_string(),
pair.get(1)?.as_u64()? as usize,
))
})
.collect()
})
.unwrap_or_default();
let verdict = v["verdict"]
.get("decision")
.and_then(|d| d.as_str())
.map(|dec| VerdictSummary {
decision: dec.to_string(),
pass: v["verdict"]["pass"].as_u64().unwrap_or(0) as usize,
warn: v["verdict"]["warn"].as_u64().unwrap_or(0) as usize,
block: v["verdict"]["block"].as_u64().unwrap_or(0) as usize,
rev: v["verdict"]["advisory_db_rev"]
.as_str()
.unwrap_or_default()
.to_string(),
});
Ok(Snapshot {
repo: v["repo"].as_str().unwrap_or_default().to_string(),
components: v["components"].as_u64().unwrap_or(0) as usize,
vulns,
licenses,
cache_hits: v["cache_hits"].as_u64().unwrap_or(0) as usize,
cache_misses: v["cache_misses"].as_u64().unwrap_or(0) as usize,
verdict,
})
}
pub struct SecurityTab {
workspace_root: PathBuf,
repos: Vec<String>,
scanning: Option<String>,
pending: Option<Slot>,
result: Option<Result<Snapshot, String>>,
theme: Theme,
}
impl SecurityTab {
pub fn new(workspace_root: PathBuf, repos: Vec<String>) -> Self {
Self {
workspace_root,
repos,
scanning: None,
pending: None,
result: None,
theme: Theme::default(),
}
}
/// Re-skin this pane with a facett palette (broadcast from the picker).
pub fn set_palette(&mut self, t: Theme) {
self.theme = t;
}
/// Re-scope to a newly picked workspace: swap in its checkout root + repo
/// set, and drop any scan from the previous workspace so the buttons (and a
/// lingering result) don't show the old workspace's repos. Mirrors
/// [`KnowledgeTab::set_workspace`]; called from `App::switch_workspace`.
pub fn set_workspace(&mut self, workspace_root: PathBuf, repos: Vec<String>) {
self.workspace_root = workspace_root;
self.repos = repos;
self.scanning = None;
self.pending = None;
self.result = None;
}
fn start_scan(&mut self, srv: Option<&Server>, workspace: &str, repo: &str) {
let slot: Slot = Arc::new(Mutex::new(None));
let sink = slot.clone();
let repo_s = repo.to_string();
match srv {
// Server-first: the server scans its own checkout; no sources needed here.
Some(s) => {
let (ep, tok, ws) = (s.endpoint.clone(), s.token.clone(), workspace.to_string());
std::thread::spawn(move || {
let r = super::remote::security_scan(&ep, &tok, &repo_s, &ws)
.map_err(|e| format!("{e:#}"))
.and_then(|j| parse_report(&j));
*sink.lock().unwrap() = Some(r);
});
}
// Local fallback: scan workspace_root/<repo> on disk.
None => {
let path = self.workspace_root.join(repo);
std::thread::spawn(move || {
let r = crate::security::scan(&path)
.map(snap_from_report)
.map_err(|e| format!("{e:#}"));
*sink.lock().unwrap() = Some(r);
});
}
}
self.scanning = Some(repo.to_string());
self.pending = Some(slot);
self.result = None;
}
pub fn draw(&mut self, ui: &mut egui::Ui, srv: Option<&Server>, workspace: &str) {
let theme = self.theme;
let busy = self.pending.is_some();
ui.horizontal_wrapped(|ui| {
ui.label("scan a workspace repo:");
if self.repos.is_empty() {
ui.colored_label(theme.text_dim, "(no repos for this workspace)");
}
for repo in self.repos.clone() {
if ui
.add_enabled(!busy, egui::Button::new(format!("🛡 {repo}")))
.clicked()
{
self.start_scan(srv, workspace, &repo);
}
}
ui.weak(if srv.is_some() {
"· server-side"
} else {
"· local"
});
});
ui.separator();
// Recover a poisoned lock rather than panicking the UI thread if the
// background scan thread happened to unwind while holding it.
let done = self
.pending
.as_ref()
.and_then(|slot| slot.lock().unwrap_or_else(|p| p.into_inner()).take());
if let Some(d) = done {
match &d {
Ok(snap) => {
let nv: usize = snap.vulns.iter().map(|(_, _, ids)| ids.len()).sum();
nornir_testmatrix::functional_status(
"nornir/viz/security",
"scan_completed",
true,
&format!("{} crate(s), {nv} vuln(s)", snap.components),
);
}
Err(e) => nornir_testmatrix::functional_status(
"nornir/viz/security",
"scan_completed",
false,
e,
),
}
self.result = Some(d);
self.pending = None;
}
if self.pending.is_some() {
let r = self.scanning.as_deref().unwrap_or("");
ui.label(format!(
"⏳ scanning {r} — cargo metadata (deep tree) + OSV.dev …"
));
ui.ctx().request_repaint();
return;
}
match &self.result {
None => {
ui.label("Pick a repo above to run a deep SBOM + vulnerability + license scan.");
ui.label(
RichText::new(
"cargo metadata → full dependency tree · OSV.dev → CVEs/RUSTSEC · CycloneDX-compatible",
)
.weak(),
);
}
Some(Err(e)) => {
ui.colored_label(RED, format!("scan failed: {e}"));
}
Some(Ok(snap)) => {
ui.horizontal(|ui| {
ui.heading(RichText::new(format!("🛡 {}", snap.repo)).color(theme.accent));
ui.label(format!("· {} crates (deep)", snap.components));
let nv: usize = snap.vulns.iter().map(|(_, _, ids)| ids.len()).sum();
let c = if nv == 0 { GREEN } else { RED };
ui.label(
RichText::new(format!("· {nv} vuln(s) in {} crate(s)", snap.vulns.len()))
.color(c)
.strong(),
);
});
// Warehouse-sourced verdict badge (real severity/EPSS/KEV) — the
// Pass/Warn/Block decision modgunn folded, distinct from the raw
// vuln count above. Only present for server-side scans.
if let Some(vd) = &snap.verdict {
let (label, color) = match vd.decision.as_str() {
"block" => ("● BLOCK", RED),
"warn" => ("● WARN", AMBER),
_ => ("● PASS", GREEN),
};
ui.horizontal(|ui| {
ui.label(RichText::new(label).color(color).strong());
ui.label(
RichText::new(format!(
"verdict · block {} · warn {} · pass {}",
vd.block, vd.warn, vd.pass
))
.color(theme.text_dim),
);
if !vd.rev.is_empty() {
ui.label(
RichText::new(format!("· advisories {}", vd.rev))
.color(theme.text_dim)
.small(),
);
}
});
}
if snap.cache_hits + snap.cache_misses > 0 {
let msg = if snap.cache_misses == 0 {
format!(
"warehouse cache: {} hit / 0 miss — zero network ✓",
snap.cache_hits
)
} else {
format!(
"warehouse cache: {} hit / {} miss (queried OSV)",
snap.cache_hits, snap.cache_misses
)
};
ui.label(RichText::new(msg).color(theme.text_dim).small());
}
ui.separator();
egui::ScrollArea::vertical()
.auto_shrink([false, false])
.show(ui, |ui| {
if snap.vulns.is_empty() {
ui.colored_label(GREEN, "✓ no known vulnerabilities");
} else {
for (name, ver, ids) in &snap.vulns {
ui.horizontal(|ui| {
let sev = if ids
.iter()
.any(|i| i.starts_with("RUSTSEC") || i.starts_with("CVE"))
{
AMBER
} else {
theme.accent
};
ui.label(RichText::new("⚠").color(sev));
ui.monospace(format!("{name} {ver}"));
ui.colored_label(theme.text_dim, ids.join(", "));
});
}
}
ui.separator();
ui.label(RichText::new("licenses").strong());
ui.horizontal_wrapped(|ui| {
for (lic, n) in snap.licenses.iter().take(12) {
ui.label(
RichText::new(format!("{lic} ×{n}"))
.color(theme.text_dim)
.monospace(),
);
ui.separator();
}
});
});
}
}
}
/// 🛡 Security tab's slice of `state_json` (LAW #6): the configured repos
/// (the scan buttons), which repo (if any) is currently scanning, and the
/// rendered scan result — component count + per-crate vuln findings +
/// license tally + the warehouse-cache hit/miss counters.
pub fn state_json(&self) -> serde_json::Value {
serde_json::json!({
"palette": self.theme.name,
"repos": self.repos,
"scanning": self.scanning,
"busy": self.pending.is_some(),
"result": match &self.result {
None => serde_json::json!({ "ran": false }),
Some(Err(e)) => serde_json::json!({ "ran": true, "ok": false, "error": e }),
Some(Ok(snap)) => serde_json::json!({
"ran": true, "ok": true,
"repo": snap.repo,
"components": snap.components,
"vuln_crates": snap.vulns.len(),
"vulns": snap.vulns.iter().map(|(c, v, ids)| serde_json::json!({
"crate": c, "version": v, "ids": ids,
})).collect::<Vec<_>>(),
"licenses": snap.licenses.iter().map(|(l, n)| serde_json::json!([l, n])).collect::<Vec<_>>(),
"cache_hits": snap.cache_hits,
"cache_misses": snap.cache_misses,
}),
},
})
}
}
#[cfg(test)]
mod tests {
use super::*;
/// The propagation bug (fixed): switching workspace must re-scope the
/// Security tab's repo buttons. Inject workspace A's repos, switch to B,
/// assert the rendered state shows ONLY B's repos and root — never A's.
#[test]
fn set_workspace_rescopes_repos_and_root() {
let mut tab = SecurityTab::new(
PathBuf::from("/ws/holger"),
vec!["holger".into(), "holger-cli".into()],
);
// Sanity: it starts on workspace A's repos.
let a = tab.state_json();
assert_eq!(a["repos"], serde_json::json!(["holger", "holger-cli"]));
// Pick workspace B.
tab.set_workspace(
PathBuf::from("/ws/knut"),
vec!["knut".into(), "knut-pipelines".into()],
);
let b = tab.state_json();
assert_eq!(
b["repos"],
serde_json::json!(["knut", "knut-pipelines"]),
"Security repos must follow the picked workspace, not stay on the previous one"
);
assert_eq!(tab.workspace_root, PathBuf::from("/ws/knut"));
// A stale scan from workspace A must be cleared, not shown under B.
assert_eq!(b["busy"], serde_json::json!(false));
assert_eq!(b["scanning"], serde_json::Value::Null);
assert_eq!(b["result"], serde_json::json!({ "ran": false }));
// None of A's repos may leak through.
assert!(!b["repos"].to_string().contains("holger"));
}
}