use super::escape::{escape_html, escape_html_opt};
use super::{ReportConfig, ReportError, ReportFormat, ReportGenerator, ReportType};
use crate::diff::{DiffResult, SlaStatus, VulnerabilityDetail};
use crate::model::NormalizedSbom;
use crate::quality::{ComplianceChecker, ComplianceLevel, ComplianceResult, ViolationSeverity};
use std::fmt::Write;
pub struct HtmlReporter {
include_styles: bool,
}
impl HtmlReporter {
#[must_use]
pub const fn new() -> Self {
Self {
include_styles: true,
}
}
}
impl Default for HtmlReporter {
fn default() -> Self {
Self::new()
}
}
fn write_html_head(html: &mut String, title: &str, include_styles: bool) -> std::fmt::Result {
writeln!(html, "<!DOCTYPE html>")?;
writeln!(html, "<html lang=\"en\">")?;
writeln!(html, "<head>")?;
writeln!(html, " <meta charset=\"UTF-8\">")?;
writeln!(
html,
" <meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\">"
)?;
writeln!(html, " <title>{}</title>", escape_html(title))?;
if include_styles {
writeln!(html, "{HTML_STYLES}")?;
}
writeln!(html, "</head>")?;
writeln!(html, "<body>")?;
writeln!(html, "<div class=\"container\">")
}
fn write_page_header(html: &mut String, title: &str, subtitle: Option<&str>) -> std::fmt::Result {
writeln!(html, "<div class=\"header\" id=\"top\">")?;
writeln!(html, " <h1>{}</h1>", escape_html(title))?;
if let Some(sub) = subtitle {
writeln!(html, " <p>{}</p>", escape_html(sub))?;
}
writeln!(
html,
" <p>Generated by sbom-tools v{} on {}</p>",
env!("CARGO_PKG_VERSION"),
chrono::Utc::now().format("%Y-%m-%d %H:%M:%S UTC")
)?;
writeln!(html, "</div>")
}
fn write_toc(html: &mut String, sections: &[(&str, &str)]) -> std::fmt::Result {
writeln!(html, "<nav class=\"toc\">")?;
writeln!(html, " <strong>Contents:</strong>")?;
for (id, label) in sections {
write!(html, " <a href=\"#{id}\">{label}</a>")?;
}
writeln!(html)?;
writeln!(html, "</nav>")
}
fn write_card(html: &mut String, title: &str, value: &str, css_class: &str) -> std::fmt::Result {
writeln!(html, " <div class=\"card\">")?;
writeln!(html, " <div class=\"card-title\">{title}</div>")?;
writeln!(
html,
" <div class=\"card-value {css_class}\">{value}</div>"
)?;
writeln!(html, " </div>")
}
fn write_html_footer(html: &mut String) -> std::fmt::Result {
writeln!(html, "<div class=\"footer\">")?;
writeln!(
html,
" <p>Generated by <a href=\"https://github.com/binarly-io/sbom-tools\">sbom-tools</a></p>"
)?;
writeln!(html, "</div>")?;
writeln!(html, "</div>")?;
writeln!(html, "</body>")?;
writeln!(html, "</html>")
}
fn write_eol_section(html: &mut String, sbom: &NormalizedSbom) -> std::fmt::Result {
use crate::model::EolStatus;
let eol_components: Vec<_> = sbom
.components
.values()
.filter(|c| {
c.eol.as_ref().is_some_and(|e| {
matches!(
e.status,
EolStatus::EndOfLife | EolStatus::ApproachingEol | EolStatus::SecurityOnly
)
})
})
.collect();
if eol_components.is_empty() {
return Ok(());
}
writeln!(html, "<div class=\"section\" id=\"eol\">")?;
writeln!(html, " <h2>End-of-Life Components</h2>")?;
writeln!(html, " <table>")?;
writeln!(html, " <thead>")?;
writeln!(
html,
" <tr><th>Component</th><th>Version</th><th>Status</th><th>Product</th><th>EOL Date</th></tr>"
)?;
writeln!(html, " </thead>")?;
writeln!(html, " <tbody>")?;
for comp in &eol_components {
let eol = comp.eol.as_ref().expect("filtered to eol.is_some()");
let badge_class = match eol.status {
EolStatus::EndOfLife => "badge-critical",
EolStatus::ApproachingEol => "badge-warning",
EolStatus::SecurityOnly => "badge-info",
_ => "",
};
let eol_date = eol
.eol_date
.map_or_else(|| "-".to_string(), |d| d.to_string());
writeln!(
html,
" <tr><td>{}</td><td>{}</td><td><span class=\"badge {}\">{}</span></td><td>{}</td><td>{}</td></tr>",
escape_html(&comp.name),
escape_html(comp.version.as_deref().unwrap_or("-")),
badge_class,
escape_html(eol.status.label()),
escape_html(&eol.product),
escape_html(&eol_date),
)?;
}
writeln!(html, " </tbody>")?;
writeln!(html, " </table>")?;
writeln!(html, "</div>")
}
fn write_diff_component_table(html: &mut String, result: &DiffResult) -> std::fmt::Result {
writeln!(html, "<div class=\"section\" id=\"component-changes\">")?;
writeln!(html, " <h2>Component Changes</h2>")?;
writeln!(html, " <table>")?;
writeln!(html, " <thead>")?;
writeln!(html, " <tr>")?;
writeln!(html, " <th>Status</th>")?;
writeln!(html, " <th>Name</th>")?;
writeln!(html, " <th>Old Version</th>")?;
writeln!(html, " <th>New Version</th>")?;
writeln!(html, " <th>Ecosystem</th>")?;
writeln!(html, " </tr>")?;
writeln!(html, " </thead>")?;
writeln!(html, " <tbody>")?;
for comp in &result.components.added {
writeln!(html, " <tr>")?;
writeln!(
html,
" <td><span class=\"badge badge-added\">Added</span></td>"
)?;
writeln!(html, " <td>{}</td>", escape_html(&comp.name))?;
writeln!(html, " <td>-</td>")?;
writeln!(
html,
" <td>{}</td>",
escape_html_opt(comp.new_version.as_deref())
)?;
writeln!(
html,
" <td>{}</td>",
escape_html_opt(comp.ecosystem.as_deref())
)?;
writeln!(html, " </tr>")?;
}
for comp in &result.components.removed {
writeln!(html, " <tr>")?;
writeln!(
html,
" <td><span class=\"badge badge-removed\">Removed</span></td>"
)?;
writeln!(html, " <td>{}</td>", escape_html(&comp.name))?;
writeln!(
html,
" <td>{}</td>",
escape_html_opt(comp.old_version.as_deref())
)?;
writeln!(html, " <td>-</td>")?;
writeln!(
html,
" <td>{}</td>",
escape_html_opt(comp.ecosystem.as_deref())
)?;
writeln!(html, " </tr>")?;
}
for comp in &result.components.modified {
writeln!(html, " <tr>")?;
writeln!(
html,
" <td><span class=\"badge badge-modified\">Modified</span></td>"
)?;
writeln!(html, " <td>{}</td>", escape_html(&comp.name))?;
writeln!(
html,
" <td>{}</td>",
escape_html_opt(comp.old_version.as_deref())
)?;
writeln!(
html,
" <td>{}</td>",
escape_html_opt(comp.new_version.as_deref())
)?;
writeln!(
html,
" <td>{}</td>",
escape_html_opt(comp.ecosystem.as_deref())
)?;
writeln!(html, " </tr>")?;
}
writeln!(html, " </tbody>")?;
writeln!(html, " </table>")?;
writeln!(html, "</div>")
}
fn write_diff_vuln_table(html: &mut String, result: &DiffResult) -> std::fmt::Result {
writeln!(html, "<div class=\"section\" id=\"vulnerabilities\">")?;
writeln!(html, " <h2>Introduced Vulnerabilities</h2>")?;
writeln!(html, " <table>")?;
writeln!(html, " <thead>")?;
writeln!(html, " <tr>")?;
writeln!(html, " <th>ID</th>")?;
writeln!(html, " <th>Severity</th>")?;
writeln!(html, " <th>CVSS</th>")?;
writeln!(html, " <th>SLA</th>")?;
writeln!(html, " <th>Type</th>")?;
writeln!(html, " <th>Component</th>")?;
writeln!(html, " <th>Version</th>")?;
writeln!(html, " <th>VEX</th>")?;
writeln!(html, " </tr>")?;
writeln!(html, " </thead>")?;
writeln!(html, " <tbody>")?;
for vuln in &result.vulnerabilities.introduced {
let badge_class = match vuln.severity.to_lowercase().as_str() {
"critical" => "badge-critical",
"high" => "badge-high",
"medium" => "badge-medium",
_ => "badge-low",
};
let (depth_label, depth_class) = match vuln.component_depth {
Some(1) => ("Direct", "badge-direct"),
Some(_) => ("Transitive", "badge-transitive"),
None => ("-", ""),
};
writeln!(html, " <tr>")?;
writeln!(html, " <td>{}</td>", escape_html(&vuln.id))?;
writeln!(
html,
" <td><span class=\"badge {}\">{}</span></td>",
badge_class,
escape_html(&vuln.severity)
)?;
writeln!(
html,
" <td>{}</td>",
vuln.cvss_score
.map(|s| format!("{s:.1}"))
.as_deref()
.unwrap_or("-")
)?;
let (sla_text, sla_class) = format_sla_html(vuln);
if sla_class.is_empty() {
writeln!(html, " <td>{sla_text}</td>")?;
} else {
writeln!(
html,
" <td><span class=\"{sla_class}\">{sla_text}</span></td>"
)?;
}
if depth_class.is_empty() {
writeln!(html, " <td>{depth_label}</td>")?;
} else {
writeln!(
html,
" <td><span class=\"badge {depth_class}\">{depth_label}</span></td>"
)?;
}
writeln!(
html,
" <td>{}</td>",
escape_html(&vuln.component_name)
)?;
writeln!(
html,
" <td>{}</td>",
escape_html_opt(vuln.version.as_deref())
)?;
let vex_display = format_vex_html(vuln.vex_state.as_ref());
writeln!(html, " <td>{vex_display}</td>")?;
writeln!(html, " </tr>")?;
}
writeln!(html, " </tbody>")?;
writeln!(html, " </table>")?;
writeln!(html, "</div>")
}
fn write_view_component_table(html: &mut String, sbom: &NormalizedSbom) -> std::fmt::Result {
writeln!(html, "<div class=\"section\" id=\"components\">")?;
writeln!(html, " <h2>Components</h2>")?;
writeln!(html, " <table>")?;
writeln!(html, " <thead>")?;
writeln!(html, " <tr>")?;
writeln!(html, " <th>Name</th>")?;
writeln!(html, " <th>Version</th>")?;
writeln!(html, " <th>Ecosystem</th>")?;
writeln!(html, " <th>License</th>")?;
writeln!(html, " <th>Vulnerabilities</th>")?;
writeln!(html, " </tr>")?;
writeln!(html, " </thead>")?;
writeln!(html, " <tbody>")?;
let mut components: Vec<_> = sbom.components.values().collect();
components.sort_by(|a, b| a.name.cmp(&b.name));
for comp in components {
let license_str = comp
.licenses
.declared
.first()
.map_or("-", |l| l.expression.as_str());
let vuln_count = comp.vulnerabilities.len();
let vuln_badge = if vuln_count > 0 {
format!("<span class=\"badge badge-critical\">{vuln_count}</span>")
} else {
"0".to_string()
};
writeln!(html, " <tr>")?;
writeln!(html, " <td>{}</td>", escape_html(&comp.name))?;
writeln!(
html,
" <td>{}</td>",
escape_html_opt(comp.version.as_deref())
)?;
writeln!(
html,
" <td>{}</td>",
comp.ecosystem
.as_ref()
.map(|e| escape_html(&format!("{e:?}")))
.as_deref()
.unwrap_or("-")
)?;
writeln!(
html,
" <td>{}</td>",
escape_html(license_str)
)?;
writeln!(html, " <td>{vuln_badge}</td>")?;
writeln!(html, " </tr>")?;
}
writeln!(html, " </tbody>")?;
writeln!(html, " </table>")?;
writeln!(html, "</div>")
}
type ViewVulnRow<'a> = (
&'a str,
&'a Option<crate::model::Severity>,
Option<f32>,
&'a str,
Option<&'a str>,
Option<&'a crate::model::VulnerabilityRef>,
);
fn write_view_vuln_table(html: &mut String, sbom: &NormalizedSbom) -> std::fmt::Result {
writeln!(html, "<div class=\"section\" id=\"vulnerabilities\">")?;
writeln!(html, " <h2>Vulnerabilities</h2>")?;
writeln!(html, " <table>")?;
writeln!(html, " <thead>")?;
writeln!(html, " <tr>")?;
writeln!(html, " <th>ID</th>")?;
writeln!(html, " <th>Severity</th>")?;
writeln!(html, " <th>CVSS</th>")?;
writeln!(html, " <th>SLA</th>")?;
writeln!(html, " <th>Component</th>")?;
writeln!(html, " <th>Version</th>")?;
writeln!(html, " <th>VEX</th>")?;
writeln!(html, " </tr>")?;
writeln!(html, " </thead>")?;
writeln!(html, " <tbody>")?;
let mut all_vulns: Vec<ViewVulnRow<'_>> = sbom
.components
.values()
.flat_map(|comp| {
comp.vulnerabilities.iter().map(move |v| {
(
v.id.as_str(),
&v.severity,
v.cvss.first().map(|c| c.base_score),
comp.name.as_str(),
comp.version.as_deref(),
Some(v),
)
})
})
.collect();
all_vulns.sort_by(|a, b| {
let sev_order = |s: &Option<crate::model::Severity>| match s {
Some(crate::model::Severity::Critical) => 0,
Some(crate::model::Severity::High) => 1,
Some(crate::model::Severity::Medium) => 2,
Some(crate::model::Severity::Low) => 3,
Some(crate::model::Severity::Info) => 4,
_ => 5,
};
sev_order(a.1).cmp(&sev_order(b.1))
});
for &(id, severity, cvss, comp_name, version, vuln) in &all_vulns {
let (badge_class, sev_str) = match severity {
Some(crate::model::Severity::Critical) => ("badge-critical", "Critical"),
Some(crate::model::Severity::High) => ("badge-high", "High"),
Some(crate::model::Severity::Medium) => ("badge-medium", "Medium"),
Some(crate::model::Severity::Low) => ("badge-low", "Low"),
Some(crate::model::Severity::Info) => ("badge-low", "Info"),
_ => ("badge-low", "Unknown"),
};
let (sla_text, sla_class) = if let Some(v) = vuln {
compute_view_sla(v)
} else {
("-".to_string(), "sla-unknown")
};
writeln!(html, " <tr>")?;
writeln!(html, " <td>{}</td>", escape_html(id))?;
writeln!(
html,
" <td><span class=\"badge {badge_class}\">{sev_str}</span></td>"
)?;
writeln!(
html,
" <td>{}</td>",
cvss.map(|s| format!("{s:.1}")).as_deref().unwrap_or("-")
)?;
if sla_class.is_empty() {
writeln!(html, " <td>{sla_text}</td>")?;
} else {
writeln!(
html,
" <td><span class=\"{sla_class}\">{sla_text}</span></td>"
)?;
}
writeln!(html, " <td>{}</td>", escape_html(comp_name))?;
writeln!(
html,
" <td>{}</td>",
escape_html_opt(version)
)?;
let vex_state = vuln.and_then(|v| v.vex_status.as_ref().map(|vs| &vs.status));
let vex_display = format_vex_html(vex_state);
writeln!(html, " <td>{vex_display}</td>")?;
writeln!(html, " </tr>")?;
}
writeln!(html, " </tbody>")?;
writeln!(html, " </table>")?;
writeln!(html, "</div>")
}
fn compute_view_sla(vuln: &crate::model::VulnerabilityRef) -> (String, &'static str) {
if let Some(published) = vuln.published {
let delta: chrono::TimeDelta = chrono::Utc::now() - published;
let days = delta.num_days();
if days < 0 {
return ("-".to_string(), "sla-unknown");
}
let days = days as u64;
let sla_days: Option<u64> = match &vuln.severity {
Some(crate::model::Severity::Critical) => Some(15),
Some(crate::model::Severity::High) => Some(30),
Some(crate::model::Severity::Medium) => Some(90),
Some(crate::model::Severity::Low) => Some(180),
_ => None,
};
if let Some(sla) = sla_days {
if days > sla {
(format!("{}d late", days - sla), "sla-overdue")
} else if sla - days <= 7 {
(format!("{}d left", sla - days), "sla-due-soon")
} else {
(format!("{}d left", sla - days), "sla-on-track")
}
} else {
(format!("{days}d old"), "sla-unknown")
}
} else {
("-".to_string(), "sla-unknown")
}
}
fn format_sla_html(vuln: &VulnerabilityDetail) -> (String, &'static str) {
match vuln.sla_status() {
SlaStatus::Overdue(days) => (format!("{days}d late"), "sla-overdue"),
SlaStatus::DueSoon(days) => (format!("{days}d left"), "sla-due-soon"),
SlaStatus::OnTrack(days) => (format!("{days}d left"), "sla-on-track"),
SlaStatus::NoDueDate => {
let text = vuln
.days_since_published
.map_or_else(|| "-".to_string(), |d| format!("{d}d old"));
(text, "sla-unknown")
}
}
}
fn format_vex_html(vex_state: Option<&crate::model::VexState>) -> String {
match vex_state {
Some(crate::model::VexState::NotAffected) => {
"<span class=\"badge badge-added\">Not Affected</span>".to_string()
}
Some(crate::model::VexState::Fixed) => {
"<span class=\"badge badge-added\">Fixed</span>".to_string()
}
Some(crate::model::VexState::Affected) => {
"<span class=\"badge badge-removed\">Affected</span>".to_string()
}
Some(crate::model::VexState::UnderInvestigation) => {
"<span class=\"badge badge-medium\">Under Investigation</span>".to_string()
}
None => "-".to_string(),
}
}
fn compliance_score_html(result: &ComplianceResult) -> u8 {
let total = result.violations.len() + 1;
let issues = result.error_count + result.warning_count;
let score = if issues >= total {
0
} else {
((total - issues) * 100) / total
};
score.min(100) as u8
}
fn trend_badge(old_val: usize, new_val: usize, lower_is_better: bool) -> &'static str {
if old_val == new_val {
""
} else if (new_val < old_val) == lower_is_better {
" <span class=\"badge badge-added\">improved</span>"
} else {
" <span class=\"badge badge-removed\">regressed</span>"
}
}
fn write_cra_compliance_diff_html(
html: &mut String,
old: &ComplianceResult,
new: &ComplianceResult,
) -> std::fmt::Result {
writeln!(html, "<div class=\"section\" id=\"cra-compliance\">")?;
writeln!(html, " <h2>CRA Compliance</h2>")?;
writeln!(html, " <table>")?;
writeln!(html, " <thead>")?;
writeln!(
html,
" <tr><th></th><th>Old SBOM</th><th>New SBOM</th><th>Trend</th></tr>"
)?;
writeln!(html, " </thead>")?;
writeln!(html, " <tbody>")?;
let old_badge = compliance_status_badge(old.is_compliant);
let new_badge = compliance_status_badge(new.is_compliant);
let old_score = compliance_score_html(old);
let new_score = compliance_score_html(new);
let err_trend = trend_badge(old.error_count, new.error_count, true);
let warn_trend = trend_badge(old.warning_count, new.warning_count, true);
let score_trend = trend_badge(old_score.into(), new_score.into(), false);
writeln!(
html,
" <tr><td><strong>Status</strong></td><td>{old_badge}</td><td>{new_badge}</td><td></td></tr>"
)?;
writeln!(
html,
" <tr><td><strong>Score</strong></td><td>{old_score}%</td><td>{new_score}%</td><td>{score_trend}</td></tr>"
)?;
writeln!(
html,
" <tr><td><strong>Level</strong></td><td>{}</td><td>{}</td><td></td></tr>",
escape_html(old.level.name()),
escape_html(new.level.name())
)?;
writeln!(
html,
" <tr><td><strong>Errors</strong></td><td>{}</td><td>{}</td><td>{err_trend}</td></tr>",
old.error_count, new.error_count
)?;
writeln!(
html,
" <tr><td><strong>Warnings</strong></td><td>{}</td><td>{}</td><td>{warn_trend}</td></tr>",
old.warning_count, new.warning_count
)?;
writeln!(html, " </tbody>")?;
writeln!(html, " </table>")?;
if !new.violations.is_empty() {
writeln!(html, " <h3>Violations (New SBOM)</h3>")?;
write_violation_table_html(html, &new.violations)?;
}
writeln!(html, "</div>")?;
writeln!(
html,
"<a href=\"#top\" class=\"back-to-top\">Back to top</a>"
)
}
fn write_cra_compliance_view_html(
html: &mut String,
result: &ComplianceResult,
) -> std::fmt::Result {
writeln!(html, "<div class=\"section\" id=\"cra-compliance\">")?;
writeln!(html, " <h2>CRA Compliance</h2>")?;
let badge = compliance_status_badge(result.is_compliant);
let score = compliance_score_html(result);
writeln!(html, " <p><strong>Status:</strong> {badge} ")?;
writeln!(html, " <strong>Score:</strong> {score}% ")?;
writeln!(
html,
" <strong>Level:</strong> {} ",
escape_html(result.level.name())
)?;
writeln!(
html,
" <strong>Issues:</strong> {} errors, {} warnings</p>",
result.error_count, result.warning_count
)?;
if !result.violations.is_empty() {
write_violation_table_html(html, &result.violations)?;
}
writeln!(html, "</div>")?;
writeln!(
html,
"<a href=\"#top\" class=\"back-to-top\">Back to top</a>"
)
}
fn aggregate_violations_html(
violations: &[crate::quality::Violation],
) -> Vec<AggregatedViolationHtml<'_>> {
use std::collections::BTreeMap;
let mut groups: BTreeMap<(u8, &str, &str), Vec<&crate::quality::Violation>> = BTreeMap::new();
for v in violations {
let sev_ord = match v.severity {
ViolationSeverity::Error => 0,
ViolationSeverity::Warning => 1,
ViolationSeverity::Info => 2,
};
groups
.entry((sev_ord, v.category.name(), v.requirement.as_str()))
.or_default()
.push(v);
}
groups
.into_values()
.map(|group| {
let message = if group.len() == 1 {
group[0].message.clone()
} else {
let elements: Vec<&str> =
group.iter().filter_map(|v| v.element.as_deref()).collect();
if elements.is_empty() {
group[0].message.clone()
} else {
let preview: Vec<&str> = elements.iter().take(5).copied().collect();
let suffix = if elements.len() > 5 {
format!(", ... +{} more", elements.len() - 5)
} else {
String::new()
};
format!(
"{} components affected ({}{})",
elements.len(),
preview.join(", "),
suffix
)
}
};
AggregatedViolationHtml {
severity: group[0].severity,
category: group[0].category.name(),
requirement: &group[0].requirement,
message,
remediation: group[0].remediation_guidance(),
count: group.len(),
}
})
.collect()
}
struct AggregatedViolationHtml<'a> {
severity: ViolationSeverity,
category: &'a str,
requirement: &'a str,
message: String,
remediation: &'static str,
count: usize,
}
fn write_violation_table_html(
html: &mut String,
violations: &[crate::quality::Violation],
) -> std::fmt::Result {
let aggregated = aggregate_violations_html(violations);
writeln!(html, " <table>")?;
writeln!(html, " <thead>")?;
writeln!(html, " <tr>")?;
writeln!(html, " <th>Severity</th>")?;
writeln!(html, " <th>Category</th>")?;
writeln!(html, " <th>Requirement</th>")?;
writeln!(html, " <th>Message</th>")?;
writeln!(html, " <th>Remediation</th>")?;
writeln!(html, " </tr>")?;
writeln!(html, " </thead>")?;
writeln!(html, " <tbody>")?;
for v in &aggregated {
let (badge_class, label) = match v.severity {
ViolationSeverity::Error => ("badge-critical", "Error"),
ViolationSeverity::Warning => ("badge-medium", "Warning"),
ViolationSeverity::Info => ("badge-low", "Info"),
};
let count_suffix = if v.count > 1 {
format!(
" <span class=\"badge badge-transitive\">x{}</span>",
v.count
)
} else {
String::new()
};
writeln!(html, " <tr>")?;
writeln!(
html,
" <td><span class=\"badge {badge_class}\">{label}</span>{count_suffix}</td>"
)?;
writeln!(html, " <td>{}</td>", escape_html(v.category))?;
writeln!(
html,
" <td>{}</td>",
escape_html(v.requirement)
)?;
writeln!(html, " <td>{}</td>", escape_html(&v.message))?;
writeln!(
html,
" <td><details><summary>View</summary>{}</details></td>",
escape_html(v.remediation)
)?;
writeln!(html, " </tr>")?;
}
writeln!(html, " </tbody>")?;
writeln!(html, " </table>")
}
fn compliance_status_badge(is_compliant: bool) -> &'static str {
if is_compliant {
"<span class=\"badge badge-added\">Compliant</span>"
} else {
"<span class=\"badge badge-removed\">Non-compliant</span>"
}
}
impl ReportGenerator for HtmlReporter {
fn generate_diff_report(
&self,
result: &DiffResult,
old_sbom: &NormalizedSbom,
new_sbom: &NormalizedSbom,
config: &ReportConfig,
) -> Result<String, ReportError> {
let mut html = String::new();
let title = config
.title
.clone()
.unwrap_or_else(|| "SBOM Diff Report".to_string());
write_html_head(&mut html, &title, self.include_styles)?;
write_page_header(&mut html, &title, None)?;
let has_components =
config.includes(ReportType::Components) && !result.components.is_empty();
let has_vulns = config.includes(ReportType::Vulnerabilities)
&& !result.vulnerabilities.introduced.is_empty();
let mut toc_entries: Vec<(&str, &str)> = Vec::new();
if has_components {
toc_entries.push(("component-changes", "Components"));
}
if has_vulns {
toc_entries.push(("vulnerabilities", "Vulnerabilities"));
}
toc_entries.push(("cra-compliance", "CRA Compliance"));
write_toc(&mut html, &toc_entries)?;
writeln!(html, "<div class=\"summary-cards\">")?;
write_card(
&mut html,
"Components Added",
&format!("+{}", result.summary.components_added),
"added",
)?;
write_card(
&mut html,
"Components Removed",
&format!("-{}", result.summary.components_removed),
"removed",
)?;
write_card(
&mut html,
"Components Modified",
&format!("~{}", result.summary.components_modified),
"modified",
)?;
write_card(
&mut html,
"Vulns Introduced",
&result.summary.vulnerabilities_introduced.to_string(),
"critical",
)?;
write_card(
&mut html,
"Semantic Score",
&format!("{:.1}", result.semantic_score),
"",
)?;
writeln!(html, "</div>")?;
if has_components {
write_diff_component_table(&mut html, result)?;
}
if has_vulns {
write_diff_vuln_table(&mut html, result)?;
}
write_eol_section(&mut html, new_sbom)?;
{
let old_cra = config.old_cra_compliance.clone().unwrap_or_else(|| {
ComplianceChecker::new(ComplianceLevel::CraPhase2).check(old_sbom)
});
let new_cra = config.new_cra_compliance.clone().unwrap_or_else(|| {
ComplianceChecker::new(ComplianceLevel::CraPhase2).check(new_sbom)
});
write_cra_compliance_diff_html(&mut html, &old_cra, &new_cra)?;
}
write_html_footer(&mut html)?;
Ok(html)
}
fn generate_view_report(
&self,
sbom: &NormalizedSbom,
config: &ReportConfig,
) -> Result<String, ReportError> {
use std::collections::HashSet;
let mut html = String::new();
let title = config
.title
.clone()
.unwrap_or_else(|| "SBOM Report".to_string());
let total_components = sbom.component_count();
let vuln_component_count = sbom
.components
.values()
.filter(|c| !c.vulnerabilities.is_empty())
.count();
let total_vulns: usize = sbom
.components
.values()
.map(|c| c.vulnerabilities.len())
.sum();
let ecosystems: HashSet<_> = sbom
.components
.values()
.filter_map(|c| c.ecosystem.as_ref())
.collect();
let licenses: HashSet<String> = sbom
.components
.values()
.flat_map(|c| c.licenses.declared.iter().map(|l| l.expression.clone()))
.collect();
let subtitle = sbom
.document
.name
.as_deref()
.map(|n| format!("Document: {n}"));
write_html_head(&mut html, &title, self.include_styles)?;
write_page_header(&mut html, &title, subtitle.as_deref())?;
let has_components = config.includes(ReportType::Components) && total_components > 0;
let has_vulns = config.includes(ReportType::Vulnerabilities) && total_vulns > 0;
let mut toc_entries: Vec<(&str, &str)> = Vec::new();
if has_components {
toc_entries.push(("components", "Components"));
}
if has_vulns {
toc_entries.push(("vulnerabilities", "Vulnerabilities"));
}
toc_entries.push(("cra-compliance", "CRA Compliance"));
write_toc(&mut html, &toc_entries)?;
writeln!(html, "<div class=\"summary-cards\">")?;
write_card(
&mut html,
"Total Components",
&total_components.to_string(),
"",
)?;
let vuln_class = if vuln_component_count > 0 {
"critical"
} else {
""
};
write_card(
&mut html,
"Vulnerable Components",
&vuln_component_count.to_string(),
vuln_class,
)?;
let total_vuln_class = if total_vulns > 0 { "critical" } else { "" };
write_card(
&mut html,
"Total Vulnerabilities",
&total_vulns.to_string(),
total_vuln_class,
)?;
write_card(&mut html, "Ecosystems", &ecosystems.len().to_string(), "")?;
write_card(
&mut html,
"Unique Licenses",
&licenses.len().to_string(),
"",
)?;
writeln!(html, "</div>")?;
if has_components {
write_view_component_table(&mut html, sbom)?;
}
if has_vulns {
write_view_vuln_table(&mut html, sbom)?;
}
{
let cra = config
.view_cra_compliance
.clone()
.unwrap_or_else(|| ComplianceChecker::new(ComplianceLevel::CraPhase2).check(sbom));
write_cra_compliance_view_html(&mut html, &cra)?;
}
write_html_footer(&mut html)?;
Ok(html)
}
fn format(&self) -> ReportFormat {
ReportFormat::Html
}
}
const HTML_STYLES: &str = r"
<style>
:root {
--bg-color: #1e1e2e;
--text-color: #cdd6f4;
--accent-color: #89b4fa;
--success-color: #a6e3a1;
--warning-color: #f9e2af;
--error-color: #f38ba8;
--border-color: #45475a;
--card-bg: #313244;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
background-color: var(--bg-color);
color: var(--text-color);
margin: 0;
padding: 20px;
line-height: 1.6;
}
.container {
max-width: 1200px;
margin: 0 auto;
}
h1, h2, h3 {
color: var(--accent-color);
}
.header {
border-bottom: 2px solid var(--border-color);
padding-bottom: 20px;
margin-bottom: 30px;
}
.summary-cards {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 20px;
margin-bottom: 30px;
}
.card {
background-color: var(--card-bg);
border-radius: 8px;
padding: 20px;
border: 1px solid var(--border-color);
}
.card-title {
font-size: 0.9em;
color: #a6adc8;
margin-bottom: 10px;
}
.card-value {
font-size: 2em;
font-weight: bold;
}
.card-value.added { color: var(--success-color); }
.card-value.removed { color: var(--error-color); }
.card-value.modified { color: var(--warning-color); }
.card-value.critical { color: var(--error-color); }
table {
width: 100%;
border-collapse: collapse;
margin-bottom: 30px;
background-color: var(--card-bg);
border-radius: 8px;
overflow: hidden;
}
th, td {
padding: 12px 15px;
text-align: left;
border-bottom: 1px solid var(--border-color);
}
th {
background-color: #45475a;
font-weight: 600;
}
tr:hover {
background-color: #3b3d4d;
}
.badge {
display: inline-block;
padding: 2px 8px;
border-radius: 4px;
font-size: 0.85em;
font-weight: 500;
}
.badge-added { background-color: rgba(166, 227, 161, 0.2); color: var(--success-color); }
.badge-removed { background-color: rgba(243, 139, 168, 0.2); color: var(--error-color); }
.badge-modified { background-color: rgba(249, 226, 175, 0.2); color: var(--warning-color); }
.badge-critical { background-color: rgba(243, 139, 168, 0.3); color: var(--error-color); }
.badge-high { background-color: rgba(250, 179, 135, 0.3); color: #fab387; }
.badge-medium { background-color: rgba(249, 226, 175, 0.3); color: var(--warning-color); }
.badge-low { background-color: rgba(148, 226, 213, 0.3); color: #94e2d5; }
.badge-direct { background-color: rgba(46, 160, 67, 0.3); color: #2ea043; }
.badge-transitive { background-color: rgba(110, 118, 129, 0.3); color: #6e7681; }
.sla-overdue { background-color: rgba(248, 81, 73, 0.2); color: #f85149; font-weight: bold; }
.sla-due-soon { background-color: rgba(227, 179, 65, 0.2); color: #e3b341; }
.sla-on-track { color: #8b949e; }
.sla-unknown { color: #8b949e; }
.section {
margin-bottom: 40px;
}
.tabs {
display: flex;
border-bottom: 2px solid var(--border-color);
margin-bottom: 20px;
}
.tab {
padding: 10px 20px;
cursor: pointer;
border-bottom: 2px solid transparent;
margin-bottom: -2px;
}
.tab:hover {
color: var(--accent-color);
}
.tab.active {
border-bottom-color: var(--accent-color);
color: var(--accent-color);
}
.footer {
margin-top: 40px;
padding-top: 20px;
border-top: 1px solid var(--border-color);
font-size: 0.9em;
color: #a6adc8;
}
.toc {
background-color: var(--card-bg);
border: 1px solid var(--border-color);
border-radius: 8px;
padding: 12px 20px;
margin-bottom: 30px;
display: flex;
gap: 16px;
align-items: center;
flex-wrap: wrap;
}
.toc a {
color: var(--accent-color);
text-decoration: none;
padding: 4px 8px;
border-radius: 4px;
}
.toc a:hover {
background-color: rgba(137, 180, 250, 0.1);
}
.back-to-top {
display: inline-block;
color: #a6adc8;
text-decoration: none;
font-size: 0.85em;
margin-bottom: 20px;
}
.back-to-top:hover {
color: var(--accent-color);
}
details summary {
cursor: pointer;
color: var(--accent-color);
font-size: 0.85em;
}
details summary:hover {
text-decoration: underline;
}
details[open] summary {
margin-bottom: 6px;
}
</style>
";