diff --git a/src/cli.rs b/src/cli.rs index e85dcfd..b3c63c4 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -524,6 +524,7 @@ pub enum OutputFormat { Markdown, Json, Sarif, + Html, } #[derive(ValueEnum, Clone, Copy, Debug, PartialEq, Eq, Deserialize)] diff --git a/src/render/html.rs b/src/render/html.rs new file mode 100644 index 0000000..9315339 --- /dev/null +++ b/src/render/html.rs @@ -0,0 +1,450 @@ +use std::fmt::Write; + +use crate::diff::ChangeSet; +use crate::enrich::Enrichment; +use crate::model::Component; + +const CSS: &str = r#" +:root { + --bg: #1a1b26; + --bg-surface: #24283b; + --bg-hover: #292e42; + --text: #c0caf5; + --text-muted: #565f89; + --accent: #7aa2f7; + --green: #9ece6a; + --red: #f7768e; + --yellow: #e0af68; + --orange: #ff9e64; + --purple: #bb9af7; + --cyan: #7dcfff; + --border: #3b4261; + --radius: 8px; +} +* { margin: 0; padding: 0; box-sizing: border-box; } +body { + background: var(--bg); + color: var(--text); + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, monospace; + padding: 2rem; + line-height: 1.6; +} +h1 { color: var(--accent); margin-bottom: 1.5rem; font-size: 1.8rem; } +h2 { color: var(--accent); font-size: 1.2rem; } +table { + width: 100%; border-collapse: collapse; margin: 0.5rem 0; + background: var(--bg-surface); border-radius: var(--radius); + overflow: hidden; +} +th { background: var(--bg-hover); color: var(--accent); text-align: left; padding: 0.6rem 1rem; font-size: 0.85rem; } +td { padding: 0.5rem 1rem; border-top: 1px solid var(--border); font-size: 0.85rem; } +tr:hover td { background: var(--bg-hover); } +details { + background: var(--bg-surface); border: 1px solid var(--border); + border-radius: var(--radius); margin-bottom: 1rem; overflow: hidden; +} +summary { + padding: 0.8rem 1rem; cursor: pointer; font-weight: 600; + user-select: none; display: flex; align-items: center; gap: 0.5rem; +} +summary:hover { background: var(--bg-hover); } +summary::marker { color: var(--accent); } +.badge { + display: inline-block; padding: 0.15rem 0.5rem; border-radius: 4px; + font-size: 0.75rem; font-weight: 700; text-transform: uppercase; +} +.badge-critical { background: var(--red); color: var(--bg); } +.badge-high { background: var(--orange); color: var(--bg); } +.badge-medium { background: var(--yellow); color: var(--bg); } +.badge-low { background: var(--cyan); color: var(--bg); } +.badge-green { background: var(--green); color: var(--bg); } +.badge-red { background: var(--red); color: var(--bg); } +.badge-purple { background: var(--purple); color: var(--bg); } +.badge-count { + background: var(--border); color: var(--text); padding: 0.1rem 0.45rem; + border-radius: 10px; font-size: 0.75rem; margin-left: auto; +} +.summary-grid { + display: grid; grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); + gap: 1rem; margin-bottom: 1.5rem; +} +.stat-card { + background: var(--bg-surface); border: 1px solid var(--border); + border-radius: var(--radius); padding: 1rem; text-align: center; +} +.stat-card .value { font-size: 2rem; font-weight: 700; color: var(--accent); } +.stat-card .label { font-size: 0.8rem; color: var(--text-muted); } +.content { padding: 0 1rem 1rem; } +"#; + +fn esc(s: &str) -> String { + s.replace('&', "&") + .replace('<', "<") + .replace('>', ">") + .replace('"', """) +} + +fn severity_badge(sev: &impl std::fmt::Display) -> String { + let s = sev.to_string(); + let cls = match s.to_lowercase().as_str() { + "critical" => "badge-critical", + "high" => "badge-high", + "medium" => "badge-medium", + "low" => "badge-low", + _ => "badge-purple", + }; + format!(r#"{}"#, cls, esc(&s)) +} + +fn comp_row(c: &Component) -> String { + format!( + "{}{}{}", + esc(&c.name), + esc(&c.version), + esc(&c.ecosystem.to_string()) + ) +} + +fn section( + out: &mut String, + title: &str, + count: usize, + badge_cls: &str, + body: F, +) { + if count == 0 { + return; + } + let _ = write!( + out, + r#"

{}

{} item{}
"#, + esc(title), + badge_cls, + count, + if count == 1 { "" } else { "s" } + ); + body(out); + out.push_str("
"); +} + +pub fn render(cs: &ChangeSet, enrichment: &Enrichment) -> String { + let mut out = String::with_capacity(16384); + + let _ = write!( + out, + r#"BOMdrift Report"# + ); + out.push_str("

📦 BOMdrift — SBOM Diff Report

"); + + // Summary cards + let total_vulns: usize = enrichment.vulns.values().map(|v| v.len()).sum(); + out.push_str(r#"
"#); + for (label, value, color) in [ + ("Added", cs.added.len(), "var(--green)"), + ("Removed", cs.removed.len(), "var(--red)"), + ("Version Changed", cs.version_changed.len(), "var(--yellow)"), + ("License Changed", cs.license_changed.len(), "var(--orange)"), + ("Vulnerabilities", total_vulns, "var(--red)"), + ("Typosquats", enrichment.typosquats.len(), "var(--purple)"), + ( + "VEX Suppressed", + enrichment.vex_suppressed_count, + "var(--cyan)", + ), + ] { + if value > 0 { + let _ = write!( + out, + r#"
{value}
{label}
"# + ); + } + } + out.push_str("
"); + + // Added + section( + &mut out, + "Added Components", + cs.added.len(), + "badge-green", + |o| { + o.push_str(""); + for c in &cs.added { + o.push_str(&comp_row(c)); + } + o.push_str("
NameVersionEcosystem
"); + }, + ); + + // Removed + section( + &mut out, + "Removed Components", + cs.removed.len(), + "badge-red", + |o| { + o.push_str(""); + for c in &cs.removed { + o.push_str(&comp_row(c)); + } + o.push_str("
NameVersionEcosystem
"); + }, + ); + + // Version Changed + section( + &mut out, + "Version Changed", + cs.version_changed.len(), + "badge-purple", + |o| { + o.push_str(""); + for (old, new) in &cs.version_changed { + let _ = write!( + o, + "", + esc(&old.name), + esc(&old.version), + esc(&new.version), + esc(&old.ecosystem.to_string()) + ); + } + o.push_str("
NameOld VersionNew VersionEcosystem
{}{}{}{}
"); + }, + ); + + // License Changed + section( + &mut out, + "License Changed", + cs.license_changed.len(), + "badge-purple", + |o| { + o.push_str(""); + for (old, new) in &cs.license_changed { + let _ = write!( + o, + "", + esc(&old.name), + esc(&old.licenses.join(", ")), + esc(&new.licenses.join(", ")) + ); + } + o.push_str("
NameOld LicenseNew License
{}{}{}
"); + }, + ); + + // Vulnerabilities + section( + &mut out, + "Vulnerabilities", + total_vulns, + "badge-critical", + |o| { + o.push_str( + "", + ); + for (pkg, advisories) in &enrichment.vulns { + for adv in advisories { + let _ = write!( + o, + "", + esc(pkg), + esc(&adv.id), + severity_badge(&adv.severity), + esc(&adv.aliases.join(", ")) + ); + } + } + o.push_str("
ComponentIDSeverityAliases
{}{}{}{}
"); + }, + ); + + // Typosquats + section( + &mut out, + "Typosquat Candidates", + enrichment.typosquats.len(), + "badge-critical", + |o| { + o.push_str(""); + for t in &enrichment.typosquats { + let _ = write!( + o, + "", + esc(&t.component.name), + esc(&t.closest), + t.score + ); + } + o.push_str("
ComponentSimilar ToScore
{}{}{:.2}
"); + }, + ); + + // Version Jumps + section( + &mut out, + "Version Jumps", + enrichment.version_jumps.len(), + "badge-high", + |o| { + o.push_str(""); + for v in &enrichment.version_jumps { + let _ = write!( + o, + "", + esc(&v.before.name), + esc(&v.before.version), + esc(&v.after.version), + v.before_major, + v.after_major + ); + } + o.push_str("
NameOldNewOld MajorNew Major
{}{}{}{}{}
"); + }, + ); + + // Maintainer Age + section( + &mut out, + "Maintainer Age Warnings", + enrichment.maintainer_age.len(), + "badge-high", + |o| { + o.push_str(""); + for m in &enrichment.maintainer_age { + let _ = write!( + o, + "", + esc(&m.component.name), + esc(&m.top_contributor), + m.days_old, + esc(&m.first_commit_at) + ); + } + o.push_str("
ComponentTop ContributorDays OldFirst Commit
{}{}{}{}
"); + }, + ); + + // License Violations + section( + &mut out, + "License Violations", + enrichment.license_violations.len(), + "badge-red", + |o| { + o.push_str(""); + for lv in &enrichment.license_violations { + let _ = write!( + o, + "", + esc(&lv.component.name), + esc(&lv.license), + esc(&lv.matched_rule), + lv.kind + ); + } + o.push_str("
ComponentLicenseMatched RuleKind
{}{}{}{:?}
"); + }, + ); + + // Recently Published + section( + &mut out, + "Recently Published", + enrichment.recently_published.len(), + "badge-high", + |o| { + o.push_str(""); + for r in &enrichment.recently_published { + let _ = write!( + o, + "", + esc(&r.component.name), + esc(&r.component.version), + esc(&r.published_at), + r.days_old + ); + } + o.push_str("
ComponentVersionPublishedDays Old
{}{}{}{}
"); + }, + ); + + // Deprecated + section( + &mut out, + "Deprecated Packages", + enrichment.deprecated.len(), + "badge-red", + |o| { + o.push_str(""); + for r in &enrichment.deprecated { + let _ = write!( + o, + "", + esc(&r.component.name), + esc(&r.component.version), + esc(r.message.as_deref().unwrap_or("")) + ); + } + o.push_str("
ComponentVersionMessage
{}{}{}
"); + }, + ); + + // Maintainer Set Changed + section( + &mut out, + "Maintainer Set Changed", + enrichment.maintainer_set_changed.len(), + "badge-high", + |o| { + o.push_str(""); + for r in &enrichment.maintainer_set_changed { + let _ = write!( + o, + "", + esc(&r.before.name), + esc(&r.before.version), + esc(&r.after.version), + esc(&r.added.join(", ")), + esc(&r.removed.join(", ")) + ); + } + o.push_str("
PackageOld VersionNew VersionAddedRemoved
{}{}{}{}{}
"); + }, + ); + + // Plugin Findings + section( + &mut out, + "Plugin Findings", + enrichment.plugin_findings.len(), + "badge-purple", + |o| { + o.push_str(""); + for p in &enrichment.plugin_findings { + let _ = write!( + o, + "", + esc(&p.plugin_name), + esc(&p.component_purl), + severity_badge(&format!("{:?}", p.severity)), + esc(&p.message) + ); + } + o.push_str("
PluginComponent PURLSeverityMessage
{}{}{}{}
"); + }, + ); + + // VEX Suppressed + if enrichment.vex_suppressed_count > 0 { + let _ = write!( + out, + r#"

VEX Suppressed

{} suppressed

{} vulnerabilities were suppressed by VEX annotations.

"#, + enrichment.vex_suppressed_count, enrichment.vex_suppressed_count + ); + } + + out.push_str(""); + out +} diff --git a/src/render/mod.rs b/src/render/mod.rs index e2af19b..a9e9a39 100644 --- a/src/render/mod.rs +++ b/src/render/mod.rs @@ -3,6 +3,7 @@ //! output buffer) so the same `ChangeSet` always renders to byte-identical output — //! a hard requirement for `peter-evans/create-or-update-comment` upsert behavior. +pub mod html; pub mod json; pub mod markdown; pub mod sarif; diff --git a/src/run.rs b/src/run.rs index 0a89e76..c5c6eb3 100644 --- a/src/run.rs +++ b/src/run.rs @@ -401,6 +401,7 @@ fn run_diff(mut args: DiffArgs) -> Result<()> { } OutputFormat::Json => render::json::render(&cs, &enrichment), OutputFormat::Sarif => render::sarif::render(&cs, &enrichment), + OutputFormat::Html => render::html::render(&cs, &enrichment), }; if let Some(path) = &args.output_file {