1use super::escape::{escape_html, escape_html_opt};
4use super::{ReportConfig, ReportError, ReportFormat, ReportGenerator, ReportType};
5use crate::diff::{DiffResult, SlaStatus, VulnerabilityDetail};
6use crate::model::NormalizedSbom;
7use crate::quality::{ComplianceChecker, ComplianceLevel, ComplianceResult, ViolationSeverity};
8use std::fmt::Write;
9
10pub struct HtmlReporter {
12 include_styles: bool,
14}
15
16impl HtmlReporter {
17 #[must_use]
19 pub const fn new() -> Self {
20 Self {
21 include_styles: true,
22 }
23 }
24}
25
26impl Default for HtmlReporter {
27 fn default() -> Self {
28 Self::new()
29 }
30}
31
32fn write_html_head(html: &mut String, title: &str, include_styles: bool) -> std::fmt::Result {
38 writeln!(html, "<!DOCTYPE html>")?;
39 writeln!(html, "<html lang=\"en\">")?;
40 writeln!(html, "<head>")?;
41 writeln!(html, " <meta charset=\"UTF-8\">")?;
42 writeln!(
43 html,
44 " <meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\">"
45 )?;
46 writeln!(html, " <title>{}</title>", escape_html(title))?;
47 if include_styles {
48 writeln!(html, "{HTML_STYLES}")?;
49 }
50 writeln!(html, "</head>")?;
51 writeln!(html, "<body>")?;
52 writeln!(html, "<div class=\"container\">")
53}
54
55fn write_page_header(html: &mut String, title: &str, subtitle: Option<&str>) -> std::fmt::Result {
57 writeln!(html, "<div class=\"header\" id=\"top\">")?;
58 writeln!(html, " <h1>{}</h1>", escape_html(title))?;
59 if let Some(sub) = subtitle {
60 writeln!(html, " <p>{}</p>", escape_html(sub))?;
61 }
62 writeln!(
63 html,
64 " <p>Generated by sbom-tools v{} on {}</p>",
65 env!("CARGO_PKG_VERSION"),
66 chrono::Utc::now().format("%Y-%m-%d %H:%M:%S UTC")
67 )?;
68 writeln!(html, "</div>")
69}
70
71fn write_toc(html: &mut String, sections: &[(&str, &str)]) -> std::fmt::Result {
73 writeln!(html, "<nav class=\"toc\">")?;
74 writeln!(html, " <strong>Contents:</strong>")?;
75 for (id, label) in sections {
76 write!(html, " <a href=\"#{id}\">{label}</a>")?;
77 }
78 writeln!(html)?;
79 writeln!(html, "</nav>")
80}
81
82fn write_card(html: &mut String, title: &str, value: &str, css_class: &str) -> std::fmt::Result {
84 writeln!(html, " <div class=\"card\">")?;
85 writeln!(html, " <div class=\"card-title\">{title}</div>")?;
86 writeln!(
87 html,
88 " <div class=\"card-value {css_class}\">{value}</div>"
89 )?;
90 writeln!(html, " </div>")
91}
92
93fn write_html_footer(html: &mut String) -> std::fmt::Result {
95 writeln!(html, "<div class=\"footer\">")?;
96 writeln!(
97 html,
98 " <p>Generated by <a href=\"https://github.com/binarly-io/sbom-tools\">sbom-tools</a></p>"
99 )?;
100 writeln!(html, "</div>")?;
101 writeln!(html, "</div>")?;
102 writeln!(html, "</body>")?;
103 writeln!(html, "</html>")
104}
105
106fn write_eol_section(html: &mut String, sbom: &NormalizedSbom) -> std::fmt::Result {
108 use crate::model::EolStatus;
109
110 let eol_components: Vec<_> = sbom
111 .components
112 .values()
113 .filter(|c| {
114 c.eol.as_ref().is_some_and(|e| {
115 matches!(
116 e.status,
117 EolStatus::EndOfLife | EolStatus::ApproachingEol | EolStatus::SecurityOnly
118 )
119 })
120 })
121 .collect();
122
123 if eol_components.is_empty() {
124 return Ok(());
125 }
126
127 writeln!(html, "<div class=\"section\" id=\"eol\">")?;
128 writeln!(html, " <h2>End-of-Life Components</h2>")?;
129 writeln!(html, " <table>")?;
130 writeln!(html, " <thead>")?;
131 writeln!(
132 html,
133 " <tr><th>Component</th><th>Version</th><th>Status</th><th>Product</th><th>EOL Date</th></tr>"
134 )?;
135 writeln!(html, " </thead>")?;
136 writeln!(html, " <tbody>")?;
137
138 for comp in &eol_components {
139 let eol = comp.eol.as_ref().expect("filtered to eol.is_some()");
140 let badge_class = match eol.status {
141 EolStatus::EndOfLife => "badge-critical",
142 EolStatus::ApproachingEol => "badge-warning",
143 EolStatus::SecurityOnly => "badge-info",
144 _ => "",
145 };
146 let eol_date = eol
147 .eol_date
148 .map_or_else(|| "-".to_string(), |d| d.to_string());
149
150 writeln!(
151 html,
152 " <tr><td>{}</td><td>{}</td><td><span class=\"badge {}\">{}</span></td><td>{}</td><td>{}</td></tr>",
153 escape_html(&comp.name),
154 escape_html(comp.version.as_deref().unwrap_or("-")),
155 badge_class,
156 escape_html(eol.status.label()),
157 escape_html(&eol.product),
158 escape_html(&eol_date),
159 )?;
160 }
161
162 writeln!(html, " </tbody>")?;
163 writeln!(html, " </table>")?;
164 writeln!(html, "</div>")
165}
166
167fn write_diff_metadata_table(html: &mut String, result: &DiffResult) -> std::fmt::Result {
169 writeln!(html, "<div class=\"section\" id=\"metadata-changes\">")?;
170 writeln!(html, " <h2>Metadata Changes</h2>")?;
171 writeln!(html, " <table>")?;
172 writeln!(html, " <thead>")?;
173 writeln!(html, " <tr>")?;
174 writeln!(html, " <th>Field</th>")?;
175 writeln!(html, " <th>Old</th>")?;
176 writeln!(html, " <th>New</th>")?;
177 writeln!(html, " </tr>")?;
178 writeln!(html, " </thead>")?;
179 writeln!(html, " <tbody>")?;
180
181 for change in &result.metadata_changes {
182 writeln!(html, " <tr>")?;
183 writeln!(
184 html,
185 " <td>{}</td>",
186 escape_html(&change.field)
187 )?;
188 writeln!(
189 html,
190 " <td>{}</td>",
191 escape_html_opt(change.old_value.as_deref())
192 )?;
193 writeln!(
194 html,
195 " <td>{}</td>",
196 escape_html_opt(change.new_value.as_deref())
197 )?;
198 writeln!(html, " </tr>")?;
199 }
200
201 writeln!(html, " </tbody>")?;
202 writeln!(html, " </table>")?;
203 writeln!(html, "</div>")
204}
205
206fn write_diff_component_table(html: &mut String, result: &DiffResult) -> std::fmt::Result {
208 writeln!(html, "<div class=\"section\" id=\"component-changes\">")?;
209 writeln!(html, " <h2>Component Changes</h2>")?;
210 writeln!(html, " <table>")?;
211 writeln!(html, " <thead>")?;
212 writeln!(html, " <tr>")?;
213 writeln!(html, " <th>Status</th>")?;
214 writeln!(html, " <th>Name</th>")?;
215 writeln!(html, " <th>Old Version</th>")?;
216 writeln!(html, " <th>New Version</th>")?;
217 writeln!(html, " <th>Ecosystem</th>")?;
218 writeln!(html, " </tr>")?;
219 writeln!(html, " </thead>")?;
220 writeln!(html, " <tbody>")?;
221
222 for comp in &result.components.added {
223 writeln!(html, " <tr>")?;
224 writeln!(
225 html,
226 " <td><span class=\"badge badge-added\">Added</span></td>"
227 )?;
228 writeln!(html, " <td>{}</td>", escape_html(&comp.name))?;
229 writeln!(html, " <td>-</td>")?;
230 writeln!(
231 html,
232 " <td>{}</td>",
233 escape_html_opt(comp.new_version.as_deref())
234 )?;
235 writeln!(
236 html,
237 " <td>{}</td>",
238 escape_html_opt(comp.ecosystem.as_deref())
239 )?;
240 writeln!(html, " </tr>")?;
241 }
242
243 for comp in &result.components.removed {
244 writeln!(html, " <tr>")?;
245 writeln!(
246 html,
247 " <td><span class=\"badge badge-removed\">Removed</span></td>"
248 )?;
249 writeln!(html, " <td>{}</td>", escape_html(&comp.name))?;
250 writeln!(
251 html,
252 " <td>{}</td>",
253 escape_html_opt(comp.old_version.as_deref())
254 )?;
255 writeln!(html, " <td>-</td>")?;
256 writeln!(
257 html,
258 " <td>{}</td>",
259 escape_html_opt(comp.ecosystem.as_deref())
260 )?;
261 writeln!(html, " </tr>")?;
262 }
263
264 for comp in &result.components.modified {
265 writeln!(html, " <tr>")?;
266 writeln!(
267 html,
268 " <td><span class=\"badge badge-modified\">Modified</span></td>"
269 )?;
270 writeln!(html, " <td>{}</td>", escape_html(&comp.name))?;
271 writeln!(
272 html,
273 " <td>{}</td>",
274 escape_html_opt(comp.old_version.as_deref())
275 )?;
276 writeln!(
277 html,
278 " <td>{}</td>",
279 escape_html_opt(comp.new_version.as_deref())
280 )?;
281 writeln!(
282 html,
283 " <td>{}</td>",
284 escape_html_opt(comp.ecosystem.as_deref())
285 )?;
286 writeln!(html, " </tr>")?;
287 }
288
289 writeln!(html, " </tbody>")?;
290 writeln!(html, " </table>")?;
291 writeln!(html, "</div>")
292}
293
294fn write_diff_vuln_table(html: &mut String, result: &DiffResult) -> std::fmt::Result {
296 writeln!(html, "<div class=\"section\" id=\"vulnerabilities\">")?;
297 writeln!(html, " <h2>Introduced Vulnerabilities</h2>")?;
298 writeln!(html, " <table>")?;
299 writeln!(html, " <thead>")?;
300 writeln!(html, " <tr>")?;
301 writeln!(html, " <th>ID</th>")?;
302 writeln!(html, " <th>Severity</th>")?;
303 writeln!(html, " <th>CVSS</th>")?;
304 writeln!(html, " <th>SLA</th>")?;
305 writeln!(html, " <th>Type</th>")?;
306 writeln!(html, " <th>Component</th>")?;
307 writeln!(html, " <th>Version</th>")?;
308 writeln!(html, " <th>VEX</th>")?;
309 writeln!(html, " </tr>")?;
310 writeln!(html, " </thead>")?;
311 writeln!(html, " <tbody>")?;
312
313 for vuln in &result.vulnerabilities.introduced {
314 let badge_class = match vuln.severity.to_lowercase().as_str() {
315 "critical" => "badge-critical",
316 "high" => "badge-high",
317 "medium" => "badge-medium",
318 _ => "badge-low",
319 };
320 let (depth_label, depth_class) = match vuln.component_depth {
321 Some(1) => ("Direct", "badge-direct"),
322 Some(_) => ("Transitive", "badge-transitive"),
323 None => ("-", ""),
324 };
325 writeln!(html, " <tr>")?;
326 writeln!(html, " <td>{}</td>", escape_html(&vuln.id))?;
327 writeln!(
328 html,
329 " <td><span class=\"badge {}\">{}</span></td>",
330 badge_class,
331 escape_html(&vuln.severity)
332 )?;
333 writeln!(
334 html,
335 " <td>{}</td>",
336 vuln.cvss_score
337 .map(|s| format!("{s:.1}"))
338 .as_deref()
339 .unwrap_or("-")
340 )?;
341 let (sla_text, sla_class) = format_sla_html(vuln);
343 if sla_class.is_empty() {
344 writeln!(html, " <td>{sla_text}</td>")?;
345 } else {
346 writeln!(
347 html,
348 " <td><span class=\"{sla_class}\">{sla_text}</span></td>"
349 )?;
350 }
351 if depth_class.is_empty() {
352 writeln!(html, " <td>{depth_label}</td>")?;
353 } else {
354 writeln!(
355 html,
356 " <td><span class=\"badge {depth_class}\">{depth_label}</span></td>"
357 )?;
358 }
359 writeln!(
360 html,
361 " <td>{}</td>",
362 escape_html(&vuln.component_name)
363 )?;
364 writeln!(
365 html,
366 " <td>{}</td>",
367 escape_html_opt(vuln.version.as_deref())
368 )?;
369 let vex_display = format_vex_html(vuln.vex_state.as_ref());
371 writeln!(html, " <td>{vex_display}</td>")?;
372 writeln!(html, " </tr>")?;
373 }
374
375 writeln!(html, " </tbody>")?;
376 writeln!(html, " </table>")?;
377 writeln!(html, "</div>")
378}
379
380fn write_view_component_table(html: &mut String, sbom: &NormalizedSbom) -> std::fmt::Result {
382 writeln!(html, "<div class=\"section\" id=\"components\">")?;
383 writeln!(html, " <h2>Components</h2>")?;
384 writeln!(html, " <table>")?;
385 writeln!(html, " <thead>")?;
386 writeln!(html, " <tr>")?;
387 writeln!(html, " <th>Name</th>")?;
388 writeln!(html, " <th>Version</th>")?;
389 writeln!(html, " <th>Ecosystem</th>")?;
390 writeln!(html, " <th>License</th>")?;
391 writeln!(html, " <th>Vulnerabilities</th>")?;
392 writeln!(html, " </tr>")?;
393 writeln!(html, " </thead>")?;
394 writeln!(html, " <tbody>")?;
395
396 let mut components: Vec<_> = sbom.components.values().collect();
398 components.sort_by(|a, b| a.name.cmp(&b.name));
399
400 for comp in components {
401 let license_str = comp
402 .licenses
403 .declared
404 .first()
405 .map_or("-", |l| l.expression.as_str());
406 let vuln_count = comp.vulnerabilities.len();
407 let vuln_badge = if vuln_count > 0 {
408 format!("<span class=\"badge badge-critical\">{vuln_count}</span>")
409 } else {
410 "0".to_string()
411 };
412
413 writeln!(html, " <tr>")?;
414 writeln!(html, " <td>{}</td>", escape_html(&comp.name))?;
415 writeln!(
416 html,
417 " <td>{}</td>",
418 escape_html_opt(comp.version.as_deref())
419 )?;
420 writeln!(
421 html,
422 " <td>{}</td>",
423 comp.ecosystem
424 .as_ref()
425 .map(|e| escape_html(&format!("{e:?}")))
426 .as_deref()
427 .unwrap_or("-")
428 )?;
429 writeln!(
430 html,
431 " <td>{}</td>",
432 escape_html(license_str)
433 )?;
434 writeln!(html, " <td>{vuln_badge}</td>")?;
435 writeln!(html, " </tr>")?;
436 }
437
438 writeln!(html, " </tbody>")?;
439 writeln!(html, " </table>")?;
440 writeln!(html, "</div>")
441}
442
443type ViewVulnRow<'a> = (
445 &'a str,
446 &'a Option<crate::model::Severity>,
447 Option<f32>,
448 &'a str,
449 Option<&'a str>,
450 Option<&'a crate::model::VulnerabilityRef>,
451);
452
453fn write_view_vuln_table(html: &mut String, sbom: &NormalizedSbom) -> std::fmt::Result {
455 writeln!(html, "<div class=\"section\" id=\"vulnerabilities\">")?;
456 writeln!(html, " <h2>Vulnerabilities</h2>")?;
457 writeln!(html, " <table>")?;
458 writeln!(html, " <thead>")?;
459 writeln!(html, " <tr>")?;
460 writeln!(html, " <th>ID</th>")?;
461 writeln!(html, " <th>Severity</th>")?;
462 writeln!(html, " <th>CVSS</th>")?;
463 writeln!(html, " <th>SLA</th>")?;
464 writeln!(html, " <th>Component</th>")?;
465 writeln!(html, " <th>Version</th>")?;
466 writeln!(html, " <th>VEX</th>")?;
467 writeln!(html, " </tr>")?;
468 writeln!(html, " </thead>")?;
469 writeln!(html, " <tbody>")?;
470
471 let mut all_vulns: Vec<ViewVulnRow<'_>> = sbom
473 .components
474 .values()
475 .flat_map(|comp| {
476 comp.vulnerabilities.iter().map(move |v| {
477 (
478 v.id.as_str(),
479 &v.severity,
480 v.cvss.first().map(|c| c.base_score),
481 comp.name.as_str(),
482 comp.version.as_deref(),
483 Some(v),
484 )
485 })
486 })
487 .collect();
488
489 all_vulns.sort_by(|a, b| {
491 let sev_order = |s: &Option<crate::model::Severity>| match s {
492 Some(crate::model::Severity::Critical) => 0,
493 Some(crate::model::Severity::High) => 1,
494 Some(crate::model::Severity::Medium) => 2,
495 Some(crate::model::Severity::Low) => 3,
496 Some(crate::model::Severity::Info) => 4,
497 _ => 5,
498 };
499 sev_order(a.1).cmp(&sev_order(b.1))
500 });
501
502 for &(id, severity, cvss, comp_name, version, vuln) in &all_vulns {
503 let (badge_class, sev_str) = match severity {
504 Some(crate::model::Severity::Critical) => ("badge-critical", "Critical"),
505 Some(crate::model::Severity::High) => ("badge-high", "High"),
506 Some(crate::model::Severity::Medium) => ("badge-medium", "Medium"),
507 Some(crate::model::Severity::Low) => ("badge-low", "Low"),
508 Some(crate::model::Severity::Info) => ("badge-low", "Info"),
509 _ => ("badge-low", "Unknown"),
510 };
511
512 let (sla_text, sla_class) = if let Some(v) = vuln {
514 compute_view_sla(v)
515 } else {
516 ("-".to_string(), "sla-unknown")
517 };
518
519 writeln!(html, " <tr>")?;
520 writeln!(html, " <td>{}</td>", escape_html(id))?;
521 writeln!(
522 html,
523 " <td><span class=\"badge {badge_class}\">{sev_str}</span></td>"
524 )?;
525 writeln!(
526 html,
527 " <td>{}</td>",
528 cvss.map(|s| format!("{s:.1}")).as_deref().unwrap_or("-")
529 )?;
530 if sla_class.is_empty() {
531 writeln!(html, " <td>{sla_text}</td>")?;
532 } else {
533 writeln!(
534 html,
535 " <td><span class=\"{sla_class}\">{sla_text}</span></td>"
536 )?;
537 }
538 writeln!(html, " <td>{}</td>", escape_html(comp_name))?;
539 writeln!(
540 html,
541 " <td>{}</td>",
542 escape_html_opt(version)
543 )?;
544 let vex_state = vuln.and_then(|v| v.vex_status.as_ref().map(|vs| &vs.status));
546 let vex_display = format_vex_html(vex_state);
547 writeln!(html, " <td>{vex_display}</td>")?;
548 writeln!(html, " </tr>")?;
549 }
550
551 writeln!(html, " </tbody>")?;
552 writeln!(html, " </table>")?;
553 writeln!(html, "</div>")
554}
555
556fn compute_view_sla(vuln: &crate::model::VulnerabilityRef) -> (String, &'static str) {
558 if let Some(published) = vuln.published {
559 let delta: chrono::TimeDelta = chrono::Utc::now() - published;
560 let days = delta.num_days();
561 if days < 0 {
562 return ("-".to_string(), "sla-unknown");
563 }
564 let days = days as u64;
565 let sla_days: Option<u64> = match &vuln.severity {
567 Some(crate::model::Severity::Critical) => Some(15),
568 Some(crate::model::Severity::High) => Some(30),
569 Some(crate::model::Severity::Medium) => Some(90),
570 Some(crate::model::Severity::Low) => Some(180),
571 _ => None,
572 };
573 if let Some(sla) = sla_days {
574 if days > sla {
575 (format!("{}d late", days - sla), "sla-overdue")
576 } else if sla - days <= 7 {
577 (format!("{}d left", sla - days), "sla-due-soon")
578 } else {
579 (format!("{}d left", sla - days), "sla-on-track")
580 }
581 } else {
582 (format!("{days}d old"), "sla-unknown")
583 }
584 } else {
585 ("-".to_string(), "sla-unknown")
586 }
587}
588
589fn format_sla_html(vuln: &VulnerabilityDetail) -> (String, &'static str) {
591 match vuln.sla_status() {
592 SlaStatus::Overdue(days) => (format!("{days}d late"), "sla-overdue"),
593 SlaStatus::DueSoon(days) => (format!("{days}d left"), "sla-due-soon"),
594 SlaStatus::OnTrack(days) => (format!("{days}d left"), "sla-on-track"),
595 SlaStatus::NoDueDate => {
596 let text = vuln
597 .days_since_published
598 .map_or_else(|| "-".to_string(), |d| format!("{d}d old"));
599 (text, "sla-unknown")
600 }
601 }
602}
603
604fn format_vex_html(vex_state: Option<&crate::model::VexState>) -> String {
606 match vex_state {
607 Some(crate::model::VexState::NotAffected) => {
608 "<span class=\"badge badge-added\">Not Affected</span>".to_string()
609 }
610 Some(crate::model::VexState::Fixed) => {
611 "<span class=\"badge badge-added\">Fixed</span>".to_string()
612 }
613 Some(crate::model::VexState::Affected) => {
614 "<span class=\"badge badge-removed\">Affected</span>".to_string()
615 }
616 Some(crate::model::VexState::UnderInvestigation) => {
617 "<span class=\"badge badge-medium\">Under Investigation</span>".to_string()
618 }
619 None => "-".to_string(),
620 }
621}
622
623fn compliance_score_html(result: &ComplianceResult) -> u8 {
625 let total = result.violations.len() + 1;
626 let issues = result.error_count + result.warning_count;
627 let score = if issues >= total {
628 0
629 } else {
630 ((total - issues) * 100) / total
631 };
632 score.min(100) as u8
633}
634
635fn trend_badge(old_val: usize, new_val: usize, lower_is_better: bool) -> &'static str {
637 if old_val == new_val {
638 ""
639 } else if (new_val < old_val) == lower_is_better {
640 " <span class=\"badge badge-added\">improved</span>"
641 } else {
642 " <span class=\"badge badge-removed\">regressed</span>"
643 }
644}
645
646fn write_cra_compliance_diff_html(
648 html: &mut String,
649 old: &ComplianceResult,
650 new: &ComplianceResult,
651) -> std::fmt::Result {
652 writeln!(html, "<div class=\"section\" id=\"cra-compliance\">")?;
653 writeln!(html, " <h2>CRA Compliance</h2>")?;
654 writeln!(html, " <table>")?;
655 writeln!(html, " <thead>")?;
656 writeln!(
657 html,
658 " <tr><th></th><th>Old SBOM</th><th>New SBOM</th><th>Trend</th></tr>"
659 )?;
660 writeln!(html, " </thead>")?;
661 writeln!(html, " <tbody>")?;
662
663 let old_badge = compliance_status_badge(old.is_compliant);
664 let new_badge = compliance_status_badge(new.is_compliant);
665 let old_score = compliance_score_html(old);
666 let new_score = compliance_score_html(new);
667 let err_trend = trend_badge(old.error_count, new.error_count, true);
668 let warn_trend = trend_badge(old.warning_count, new.warning_count, true);
669 let score_trend = trend_badge(old_score.into(), new_score.into(), false);
670
671 writeln!(
672 html,
673 " <tr><td><strong>Status</strong></td><td>{old_badge}</td><td>{new_badge}</td><td></td></tr>"
674 )?;
675 writeln!(
676 html,
677 " <tr><td><strong>Score</strong></td><td>{old_score}%</td><td>{new_score}%</td><td>{score_trend}</td></tr>"
678 )?;
679 writeln!(
680 html,
681 " <tr><td><strong>Level</strong></td><td>{}</td><td>{}</td><td></td></tr>",
682 escape_html(old.level.name()),
683 escape_html(new.level.name())
684 )?;
685 writeln!(
686 html,
687 " <tr><td><strong>Errors</strong></td><td>{}</td><td>{}</td><td>{err_trend}</td></tr>",
688 old.error_count, new.error_count
689 )?;
690 writeln!(
691 html,
692 " <tr><td><strong>Warnings</strong></td><td>{}</td><td>{}</td><td>{warn_trend}</td></tr>",
693 old.warning_count, new.warning_count
694 )?;
695
696 writeln!(html, " </tbody>")?;
697 writeln!(html, " </table>")?;
698
699 write_conformity_assessment_html(html, new)?;
700 write_reporting_channels_html(html, new)?;
701
702 write_compact_diff_violation_summary_html(html, new)?;
703
704 writeln!(html, "</div>")?;
705 writeln!(
706 html,
707 "<a href=\"#top\" class=\"back-to-top\">Back to top</a>"
708 )
709}
710
711fn write_compact_diff_violation_summary_html(
712 html: &mut String,
713 result: &ComplianceResult,
714) -> std::fmt::Result {
715 if result.violations.is_empty() {
716 return Ok(());
717 }
718
719 let group_count = count_violation_groups_html(&result.violations);
720 writeln!(html, " <h3>Violation Summary (New SBOM)</h3>")?;
721 writeln!(
722 html,
723 " <p>{} total findings across {group_count} distinct requirement groups.</p>",
724 result.violations.len(),
725 )?;
726 writeln!(
727 html,
728 " <p><em>Re-run with <code>sbom-tools diff ... -o json</code> or <code>-o sarif</code> for the full CRA violation detail.</em></p>"
729 )?;
730
731 Ok(())
732}
733
734fn write_cra_compliance_view_html(
736 html: &mut String,
737 result: &ComplianceResult,
738) -> std::fmt::Result {
739 writeln!(html, "<div class=\"section\" id=\"cra-compliance\">")?;
740 writeln!(html, " <h2>CRA Compliance</h2>")?;
741
742 let badge = compliance_status_badge(result.is_compliant);
743 let score = compliance_score_html(result);
744 writeln!(html, " <p><strong>Status:</strong> {badge} ")?;
745 writeln!(html, " <strong>Score:</strong> {score}% ")?;
746 writeln!(
747 html,
748 " <strong>Level:</strong> {} ",
749 escape_html(result.level.name())
750 )?;
751 writeln!(
752 html,
753 " <strong>Issues:</strong> {} errors, {} warnings</p>",
754 result.error_count, result.warning_count
755 )?;
756
757 write_conformity_assessment_html(html, result)?;
758 write_reporting_channels_html(html, result)?;
759
760 if !result.violations.is_empty() {
761 write_violation_table_html(html, &result.violations)?;
762 }
763
764 writeln!(html, "</div>")?;
765 writeln!(
766 html,
767 "<a href=\"#top\" class=\"back-to-top\">Back to top</a>"
768 )
769}
770
771fn write_conformity_assessment_html(
775 html: &mut String,
776 result: &ComplianceResult,
777) -> std::fmt::Result {
778 let Some(summary) = result.conformity_summary.as_ref() else {
779 return Ok(());
780 };
781 writeln!(html, " <h3>Conformity Assessment (CRA Annex VIII)</h3>")?;
782 writeln!(
783 html,
784 " <p><strong>Product class:</strong> {}<br><strong>Conformity route:</strong> {}</p>",
785 crate::reports::escape::escape_html(summary.product_class.name()),
786 crate::reports::escape::escape_html(summary.route.name())
787 )?;
788 writeln!(html, " <table>")?;
789 writeln!(
790 html,
791 " <thead><tr><th>Evidence</th><th>Status</th><th>Detail</th></tr></thead>"
792 )?;
793 writeln!(html, " <tbody>")?;
794 for ev in &summary.evidence {
795 let status = if ev.satisfied {
796 "<span class=\"present\">Present</span>"
797 } else {
798 "<span class=\"missing\">Missing</span>"
799 };
800 writeln!(
801 html,
802 " <tr><td>{}</td><td>{}</td><td>{}</td></tr>",
803 crate::reports::escape::escape_html(&ev.label),
804 status,
805 crate::reports::escape::escape_html(&ev.detail)
806 )?;
807 }
808 writeln!(html, " </tbody>")?;
809 writeln!(html, " </table>")?;
810 Ok(())
811}
812
813fn write_reporting_channels_html(html: &mut String, result: &ComplianceResult) -> std::fmt::Result {
816 if !result.level.is_cra() {
817 return Ok(());
818 }
819
820 let psirt = channel_status_html(result, "Art. 14: PSIRT");
821 let early = channel_status_html(result, "Art. 14(1)");
822 let incident = channel_status_html(result, "Art. 14(2)");
823 let enisa = channel_status_html(result, "Art. 14(7)");
824
825 writeln!(html, " <h3>Reporting Channels (CRA Art. 14)</h3>")?;
826 writeln!(html, " <table>")?;
827 writeln!(
828 html,
829 " <thead><tr><th>Channel</th><th>Status</th></tr></thead>"
830 )?;
831 writeln!(html, " <tbody>")?;
832 writeln!(
833 html,
834 " <tr><td>PSIRT contact</td><td>{}</td></tr>",
835 psirt.html()
836 )?;
837 writeln!(
838 html,
839 " <tr><td>24-hour early warning (Art. 14(1))</td><td>{}</td></tr>",
840 early.html()
841 )?;
842 writeln!(
843 html,
844 " <tr><td>72-hour incident report (Art. 14(2))</td><td>{}</td></tr>",
845 incident.html()
846 )?;
847 writeln!(
848 html,
849 " <tr><td>ENISA single reporting platform (Art. 14(7))</td><td>{}</td></tr>",
850 enisa.html()
851 )?;
852 writeln!(html, " </tbody>")?;
853 writeln!(html, " </table>")?;
854 writeln!(
855 html,
856 " <p><em>Article 14 reporting obligations apply from 11 September 2026. \
857 Channels marked 'Missing (pre-deadline)' surface as Info; \
858 after the deadline they become Warnings.</em></p>"
859 )?;
860 Ok(())
861}
862
863#[derive(Debug, Clone, Copy, PartialEq, Eq)]
864enum ChannelStatusHtml {
865 Documented,
866 MissingPreDeadline,
867 MissingPostDeadline,
868}
869
870impl ChannelStatusHtml {
871 fn html(self) -> &'static str {
872 match self {
873 Self::Documented => "<span class=\"badge badge-low\">Documented</span>",
874 Self::MissingPreDeadline => {
875 "<span class=\"badge badge-medium\">Missing (pre-deadline 2026-09-11)</span>"
876 }
877 Self::MissingPostDeadline => "<span class=\"badge badge-critical\">Missing</span>",
878 }
879 }
880}
881
882fn channel_status_html(result: &ComplianceResult, needle: &str) -> ChannelStatusHtml {
883 match result
884 .violations
885 .iter()
886 .find(|v| v.requirement.contains(needle))
887 {
888 None => ChannelStatusHtml::Documented,
889 Some(v) => match v.severity {
890 ViolationSeverity::Warning | ViolationSeverity::Error => {
891 ChannelStatusHtml::MissingPostDeadline
892 }
893 ViolationSeverity::Info => ChannelStatusHtml::MissingPreDeadline,
894 },
895 }
896}
897
898fn count_violation_groups_html(violations: &[crate::quality::Violation]) -> usize {
901 use std::collections::HashSet;
902 let mut groups: HashSet<(u8, &str, &str)> = HashSet::new();
903 for v in violations {
904 let sev_ord = match v.severity {
905 ViolationSeverity::Error => 0,
906 ViolationSeverity::Warning => 1,
907 ViolationSeverity::Info => 2,
908 };
909 groups.insert((sev_ord, v.category.name(), v.requirement.as_str()));
910 }
911 groups.len()
912}
913
914fn aggregate_violations_html(
916 violations: &[crate::quality::Violation],
917) -> Vec<AggregatedViolationHtml<'_>> {
918 use std::collections::BTreeMap;
919
920 let mut groups: BTreeMap<(u8, &str, &str), Vec<&crate::quality::Violation>> = BTreeMap::new();
921 for v in violations {
922 let sev_ord = match v.severity {
923 ViolationSeverity::Error => 0,
924 ViolationSeverity::Warning => 1,
925 ViolationSeverity::Info => 2,
926 };
927 groups
928 .entry((sev_ord, v.category.name(), v.requirement.as_str()))
929 .or_default()
930 .push(v);
931 }
932
933 groups
934 .into_values()
935 .map(|group| {
936 let message = if group.len() == 1 {
937 group[0].message.clone()
938 } else {
939 let elements: Vec<&str> =
940 group.iter().filter_map(|v| v.element.as_deref()).collect();
941 if elements.is_empty() {
942 group[0].message.clone()
943 } else {
944 let preview: Vec<&str> = elements.iter().take(5).copied().collect();
945 let suffix = if elements.len() > 5 {
946 format!(", ... +{} more", elements.len() - 5)
947 } else {
948 String::new()
949 };
950 format!(
951 "{} components affected ({}{})",
952 elements.len(),
953 preview.join(", "),
954 suffix
955 )
956 }
957 };
958 let standard_refs = format_standard_refs_html(&group[0].standard_refs);
959 AggregatedViolationHtml {
960 severity: group[0].severity,
961 category: group[0].category.name(),
962 requirement: &group[0].requirement,
963 message,
964 remediation: group[0].remediation_guidance(),
965 count: group.len(),
966 standard_refs,
967 }
968 })
969 .collect()
970}
971
972fn format_standard_refs_html(refs: &[crate::quality::StandardRef]) -> String {
974 use std::fmt::Write;
975 let mut out = String::new();
976 for (i, r) in refs.iter().enumerate() {
977 if i > 0 {
978 out.push_str(", ");
979 }
980 let _ = write!(out, "{}: {}", r.standard.label(), r.id);
981 }
982 out
983}
984
985struct AggregatedViolationHtml<'a> {
986 severity: ViolationSeverity,
987 category: &'a str,
988 requirement: &'a str,
989 message: String,
990 remediation: &'static str,
991 count: usize,
992 standard_refs: String,
993}
994
995fn write_violation_table_html(
997 html: &mut String,
998 violations: &[crate::quality::Violation],
999) -> std::fmt::Result {
1000 let aggregated = aggregate_violations_html(violations);
1001 writeln!(html, " <table>")?;
1002 writeln!(html, " <thead>")?;
1003 writeln!(html, " <tr>")?;
1004 writeln!(html, " <th>Severity</th>")?;
1005 writeln!(html, " <th>Category</th>")?;
1006 writeln!(html, " <th>Standard refs</th>")?;
1007 writeln!(html, " <th>Requirement</th>")?;
1008 writeln!(html, " <th>Message</th>")?;
1009 writeln!(html, " <th>Remediation</th>")?;
1010 writeln!(html, " </tr>")?;
1011 writeln!(html, " </thead>")?;
1012 writeln!(html, " <tbody>")?;
1013
1014 for v in &aggregated {
1015 let (badge_class, label) = match v.severity {
1016 ViolationSeverity::Error => ("badge-critical", "Error"),
1017 ViolationSeverity::Warning => ("badge-medium", "Warning"),
1018 ViolationSeverity::Info => ("badge-low", "Info"),
1019 };
1020 let count_suffix = if v.count > 1 {
1021 format!(
1022 " <span class=\"badge badge-transitive\">x{}</span>",
1023 v.count
1024 )
1025 } else {
1026 String::new()
1027 };
1028 writeln!(html, " <tr>")?;
1029 writeln!(
1030 html,
1031 " <td><span class=\"badge {badge_class}\">{label}</span>{count_suffix}</td>"
1032 )?;
1033 writeln!(html, " <td>{}</td>", escape_html(v.category))?;
1034 writeln!(
1035 html,
1036 " <td>{}</td>",
1037 escape_html(&v.standard_refs)
1038 )?;
1039 writeln!(
1040 html,
1041 " <td>{}</td>",
1042 escape_html(v.requirement)
1043 )?;
1044 writeln!(html, " <td>{}</td>", escape_html(&v.message))?;
1045 writeln!(
1046 html,
1047 " <td><details><summary>View</summary>{}</details></td>",
1048 escape_html(v.remediation)
1049 )?;
1050 writeln!(html, " </tr>")?;
1051 }
1052
1053 writeln!(html, " </tbody>")?;
1054 writeln!(html, " </table>")
1055}
1056
1057fn compliance_status_badge(is_compliant: bool) -> &'static str {
1059 if is_compliant {
1060 "<span class=\"badge badge-added\">Compliant</span>"
1061 } else {
1062 "<span class=\"badge badge-removed\">Non-compliant</span>"
1063 }
1064}
1065
1066impl ReportGenerator for HtmlReporter {
1071 fn generate_diff_report(
1072 &self,
1073 result: &DiffResult,
1074 old_sbom: &NormalizedSbom,
1075 new_sbom: &NormalizedSbom,
1076 config: &ReportConfig,
1077 ) -> Result<String, ReportError> {
1078 let mut html = String::new();
1079 let title = config
1080 .title
1081 .clone()
1082 .unwrap_or_else(|| "SBOM Diff Report".to_string());
1083
1084 write_html_head(&mut html, &title, self.include_styles)?;
1085 write_page_header(&mut html, &title, None)?;
1086
1087 let has_components =
1089 config.includes(ReportType::Components) && !result.components.is_empty();
1090 let has_vulns = config.includes(ReportType::Vulnerabilities)
1091 && !result.vulnerabilities.introduced.is_empty();
1092 let has_metadata = !result.metadata_changes.is_empty();
1093 let mut toc_entries: Vec<(&str, &str)> = Vec::new();
1094 if has_metadata {
1095 toc_entries.push(("metadata-changes", "Metadata"));
1096 }
1097 if has_components {
1098 toc_entries.push(("component-changes", "Components"));
1099 }
1100 if has_vulns {
1101 toc_entries.push(("vulnerabilities", "Vulnerabilities"));
1102 }
1103 toc_entries.push(("cra-compliance", "CRA Compliance"));
1104 write_toc(&mut html, &toc_entries)?;
1105
1106 writeln!(html, "<div class=\"summary-cards\">")?;
1108 write_card(
1109 &mut html,
1110 "Components Added",
1111 &format!("+{}", result.summary.components_added),
1112 "added",
1113 )?;
1114 write_card(
1115 &mut html,
1116 "Components Removed",
1117 &format!("-{}", result.summary.components_removed),
1118 "removed",
1119 )?;
1120 write_card(
1121 &mut html,
1122 "Components Modified",
1123 &format!("~{}", result.summary.components_modified),
1124 "modified",
1125 )?;
1126 write_card(
1127 &mut html,
1128 "Vulns Introduced",
1129 &result.summary.vulnerabilities_introduced.to_string(),
1130 "critical",
1131 )?;
1132 write_card(
1133 &mut html,
1134 "Semantic Score",
1135 &format!("{:.1}", result.semantic_score),
1136 "",
1137 )?;
1138 writeln!(html, "</div>")?;
1139
1140 if has_metadata {
1142 write_diff_metadata_table(&mut html, result)?;
1143 }
1144
1145 if has_components {
1147 write_diff_component_table(&mut html, result)?;
1148 }
1149
1150 if has_vulns {
1152 write_diff_vuln_table(&mut html, result)?;
1153 }
1154
1155 write_eol_section(&mut html, new_sbom)?;
1157
1158 {
1160 let old_cra = config.old_cra_compliance.clone().unwrap_or_else(|| {
1161 ComplianceChecker::new(ComplianceLevel::CraPhase2).check(old_sbom)
1162 });
1163 let new_cra = config.new_cra_compliance.clone().unwrap_or_else(|| {
1164 ComplianceChecker::new(ComplianceLevel::CraPhase2).check(new_sbom)
1165 });
1166 write_cra_compliance_diff_html(&mut html, &old_cra, &new_cra)?;
1167 }
1168
1169 write_html_footer(&mut html)?;
1170 Ok(html)
1171 }
1172
1173 fn generate_view_report(
1174 &self,
1175 sbom: &NormalizedSbom,
1176 config: &ReportConfig,
1177 ) -> Result<String, ReportError> {
1178 use std::collections::HashSet;
1179
1180 let mut html = String::new();
1181 let title = config
1182 .title
1183 .clone()
1184 .unwrap_or_else(|| "SBOM Report".to_string());
1185
1186 let total_components = sbom.component_count();
1188 let vuln_component_count = sbom
1189 .components
1190 .values()
1191 .filter(|c| !c.vulnerabilities.is_empty())
1192 .count();
1193 let total_vulns: usize = sbom
1194 .components
1195 .values()
1196 .map(|c| c.vulnerabilities.len())
1197 .sum();
1198 let ecosystems: HashSet<_> = sbom
1199 .components
1200 .values()
1201 .filter_map(|c| c.ecosystem.as_ref())
1202 .collect();
1203 let licenses: HashSet<String> = sbom
1204 .components
1205 .values()
1206 .flat_map(|c| c.licenses.declared.iter().map(|l| l.expression.clone()))
1207 .collect();
1208
1209 let subtitle = sbom
1210 .document
1211 .name
1212 .as_deref()
1213 .map(|n| format!("Document: {n}"));
1214 write_html_head(&mut html, &title, self.include_styles)?;
1215 write_page_header(&mut html, &title, subtitle.as_deref())?;
1216
1217 let has_components = config.includes(ReportType::Components) && total_components > 0;
1219 let has_vulns = config.includes(ReportType::Vulnerabilities) && total_vulns > 0;
1220 let mut toc_entries: Vec<(&str, &str)> = Vec::new();
1221 if has_components {
1222 toc_entries.push(("components", "Components"));
1223 }
1224 if has_vulns {
1225 toc_entries.push(("vulnerabilities", "Vulnerabilities"));
1226 }
1227 toc_entries.push(("cra-compliance", "CRA Compliance"));
1228 write_toc(&mut html, &toc_entries)?;
1229
1230 writeln!(html, "<div class=\"summary-cards\">")?;
1232 write_card(
1233 &mut html,
1234 "Total Components",
1235 &total_components.to_string(),
1236 "",
1237 )?;
1238 let vuln_class = if vuln_component_count > 0 {
1239 "critical"
1240 } else {
1241 ""
1242 };
1243 write_card(
1244 &mut html,
1245 "Vulnerable Components",
1246 &vuln_component_count.to_string(),
1247 vuln_class,
1248 )?;
1249 let total_vuln_class = if total_vulns > 0 { "critical" } else { "" };
1250 write_card(
1251 &mut html,
1252 "Total Vulnerabilities",
1253 &total_vulns.to_string(),
1254 total_vuln_class,
1255 )?;
1256 write_card(&mut html, "Ecosystems", &ecosystems.len().to_string(), "")?;
1257 write_card(
1258 &mut html,
1259 "Unique Licenses",
1260 &licenses.len().to_string(),
1261 "",
1262 )?;
1263 writeln!(html, "</div>")?;
1264
1265 if has_components {
1267 write_view_component_table(&mut html, sbom)?;
1268 }
1269
1270 if has_vulns {
1272 write_view_vuln_table(&mut html, sbom)?;
1273 }
1274
1275 {
1277 let cra = config
1278 .view_cra_compliance
1279 .clone()
1280 .unwrap_or_else(|| ComplianceChecker::new(ComplianceLevel::CraPhase2).check(sbom));
1281 write_cra_compliance_view_html(&mut html, &cra)?;
1282 }
1283
1284 write_html_footer(&mut html)?;
1285 Ok(html)
1286 }
1287
1288 fn format(&self) -> ReportFormat {
1289 ReportFormat::Html
1290 }
1291}
1292
1293const HTML_STYLES: &str = r"
1298 <style>
1299 :root {
1300 --bg-color: #1e1e2e;
1301 --text-color: #cdd6f4;
1302 --accent-color: #89b4fa;
1303 --success-color: #a6e3a1;
1304 --warning-color: #f9e2af;
1305 --error-color: #f38ba8;
1306 --border-color: #45475a;
1307 --card-bg: #313244;
1308 }
1309
1310 body {
1311 font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
1312 background-color: var(--bg-color);
1313 color: var(--text-color);
1314 margin: 0;
1315 padding: 20px;
1316 line-height: 1.6;
1317 }
1318
1319 .container {
1320 max-width: 1200px;
1321 margin: 0 auto;
1322 }
1323
1324 h1, h2, h3 {
1325 color: var(--accent-color);
1326 }
1327
1328 .header {
1329 border-bottom: 2px solid var(--border-color);
1330 padding-bottom: 20px;
1331 margin-bottom: 30px;
1332 }
1333
1334 .summary-cards {
1335 display: grid;
1336 grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
1337 gap: 20px;
1338 margin-bottom: 30px;
1339 }
1340
1341 .card {
1342 background-color: var(--card-bg);
1343 border-radius: 8px;
1344 padding: 20px;
1345 border: 1px solid var(--border-color);
1346 }
1347
1348 .card-title {
1349 font-size: 0.9em;
1350 color: #a6adc8;
1351 margin-bottom: 10px;
1352 }
1353
1354 .card-value {
1355 font-size: 2em;
1356 font-weight: bold;
1357 }
1358
1359 .card-value.added { color: var(--success-color); }
1360 .card-value.removed { color: var(--error-color); }
1361 .card-value.modified { color: var(--warning-color); }
1362 .card-value.critical { color: var(--error-color); }
1363
1364 table {
1365 width: 100%;
1366 border-collapse: collapse;
1367 margin-bottom: 30px;
1368 background-color: var(--card-bg);
1369 border-radius: 8px;
1370 overflow: hidden;
1371 }
1372
1373 th, td {
1374 padding: 12px 15px;
1375 text-align: left;
1376 border-bottom: 1px solid var(--border-color);
1377 }
1378
1379 th {
1380 background-color: #45475a;
1381 font-weight: 600;
1382 }
1383
1384 tr:hover {
1385 background-color: #3b3d4d;
1386 }
1387
1388 .badge {
1389 display: inline-block;
1390 padding: 2px 8px;
1391 border-radius: 4px;
1392 font-size: 0.85em;
1393 font-weight: 500;
1394 }
1395
1396 .badge-added { background-color: rgba(166, 227, 161, 0.2); color: var(--success-color); }
1397 .badge-removed { background-color: rgba(243, 139, 168, 0.2); color: var(--error-color); }
1398 .badge-modified { background-color: rgba(249, 226, 175, 0.2); color: var(--warning-color); }
1399 .badge-critical { background-color: rgba(243, 139, 168, 0.3); color: var(--error-color); }
1400 .badge-high { background-color: rgba(250, 179, 135, 0.3); color: #fab387; }
1401 .badge-medium { background-color: rgba(249, 226, 175, 0.3); color: var(--warning-color); }
1402 .badge-low { background-color: rgba(148, 226, 213, 0.3); color: #94e2d5; }
1403 .badge-direct { background-color: rgba(46, 160, 67, 0.3); color: #2ea043; }
1404 .badge-transitive { background-color: rgba(110, 118, 129, 0.3); color: #6e7681; }
1405 .sla-overdue { background-color: rgba(248, 81, 73, 0.2); color: #f85149; font-weight: bold; }
1406 .sla-due-soon { background-color: rgba(227, 179, 65, 0.2); color: #e3b341; }
1407 .sla-on-track { color: #8b949e; }
1408 .sla-unknown { color: #8b949e; }
1409
1410 .section {
1411 margin-bottom: 40px;
1412 }
1413
1414 .tabs {
1415 display: flex;
1416 border-bottom: 2px solid var(--border-color);
1417 margin-bottom: 20px;
1418 }
1419
1420 .tab {
1421 padding: 10px 20px;
1422 cursor: pointer;
1423 border-bottom: 2px solid transparent;
1424 margin-bottom: -2px;
1425 }
1426
1427 .tab:hover {
1428 color: var(--accent-color);
1429 }
1430
1431 .tab.active {
1432 border-bottom-color: var(--accent-color);
1433 color: var(--accent-color);
1434 }
1435
1436 .footer {
1437 margin-top: 40px;
1438 padding-top: 20px;
1439 border-top: 1px solid var(--border-color);
1440 font-size: 0.9em;
1441 color: #a6adc8;
1442 }
1443
1444 .toc {
1445 background-color: var(--card-bg);
1446 border: 1px solid var(--border-color);
1447 border-radius: 8px;
1448 padding: 12px 20px;
1449 margin-bottom: 30px;
1450 display: flex;
1451 gap: 16px;
1452 align-items: center;
1453 flex-wrap: wrap;
1454 }
1455
1456 .toc a {
1457 color: var(--accent-color);
1458 text-decoration: none;
1459 padding: 4px 8px;
1460 border-radius: 4px;
1461 }
1462
1463 .toc a:hover {
1464 background-color: rgba(137, 180, 250, 0.1);
1465 }
1466
1467 .back-to-top {
1468 display: inline-block;
1469 color: #a6adc8;
1470 text-decoration: none;
1471 font-size: 0.85em;
1472 margin-bottom: 20px;
1473 }
1474
1475 .back-to-top:hover {
1476 color: var(--accent-color);
1477 }
1478
1479 details summary {
1480 cursor: pointer;
1481 color: var(--accent-color);
1482 font-size: 0.85em;
1483 }
1484
1485 details summary:hover {
1486 text-decoration: underline;
1487 }
1488
1489 details[open] summary {
1490 margin-bottom: 6px;
1491 }
1492 </style>
1493";