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#"
"#
+ );
+ }
+ }
+ out.push_str("
");
+
+ // Added
+ section(
+ &mut out,
+ "Added Components",
+ cs.added.len(),
+ "badge-green",
+ |o| {
+ o.push_str("| Name | Version | Ecosystem |
");
+ for c in &cs.added {
+ o.push_str(&comp_row(c));
+ }
+ o.push_str("
");
+ },
+ );
+
+ // Removed
+ section(
+ &mut out,
+ "Removed Components",
+ cs.removed.len(),
+ "badge-red",
+ |o| {
+ o.push_str("| Name | Version | Ecosystem |
");
+ for c in &cs.removed {
+ o.push_str(&comp_row(c));
+ }
+ o.push_str("
");
+ },
+ );
+
+ // Version Changed
+ section(
+ &mut out,
+ "Version Changed",
+ cs.version_changed.len(),
+ "badge-purple",
+ |o| {
+ o.push_str("| Name | Old Version | New Version | Ecosystem |
");
+ 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("
");
+ },
+ );
+
+ // License Changed
+ section(
+ &mut out,
+ "License Changed",
+ cs.license_changed.len(),
+ "badge-purple",
+ |o| {
+ o.push_str("| Name | Old License | New License |
");
+ for (old, new) in &cs.license_changed {
+ let _ = write!(
+ o,
+ "| {} | {} | {} |
",
+ esc(&old.name),
+ esc(&old.licenses.join(", ")),
+ esc(&new.licenses.join(", "))
+ );
+ }
+ o.push_str("
");
+ },
+ );
+
+ // Vulnerabilities
+ section(
+ &mut out,
+ "Vulnerabilities",
+ total_vulns,
+ "badge-critical",
+ |o| {
+ o.push_str(
+ "| Component | ID | Severity | Aliases |
",
+ );
+ 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("
");
+ },
+ );
+
+ // Typosquats
+ section(
+ &mut out,
+ "Typosquat Candidates",
+ enrichment.typosquats.len(),
+ "badge-critical",
+ |o| {
+ o.push_str("| Component | Similar To | Score |
");
+ for t in &enrichment.typosquats {
+ let _ = write!(
+ o,
+ "| {} | {} | {:.2} |
",
+ esc(&t.component.name),
+ esc(&t.closest),
+ t.score
+ );
+ }
+ o.push_str("
");
+ },
+ );
+
+ // Version Jumps
+ section(
+ &mut out,
+ "Version Jumps",
+ enrichment.version_jumps.len(),
+ "badge-high",
+ |o| {
+ o.push_str("| Name | Old | New | Old Major | New Major |
");
+ 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("
");
+ },
+ );
+
+ // Maintainer Age
+ section(
+ &mut out,
+ "Maintainer Age Warnings",
+ enrichment.maintainer_age.len(),
+ "badge-high",
+ |o| {
+ o.push_str("| Component | Top Contributor | Days Old | First Commit |
");
+ 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("
");
+ },
+ );
+
+ // License Violations
+ section(
+ &mut out,
+ "License Violations",
+ enrichment.license_violations.len(),
+ "badge-red",
+ |o| {
+ o.push_str("| Component | License | Matched Rule | Kind |
");
+ 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("
");
+ },
+ );
+
+ // Recently Published
+ section(
+ &mut out,
+ "Recently Published",
+ enrichment.recently_published.len(),
+ "badge-high",
+ |o| {
+ o.push_str("| Component | Version | Published | Days Old |
");
+ 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("
");
+ },
+ );
+
+ // Deprecated
+ section(
+ &mut out,
+ "Deprecated Packages",
+ enrichment.deprecated.len(),
+ "badge-red",
+ |o| {
+ o.push_str("| Component | Version | Message |
");
+ 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("
");
+ },
+ );
+
+ // Maintainer Set Changed
+ section(
+ &mut out,
+ "Maintainer Set Changed",
+ enrichment.maintainer_set_changed.len(),
+ "badge-high",
+ |o| {
+ o.push_str("| Package | Old Version | New Version | Added | Removed |
");
+ 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("
");
+ },
+ );
+
+ // Plugin Findings
+ section(
+ &mut out,
+ "Plugin Findings",
+ enrichment.plugin_findings.len(),
+ "badge-purple",
+ |o| {
+ o.push_str("| Plugin | Component PURL | Severity | Message |
");
+ 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("
");
+ },
+ );
+
+ // 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 {