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_component_table(html: &mut String, result: &DiffResult) -> std::fmt::Result {
169 writeln!(html, "<div class=\"section\" id=\"component-changes\">")?;
170 writeln!(html, " <h2>Component Changes</h2>")?;
171 writeln!(html, " <table>")?;
172 writeln!(html, " <thead>")?;
173 writeln!(html, " <tr>")?;
174 writeln!(html, " <th>Status</th>")?;
175 writeln!(html, " <th>Name</th>")?;
176 writeln!(html, " <th>Old Version</th>")?;
177 writeln!(html, " <th>New Version</th>")?;
178 writeln!(html, " <th>Ecosystem</th>")?;
179 writeln!(html, " </tr>")?;
180 writeln!(html, " </thead>")?;
181 writeln!(html, " <tbody>")?;
182
183 for comp in &result.components.added {
184 writeln!(html, " <tr>")?;
185 writeln!(
186 html,
187 " <td><span class=\"badge badge-added\">Added</span></td>"
188 )?;
189 writeln!(html, " <td>{}</td>", escape_html(&comp.name))?;
190 writeln!(html, " <td>-</td>")?;
191 writeln!(
192 html,
193 " <td>{}</td>",
194 escape_html_opt(comp.new_version.as_deref())
195 )?;
196 writeln!(
197 html,
198 " <td>{}</td>",
199 escape_html_opt(comp.ecosystem.as_deref())
200 )?;
201 writeln!(html, " </tr>")?;
202 }
203
204 for comp in &result.components.removed {
205 writeln!(html, " <tr>")?;
206 writeln!(
207 html,
208 " <td><span class=\"badge badge-removed\">Removed</span></td>"
209 )?;
210 writeln!(html, " <td>{}</td>", escape_html(&comp.name))?;
211 writeln!(
212 html,
213 " <td>{}</td>",
214 escape_html_opt(comp.old_version.as_deref())
215 )?;
216 writeln!(html, " <td>-</td>")?;
217 writeln!(
218 html,
219 " <td>{}</td>",
220 escape_html_opt(comp.ecosystem.as_deref())
221 )?;
222 writeln!(html, " </tr>")?;
223 }
224
225 for comp in &result.components.modified {
226 writeln!(html, " <tr>")?;
227 writeln!(
228 html,
229 " <td><span class=\"badge badge-modified\">Modified</span></td>"
230 )?;
231 writeln!(html, " <td>{}</td>", escape_html(&comp.name))?;
232 writeln!(
233 html,
234 " <td>{}</td>",
235 escape_html_opt(comp.old_version.as_deref())
236 )?;
237 writeln!(
238 html,
239 " <td>{}</td>",
240 escape_html_opt(comp.new_version.as_deref())
241 )?;
242 writeln!(
243 html,
244 " <td>{}</td>",
245 escape_html_opt(comp.ecosystem.as_deref())
246 )?;
247 writeln!(html, " </tr>")?;
248 }
249
250 writeln!(html, " </tbody>")?;
251 writeln!(html, " </table>")?;
252 writeln!(html, "</div>")
253}
254
255fn write_diff_vuln_table(html: &mut String, result: &DiffResult) -> std::fmt::Result {
257 writeln!(html, "<div class=\"section\" id=\"vulnerabilities\">")?;
258 writeln!(html, " <h2>Introduced Vulnerabilities</h2>")?;
259 writeln!(html, " <table>")?;
260 writeln!(html, " <thead>")?;
261 writeln!(html, " <tr>")?;
262 writeln!(html, " <th>ID</th>")?;
263 writeln!(html, " <th>Severity</th>")?;
264 writeln!(html, " <th>CVSS</th>")?;
265 writeln!(html, " <th>SLA</th>")?;
266 writeln!(html, " <th>Type</th>")?;
267 writeln!(html, " <th>Component</th>")?;
268 writeln!(html, " <th>Version</th>")?;
269 writeln!(html, " <th>VEX</th>")?;
270 writeln!(html, " </tr>")?;
271 writeln!(html, " </thead>")?;
272 writeln!(html, " <tbody>")?;
273
274 for vuln in &result.vulnerabilities.introduced {
275 let badge_class = match vuln.severity.to_lowercase().as_str() {
276 "critical" => "badge-critical",
277 "high" => "badge-high",
278 "medium" => "badge-medium",
279 _ => "badge-low",
280 };
281 let (depth_label, depth_class) = match vuln.component_depth {
282 Some(1) => ("Direct", "badge-direct"),
283 Some(_) => ("Transitive", "badge-transitive"),
284 None => ("-", ""),
285 };
286 writeln!(html, " <tr>")?;
287 writeln!(html, " <td>{}</td>", escape_html(&vuln.id))?;
288 writeln!(
289 html,
290 " <td><span class=\"badge {}\">{}</span></td>",
291 badge_class,
292 escape_html(&vuln.severity)
293 )?;
294 writeln!(
295 html,
296 " <td>{}</td>",
297 vuln.cvss_score
298 .map(|s| format!("{s:.1}"))
299 .as_deref()
300 .unwrap_or("-")
301 )?;
302 let (sla_text, sla_class) = format_sla_html(vuln);
304 if sla_class.is_empty() {
305 writeln!(html, " <td>{sla_text}</td>")?;
306 } else {
307 writeln!(
308 html,
309 " <td><span class=\"{sla_class}\">{sla_text}</span></td>"
310 )?;
311 }
312 if depth_class.is_empty() {
313 writeln!(html, " <td>{depth_label}</td>")?;
314 } else {
315 writeln!(
316 html,
317 " <td><span class=\"badge {depth_class}\">{depth_label}</span></td>"
318 )?;
319 }
320 writeln!(
321 html,
322 " <td>{}</td>",
323 escape_html(&vuln.component_name)
324 )?;
325 writeln!(
326 html,
327 " <td>{}</td>",
328 escape_html_opt(vuln.version.as_deref())
329 )?;
330 let vex_display = format_vex_html(vuln.vex_state.as_ref());
332 writeln!(html, " <td>{vex_display}</td>")?;
333 writeln!(html, " </tr>")?;
334 }
335
336 writeln!(html, " </tbody>")?;
337 writeln!(html, " </table>")?;
338 writeln!(html, "</div>")
339}
340
341fn write_view_component_table(html: &mut String, sbom: &NormalizedSbom) -> std::fmt::Result {
343 writeln!(html, "<div class=\"section\" id=\"components\">")?;
344 writeln!(html, " <h2>Components</h2>")?;
345 writeln!(html, " <table>")?;
346 writeln!(html, " <thead>")?;
347 writeln!(html, " <tr>")?;
348 writeln!(html, " <th>Name</th>")?;
349 writeln!(html, " <th>Version</th>")?;
350 writeln!(html, " <th>Ecosystem</th>")?;
351 writeln!(html, " <th>License</th>")?;
352 writeln!(html, " <th>Vulnerabilities</th>")?;
353 writeln!(html, " </tr>")?;
354 writeln!(html, " </thead>")?;
355 writeln!(html, " <tbody>")?;
356
357 let mut components: Vec<_> = sbom.components.values().collect();
359 components.sort_by(|a, b| a.name.cmp(&b.name));
360
361 for comp in components {
362 let license_str = comp
363 .licenses
364 .declared
365 .first()
366 .map_or("-", |l| l.expression.as_str());
367 let vuln_count = comp.vulnerabilities.len();
368 let vuln_badge = if vuln_count > 0 {
369 format!("<span class=\"badge badge-critical\">{vuln_count}</span>")
370 } else {
371 "0".to_string()
372 };
373
374 writeln!(html, " <tr>")?;
375 writeln!(html, " <td>{}</td>", escape_html(&comp.name))?;
376 writeln!(
377 html,
378 " <td>{}</td>",
379 escape_html_opt(comp.version.as_deref())
380 )?;
381 writeln!(
382 html,
383 " <td>{}</td>",
384 comp.ecosystem
385 .as_ref()
386 .map(|e| escape_html(&format!("{e:?}")))
387 .as_deref()
388 .unwrap_or("-")
389 )?;
390 writeln!(
391 html,
392 " <td>{}</td>",
393 escape_html(license_str)
394 )?;
395 writeln!(html, " <td>{vuln_badge}</td>")?;
396 writeln!(html, " </tr>")?;
397 }
398
399 writeln!(html, " </tbody>")?;
400 writeln!(html, " </table>")?;
401 writeln!(html, "</div>")
402}
403
404type ViewVulnRow<'a> = (
406 &'a str,
407 &'a Option<crate::model::Severity>,
408 Option<f32>,
409 &'a str,
410 Option<&'a str>,
411 Option<&'a crate::model::VulnerabilityRef>,
412);
413
414fn write_view_vuln_table(html: &mut String, sbom: &NormalizedSbom) -> std::fmt::Result {
416 writeln!(html, "<div class=\"section\" id=\"vulnerabilities\">")?;
417 writeln!(html, " <h2>Vulnerabilities</h2>")?;
418 writeln!(html, " <table>")?;
419 writeln!(html, " <thead>")?;
420 writeln!(html, " <tr>")?;
421 writeln!(html, " <th>ID</th>")?;
422 writeln!(html, " <th>Severity</th>")?;
423 writeln!(html, " <th>CVSS</th>")?;
424 writeln!(html, " <th>SLA</th>")?;
425 writeln!(html, " <th>Component</th>")?;
426 writeln!(html, " <th>Version</th>")?;
427 writeln!(html, " <th>VEX</th>")?;
428 writeln!(html, " </tr>")?;
429 writeln!(html, " </thead>")?;
430 writeln!(html, " <tbody>")?;
431
432 let mut all_vulns: Vec<ViewVulnRow<'_>> = sbom
434 .components
435 .values()
436 .flat_map(|comp| {
437 comp.vulnerabilities.iter().map(move |v| {
438 (
439 v.id.as_str(),
440 &v.severity,
441 v.cvss.first().map(|c| c.base_score),
442 comp.name.as_str(),
443 comp.version.as_deref(),
444 Some(v),
445 )
446 })
447 })
448 .collect();
449
450 all_vulns.sort_by(|a, b| {
452 let sev_order = |s: &Option<crate::model::Severity>| match s {
453 Some(crate::model::Severity::Critical) => 0,
454 Some(crate::model::Severity::High) => 1,
455 Some(crate::model::Severity::Medium) => 2,
456 Some(crate::model::Severity::Low) => 3,
457 Some(crate::model::Severity::Info) => 4,
458 _ => 5,
459 };
460 sev_order(a.1).cmp(&sev_order(b.1))
461 });
462
463 for &(id, severity, cvss, comp_name, version, vuln) in &all_vulns {
464 let (badge_class, sev_str) = match severity {
465 Some(crate::model::Severity::Critical) => ("badge-critical", "Critical"),
466 Some(crate::model::Severity::High) => ("badge-high", "High"),
467 Some(crate::model::Severity::Medium) => ("badge-medium", "Medium"),
468 Some(crate::model::Severity::Low) => ("badge-low", "Low"),
469 Some(crate::model::Severity::Info) => ("badge-low", "Info"),
470 _ => ("badge-low", "Unknown"),
471 };
472
473 let (sla_text, sla_class) = if let Some(v) = vuln {
475 compute_view_sla(v)
476 } else {
477 ("-".to_string(), "sla-unknown")
478 };
479
480 writeln!(html, " <tr>")?;
481 writeln!(html, " <td>{}</td>", escape_html(id))?;
482 writeln!(
483 html,
484 " <td><span class=\"badge {badge_class}\">{sev_str}</span></td>"
485 )?;
486 writeln!(
487 html,
488 " <td>{}</td>",
489 cvss.map(|s| format!("{s:.1}")).as_deref().unwrap_or("-")
490 )?;
491 if sla_class.is_empty() {
492 writeln!(html, " <td>{sla_text}</td>")?;
493 } else {
494 writeln!(
495 html,
496 " <td><span class=\"{sla_class}\">{sla_text}</span></td>"
497 )?;
498 }
499 writeln!(html, " <td>{}</td>", escape_html(comp_name))?;
500 writeln!(
501 html,
502 " <td>{}</td>",
503 escape_html_opt(version)
504 )?;
505 let vex_state = vuln.and_then(|v| v.vex_status.as_ref().map(|vs| &vs.status));
507 let vex_display = format_vex_html(vex_state);
508 writeln!(html, " <td>{vex_display}</td>")?;
509 writeln!(html, " </tr>")?;
510 }
511
512 writeln!(html, " </tbody>")?;
513 writeln!(html, " </table>")?;
514 writeln!(html, "</div>")
515}
516
517fn compute_view_sla(vuln: &crate::model::VulnerabilityRef) -> (String, &'static str) {
519 if let Some(published) = vuln.published {
520 let delta: chrono::TimeDelta = chrono::Utc::now() - published;
521 let days = delta.num_days();
522 if days < 0 {
523 return ("-".to_string(), "sla-unknown");
524 }
525 let days = days as u64;
526 let sla_days: Option<u64> = match &vuln.severity {
528 Some(crate::model::Severity::Critical) => Some(15),
529 Some(crate::model::Severity::High) => Some(30),
530 Some(crate::model::Severity::Medium) => Some(90),
531 Some(crate::model::Severity::Low) => Some(180),
532 _ => None,
533 };
534 if let Some(sla) = sla_days {
535 if days > sla {
536 (format!("{}d late", days - sla), "sla-overdue")
537 } else if sla - days <= 7 {
538 (format!("{}d left", sla - days), "sla-due-soon")
539 } else {
540 (format!("{}d left", sla - days), "sla-on-track")
541 }
542 } else {
543 (format!("{days}d old"), "sla-unknown")
544 }
545 } else {
546 ("-".to_string(), "sla-unknown")
547 }
548}
549
550fn format_sla_html(vuln: &VulnerabilityDetail) -> (String, &'static str) {
552 match vuln.sla_status() {
553 SlaStatus::Overdue(days) => (format!("{days}d late"), "sla-overdue"),
554 SlaStatus::DueSoon(days) => (format!("{days}d left"), "sla-due-soon"),
555 SlaStatus::OnTrack(days) => (format!("{days}d left"), "sla-on-track"),
556 SlaStatus::NoDueDate => {
557 let text = vuln
558 .days_since_published
559 .map_or_else(|| "-".to_string(), |d| format!("{d}d old"));
560 (text, "sla-unknown")
561 }
562 }
563}
564
565fn format_vex_html(vex_state: Option<&crate::model::VexState>) -> String {
567 match vex_state {
568 Some(crate::model::VexState::NotAffected) => {
569 "<span class=\"badge badge-added\">Not Affected</span>".to_string()
570 }
571 Some(crate::model::VexState::Fixed) => {
572 "<span class=\"badge badge-added\">Fixed</span>".to_string()
573 }
574 Some(crate::model::VexState::Affected) => {
575 "<span class=\"badge badge-removed\">Affected</span>".to_string()
576 }
577 Some(crate::model::VexState::UnderInvestigation) => {
578 "<span class=\"badge badge-medium\">Under Investigation</span>".to_string()
579 }
580 None => "-".to_string(),
581 }
582}
583
584fn compliance_score_html(result: &ComplianceResult) -> u8 {
586 let total = result.violations.len() + 1;
587 let issues = result.error_count + result.warning_count;
588 let score = if issues >= total {
589 0
590 } else {
591 ((total - issues) * 100) / total
592 };
593 score.min(100) as u8
594}
595
596fn trend_badge(old_val: usize, new_val: usize, lower_is_better: bool) -> &'static str {
598 if old_val == new_val {
599 ""
600 } else if (new_val < old_val) == lower_is_better {
601 " <span class=\"badge badge-added\">improved</span>"
602 } else {
603 " <span class=\"badge badge-removed\">regressed</span>"
604 }
605}
606
607fn write_cra_compliance_diff_html(
609 html: &mut String,
610 old: &ComplianceResult,
611 new: &ComplianceResult,
612) -> std::fmt::Result {
613 writeln!(html, "<div class=\"section\" id=\"cra-compliance\">")?;
614 writeln!(html, " <h2>CRA Compliance</h2>")?;
615 writeln!(html, " <table>")?;
616 writeln!(html, " <thead>")?;
617 writeln!(
618 html,
619 " <tr><th></th><th>Old SBOM</th><th>New SBOM</th><th>Trend</th></tr>"
620 )?;
621 writeln!(html, " </thead>")?;
622 writeln!(html, " <tbody>")?;
623
624 let old_badge = compliance_status_badge(old.is_compliant);
625 let new_badge = compliance_status_badge(new.is_compliant);
626 let old_score = compliance_score_html(old);
627 let new_score = compliance_score_html(new);
628 let err_trend = trend_badge(old.error_count, new.error_count, true);
629 let warn_trend = trend_badge(old.warning_count, new.warning_count, true);
630 let score_trend = trend_badge(old_score.into(), new_score.into(), false);
631
632 writeln!(
633 html,
634 " <tr><td><strong>Status</strong></td><td>{old_badge}</td><td>{new_badge}</td><td></td></tr>"
635 )?;
636 writeln!(
637 html,
638 " <tr><td><strong>Score</strong></td><td>{old_score}%</td><td>{new_score}%</td><td>{score_trend}</td></tr>"
639 )?;
640 writeln!(
641 html,
642 " <tr><td><strong>Level</strong></td><td>{}</td><td>{}</td><td></td></tr>",
643 escape_html(old.level.name()),
644 escape_html(new.level.name())
645 )?;
646 writeln!(
647 html,
648 " <tr><td><strong>Errors</strong></td><td>{}</td><td>{}</td><td>{err_trend}</td></tr>",
649 old.error_count, new.error_count
650 )?;
651 writeln!(
652 html,
653 " <tr><td><strong>Warnings</strong></td><td>{}</td><td>{}</td><td>{warn_trend}</td></tr>",
654 old.warning_count, new.warning_count
655 )?;
656
657 writeln!(html, " </tbody>")?;
658 writeln!(html, " </table>")?;
659
660 if !new.violations.is_empty() {
661 writeln!(html, " <h3>Violations (New SBOM)</h3>")?;
662 write_violation_table_html(html, &new.violations)?;
663 }
664
665 writeln!(html, "</div>")?;
666 writeln!(
667 html,
668 "<a href=\"#top\" class=\"back-to-top\">Back to top</a>"
669 )
670}
671
672fn write_cra_compliance_view_html(
674 html: &mut String,
675 result: &ComplianceResult,
676) -> std::fmt::Result {
677 writeln!(html, "<div class=\"section\" id=\"cra-compliance\">")?;
678 writeln!(html, " <h2>CRA Compliance</h2>")?;
679
680 let badge = compliance_status_badge(result.is_compliant);
681 let score = compliance_score_html(result);
682 writeln!(html, " <p><strong>Status:</strong> {badge} ")?;
683 writeln!(html, " <strong>Score:</strong> {score}% ")?;
684 writeln!(
685 html,
686 " <strong>Level:</strong> {} ",
687 escape_html(result.level.name())
688 )?;
689 writeln!(
690 html,
691 " <strong>Issues:</strong> {} errors, {} warnings</p>",
692 result.error_count, result.warning_count
693 )?;
694
695 if !result.violations.is_empty() {
696 write_violation_table_html(html, &result.violations)?;
697 }
698
699 writeln!(html, "</div>")?;
700 writeln!(
701 html,
702 "<a href=\"#top\" class=\"back-to-top\">Back to top</a>"
703 )
704}
705
706fn aggregate_violations_html(
708 violations: &[crate::quality::Violation],
709) -> Vec<AggregatedViolationHtml<'_>> {
710 use std::collections::BTreeMap;
711
712 let mut groups: BTreeMap<(u8, &str, &str), Vec<&crate::quality::Violation>> = BTreeMap::new();
713 for v in violations {
714 let sev_ord = match v.severity {
715 ViolationSeverity::Error => 0,
716 ViolationSeverity::Warning => 1,
717 ViolationSeverity::Info => 2,
718 };
719 groups
720 .entry((sev_ord, v.category.name(), v.requirement.as_str()))
721 .or_default()
722 .push(v);
723 }
724
725 groups
726 .into_values()
727 .map(|group| {
728 let message = if group.len() == 1 {
729 group[0].message.clone()
730 } else {
731 let elements: Vec<&str> =
732 group.iter().filter_map(|v| v.element.as_deref()).collect();
733 if elements.is_empty() {
734 group[0].message.clone()
735 } else {
736 let preview: Vec<&str> = elements.iter().take(5).copied().collect();
737 let suffix = if elements.len() > 5 {
738 format!(", ... +{} more", elements.len() - 5)
739 } else {
740 String::new()
741 };
742 format!(
743 "{} components affected ({}{})",
744 elements.len(),
745 preview.join(", "),
746 suffix
747 )
748 }
749 };
750 AggregatedViolationHtml {
751 severity: group[0].severity,
752 category: group[0].category.name(),
753 requirement: &group[0].requirement,
754 message,
755 remediation: group[0].remediation_guidance(),
756 count: group.len(),
757 }
758 })
759 .collect()
760}
761
762struct AggregatedViolationHtml<'a> {
763 severity: ViolationSeverity,
764 category: &'a str,
765 requirement: &'a str,
766 message: String,
767 remediation: &'static str,
768 count: usize,
769}
770
771fn write_violation_table_html(
773 html: &mut String,
774 violations: &[crate::quality::Violation],
775) -> std::fmt::Result {
776 let aggregated = aggregate_violations_html(violations);
777 writeln!(html, " <table>")?;
778 writeln!(html, " <thead>")?;
779 writeln!(html, " <tr>")?;
780 writeln!(html, " <th>Severity</th>")?;
781 writeln!(html, " <th>Category</th>")?;
782 writeln!(html, " <th>Requirement</th>")?;
783 writeln!(html, " <th>Message</th>")?;
784 writeln!(html, " <th>Remediation</th>")?;
785 writeln!(html, " </tr>")?;
786 writeln!(html, " </thead>")?;
787 writeln!(html, " <tbody>")?;
788
789 for v in &aggregated {
790 let (badge_class, label) = match v.severity {
791 ViolationSeverity::Error => ("badge-critical", "Error"),
792 ViolationSeverity::Warning => ("badge-medium", "Warning"),
793 ViolationSeverity::Info => ("badge-low", "Info"),
794 };
795 let count_suffix = if v.count > 1 {
796 format!(
797 " <span class=\"badge badge-transitive\">x{}</span>",
798 v.count
799 )
800 } else {
801 String::new()
802 };
803 writeln!(html, " <tr>")?;
804 writeln!(
805 html,
806 " <td><span class=\"badge {badge_class}\">{label}</span>{count_suffix}</td>"
807 )?;
808 writeln!(html, " <td>{}</td>", escape_html(v.category))?;
809 writeln!(
810 html,
811 " <td>{}</td>",
812 escape_html(v.requirement)
813 )?;
814 writeln!(html, " <td>{}</td>", escape_html(&v.message))?;
815 writeln!(
816 html,
817 " <td><details><summary>View</summary>{}</details></td>",
818 escape_html(v.remediation)
819 )?;
820 writeln!(html, " </tr>")?;
821 }
822
823 writeln!(html, " </tbody>")?;
824 writeln!(html, " </table>")
825}
826
827fn compliance_status_badge(is_compliant: bool) -> &'static str {
829 if is_compliant {
830 "<span class=\"badge badge-added\">Compliant</span>"
831 } else {
832 "<span class=\"badge badge-removed\">Non-compliant</span>"
833 }
834}
835
836impl ReportGenerator for HtmlReporter {
841 fn generate_diff_report(
842 &self,
843 result: &DiffResult,
844 old_sbom: &NormalizedSbom,
845 new_sbom: &NormalizedSbom,
846 config: &ReportConfig,
847 ) -> Result<String, ReportError> {
848 let mut html = String::new();
849 let title = config
850 .title
851 .clone()
852 .unwrap_or_else(|| "SBOM Diff Report".to_string());
853
854 write_html_head(&mut html, &title, self.include_styles)?;
855 write_page_header(&mut html, &title, None)?;
856
857 let has_components =
859 config.includes(ReportType::Components) && !result.components.is_empty();
860 let has_vulns = config.includes(ReportType::Vulnerabilities)
861 && !result.vulnerabilities.introduced.is_empty();
862 let mut toc_entries: Vec<(&str, &str)> = Vec::new();
863 if has_components {
864 toc_entries.push(("component-changes", "Components"));
865 }
866 if has_vulns {
867 toc_entries.push(("vulnerabilities", "Vulnerabilities"));
868 }
869 toc_entries.push(("cra-compliance", "CRA Compliance"));
870 write_toc(&mut html, &toc_entries)?;
871
872 writeln!(html, "<div class=\"summary-cards\">")?;
874 write_card(
875 &mut html,
876 "Components Added",
877 &format!("+{}", result.summary.components_added),
878 "added",
879 )?;
880 write_card(
881 &mut html,
882 "Components Removed",
883 &format!("-{}", result.summary.components_removed),
884 "removed",
885 )?;
886 write_card(
887 &mut html,
888 "Components Modified",
889 &format!("~{}", result.summary.components_modified),
890 "modified",
891 )?;
892 write_card(
893 &mut html,
894 "Vulns Introduced",
895 &result.summary.vulnerabilities_introduced.to_string(),
896 "critical",
897 )?;
898 write_card(
899 &mut html,
900 "Semantic Score",
901 &format!("{:.1}", result.semantic_score),
902 "",
903 )?;
904 writeln!(html, "</div>")?;
905
906 if has_components {
908 write_diff_component_table(&mut html, result)?;
909 }
910
911 if has_vulns {
913 write_diff_vuln_table(&mut html, result)?;
914 }
915
916 write_eol_section(&mut html, new_sbom)?;
918
919 {
921 let old_cra = config.old_cra_compliance.clone().unwrap_or_else(|| {
922 ComplianceChecker::new(ComplianceLevel::CraPhase2).check(old_sbom)
923 });
924 let new_cra = config.new_cra_compliance.clone().unwrap_or_else(|| {
925 ComplianceChecker::new(ComplianceLevel::CraPhase2).check(new_sbom)
926 });
927 write_cra_compliance_diff_html(&mut html, &old_cra, &new_cra)?;
928 }
929
930 write_html_footer(&mut html)?;
931 Ok(html)
932 }
933
934 fn generate_view_report(
935 &self,
936 sbom: &NormalizedSbom,
937 config: &ReportConfig,
938 ) -> Result<String, ReportError> {
939 use std::collections::HashSet;
940
941 let mut html = String::new();
942 let title = config
943 .title
944 .clone()
945 .unwrap_or_else(|| "SBOM Report".to_string());
946
947 let total_components = sbom.component_count();
949 let vuln_component_count = sbom
950 .components
951 .values()
952 .filter(|c| !c.vulnerabilities.is_empty())
953 .count();
954 let total_vulns: usize = sbom
955 .components
956 .values()
957 .map(|c| c.vulnerabilities.len())
958 .sum();
959 let ecosystems: HashSet<_> = sbom
960 .components
961 .values()
962 .filter_map(|c| c.ecosystem.as_ref())
963 .collect();
964 let licenses: HashSet<String> = sbom
965 .components
966 .values()
967 .flat_map(|c| c.licenses.declared.iter().map(|l| l.expression.clone()))
968 .collect();
969
970 let subtitle = sbom
971 .document
972 .name
973 .as_deref()
974 .map(|n| format!("Document: {n}"));
975 write_html_head(&mut html, &title, self.include_styles)?;
976 write_page_header(&mut html, &title, subtitle.as_deref())?;
977
978 let has_components = config.includes(ReportType::Components) && total_components > 0;
980 let has_vulns = config.includes(ReportType::Vulnerabilities) && total_vulns > 0;
981 let mut toc_entries: Vec<(&str, &str)> = Vec::new();
982 if has_components {
983 toc_entries.push(("components", "Components"));
984 }
985 if has_vulns {
986 toc_entries.push(("vulnerabilities", "Vulnerabilities"));
987 }
988 toc_entries.push(("cra-compliance", "CRA Compliance"));
989 write_toc(&mut html, &toc_entries)?;
990
991 writeln!(html, "<div class=\"summary-cards\">")?;
993 write_card(
994 &mut html,
995 "Total Components",
996 &total_components.to_string(),
997 "",
998 )?;
999 let vuln_class = if vuln_component_count > 0 {
1000 "critical"
1001 } else {
1002 ""
1003 };
1004 write_card(
1005 &mut html,
1006 "Vulnerable Components",
1007 &vuln_component_count.to_string(),
1008 vuln_class,
1009 )?;
1010 let total_vuln_class = if total_vulns > 0 { "critical" } else { "" };
1011 write_card(
1012 &mut html,
1013 "Total Vulnerabilities",
1014 &total_vulns.to_string(),
1015 total_vuln_class,
1016 )?;
1017 write_card(&mut html, "Ecosystems", &ecosystems.len().to_string(), "")?;
1018 write_card(
1019 &mut html,
1020 "Unique Licenses",
1021 &licenses.len().to_string(),
1022 "",
1023 )?;
1024 writeln!(html, "</div>")?;
1025
1026 if has_components {
1028 write_view_component_table(&mut html, sbom)?;
1029 }
1030
1031 if has_vulns {
1033 write_view_vuln_table(&mut html, sbom)?;
1034 }
1035
1036 {
1038 let cra = config
1039 .view_cra_compliance
1040 .clone()
1041 .unwrap_or_else(|| ComplianceChecker::new(ComplianceLevel::CraPhase2).check(sbom));
1042 write_cra_compliance_view_html(&mut html, &cra)?;
1043 }
1044
1045 write_html_footer(&mut html)?;
1046 Ok(html)
1047 }
1048
1049 fn format(&self) -> ReportFormat {
1050 ReportFormat::Html
1051 }
1052}
1053
1054const HTML_STYLES: &str = r"
1059 <style>
1060 :root {
1061 --bg-color: #1e1e2e;
1062 --text-color: #cdd6f4;
1063 --accent-color: #89b4fa;
1064 --success-color: #a6e3a1;
1065 --warning-color: #f9e2af;
1066 --error-color: #f38ba8;
1067 --border-color: #45475a;
1068 --card-bg: #313244;
1069 }
1070
1071 body {
1072 font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
1073 background-color: var(--bg-color);
1074 color: var(--text-color);
1075 margin: 0;
1076 padding: 20px;
1077 line-height: 1.6;
1078 }
1079
1080 .container {
1081 max-width: 1200px;
1082 margin: 0 auto;
1083 }
1084
1085 h1, h2, h3 {
1086 color: var(--accent-color);
1087 }
1088
1089 .header {
1090 border-bottom: 2px solid var(--border-color);
1091 padding-bottom: 20px;
1092 margin-bottom: 30px;
1093 }
1094
1095 .summary-cards {
1096 display: grid;
1097 grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
1098 gap: 20px;
1099 margin-bottom: 30px;
1100 }
1101
1102 .card {
1103 background-color: var(--card-bg);
1104 border-radius: 8px;
1105 padding: 20px;
1106 border: 1px solid var(--border-color);
1107 }
1108
1109 .card-title {
1110 font-size: 0.9em;
1111 color: #a6adc8;
1112 margin-bottom: 10px;
1113 }
1114
1115 .card-value {
1116 font-size: 2em;
1117 font-weight: bold;
1118 }
1119
1120 .card-value.added { color: var(--success-color); }
1121 .card-value.removed { color: var(--error-color); }
1122 .card-value.modified { color: var(--warning-color); }
1123 .card-value.critical { color: var(--error-color); }
1124
1125 table {
1126 width: 100%;
1127 border-collapse: collapse;
1128 margin-bottom: 30px;
1129 background-color: var(--card-bg);
1130 border-radius: 8px;
1131 overflow: hidden;
1132 }
1133
1134 th, td {
1135 padding: 12px 15px;
1136 text-align: left;
1137 border-bottom: 1px solid var(--border-color);
1138 }
1139
1140 th {
1141 background-color: #45475a;
1142 font-weight: 600;
1143 }
1144
1145 tr:hover {
1146 background-color: #3b3d4d;
1147 }
1148
1149 .badge {
1150 display: inline-block;
1151 padding: 2px 8px;
1152 border-radius: 4px;
1153 font-size: 0.85em;
1154 font-weight: 500;
1155 }
1156
1157 .badge-added { background-color: rgba(166, 227, 161, 0.2); color: var(--success-color); }
1158 .badge-removed { background-color: rgba(243, 139, 168, 0.2); color: var(--error-color); }
1159 .badge-modified { background-color: rgba(249, 226, 175, 0.2); color: var(--warning-color); }
1160 .badge-critical { background-color: rgba(243, 139, 168, 0.3); color: var(--error-color); }
1161 .badge-high { background-color: rgba(250, 179, 135, 0.3); color: #fab387; }
1162 .badge-medium { background-color: rgba(249, 226, 175, 0.3); color: var(--warning-color); }
1163 .badge-low { background-color: rgba(148, 226, 213, 0.3); color: #94e2d5; }
1164 .badge-direct { background-color: rgba(46, 160, 67, 0.3); color: #2ea043; }
1165 .badge-transitive { background-color: rgba(110, 118, 129, 0.3); color: #6e7681; }
1166 .sla-overdue { background-color: rgba(248, 81, 73, 0.2); color: #f85149; font-weight: bold; }
1167 .sla-due-soon { background-color: rgba(227, 179, 65, 0.2); color: #e3b341; }
1168 .sla-on-track { color: #8b949e; }
1169 .sla-unknown { color: #8b949e; }
1170
1171 .section {
1172 margin-bottom: 40px;
1173 }
1174
1175 .tabs {
1176 display: flex;
1177 border-bottom: 2px solid var(--border-color);
1178 margin-bottom: 20px;
1179 }
1180
1181 .tab {
1182 padding: 10px 20px;
1183 cursor: pointer;
1184 border-bottom: 2px solid transparent;
1185 margin-bottom: -2px;
1186 }
1187
1188 .tab:hover {
1189 color: var(--accent-color);
1190 }
1191
1192 .tab.active {
1193 border-bottom-color: var(--accent-color);
1194 color: var(--accent-color);
1195 }
1196
1197 .footer {
1198 margin-top: 40px;
1199 padding-top: 20px;
1200 border-top: 1px solid var(--border-color);
1201 font-size: 0.9em;
1202 color: #a6adc8;
1203 }
1204
1205 .toc {
1206 background-color: var(--card-bg);
1207 border: 1px solid var(--border-color);
1208 border-radius: 8px;
1209 padding: 12px 20px;
1210 margin-bottom: 30px;
1211 display: flex;
1212 gap: 16px;
1213 align-items: center;
1214 flex-wrap: wrap;
1215 }
1216
1217 .toc a {
1218 color: var(--accent-color);
1219 text-decoration: none;
1220 padding: 4px 8px;
1221 border-radius: 4px;
1222 }
1223
1224 .toc a:hover {
1225 background-color: rgba(137, 180, 250, 0.1);
1226 }
1227
1228 .back-to-top {
1229 display: inline-block;
1230 color: #a6adc8;
1231 text-decoration: none;
1232 font-size: 0.85em;
1233 margin-bottom: 20px;
1234 }
1235
1236 .back-to-top:hover {
1237 color: var(--accent-color);
1238 }
1239
1240 details summary {
1241 cursor: pointer;
1242 color: var(--accent-color);
1243 font-size: 0.85em;
1244 }
1245
1246 details summary:hover {
1247 text-decoration: underline;
1248 }
1249
1250 details[open] summary {
1251 margin-bottom: 6px;
1252 }
1253 </style>
1254";