Skip to main content

sbom_tools/reports/
html.rs

1//! HTML report generator.
2
3use 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 std::fmt::Write;
8
9/// A single vulnerability row: (id, severity, cvss_score, component_name, component_version).
10type VulnRow<'a> = (&'a str, &'a Option<crate::model::Severity>, Option<f32>, &'a str, Option<&'a str>);
11
12/// HTML report generator
13pub struct HtmlReporter {
14    /// Include inline CSS
15    include_styles: bool,
16}
17
18impl HtmlReporter {
19    /// Create a new HTML reporter
20    pub fn new() -> Self {
21        Self {
22            include_styles: true,
23        }
24    }
25
26    fn get_styles(&self) -> &'static str {
27        r#"
28        <style>
29            :root {
30                --bg-color: #1e1e2e;
31                --text-color: #cdd6f4;
32                --accent-color: #89b4fa;
33                --success-color: #a6e3a1;
34                --warning-color: #f9e2af;
35                --error-color: #f38ba8;
36                --border-color: #45475a;
37                --card-bg: #313244;
38            }
39
40            body {
41                font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
42                background-color: var(--bg-color);
43                color: var(--text-color);
44                margin: 0;
45                padding: 20px;
46                line-height: 1.6;
47            }
48
49            .container {
50                max-width: 1200px;
51                margin: 0 auto;
52            }
53
54            h1, h2, h3 {
55                color: var(--accent-color);
56            }
57
58            .header {
59                border-bottom: 2px solid var(--border-color);
60                padding-bottom: 20px;
61                margin-bottom: 30px;
62            }
63
64            .summary-cards {
65                display: grid;
66                grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
67                gap: 20px;
68                margin-bottom: 30px;
69            }
70
71            .card {
72                background-color: var(--card-bg);
73                border-radius: 8px;
74                padding: 20px;
75                border: 1px solid var(--border-color);
76            }
77
78            .card-title {
79                font-size: 0.9em;
80                color: #a6adc8;
81                margin-bottom: 10px;
82            }
83
84            .card-value {
85                font-size: 2em;
86                font-weight: bold;
87            }
88
89            .card-value.added { color: var(--success-color); }
90            .card-value.removed { color: var(--error-color); }
91            .card-value.modified { color: var(--warning-color); }
92            .card-value.critical { color: var(--error-color); }
93
94            table {
95                width: 100%;
96                border-collapse: collapse;
97                margin-bottom: 30px;
98                background-color: var(--card-bg);
99                border-radius: 8px;
100                overflow: hidden;
101            }
102
103            th, td {
104                padding: 12px 15px;
105                text-align: left;
106                border-bottom: 1px solid var(--border-color);
107            }
108
109            th {
110                background-color: #45475a;
111                font-weight: 600;
112            }
113
114            tr:hover {
115                background-color: #3b3d4d;
116            }
117
118            .badge {
119                display: inline-block;
120                padding: 2px 8px;
121                border-radius: 4px;
122                font-size: 0.85em;
123                font-weight: 500;
124            }
125
126            .badge-added { background-color: rgba(166, 227, 161, 0.2); color: var(--success-color); }
127            .badge-removed { background-color: rgba(243, 139, 168, 0.2); color: var(--error-color); }
128            .badge-modified { background-color: rgba(249, 226, 175, 0.2); color: var(--warning-color); }
129            .badge-critical { background-color: rgba(243, 139, 168, 0.3); color: var(--error-color); }
130            .badge-high { background-color: rgba(250, 179, 135, 0.3); color: #fab387; }
131            .badge-medium { background-color: rgba(249, 226, 175, 0.3); color: var(--warning-color); }
132            .badge-low { background-color: rgba(148, 226, 213, 0.3); color: #94e2d5; }
133            .badge-direct { background-color: rgba(46, 160, 67, 0.3); color: #2ea043; }
134            .badge-transitive { background-color: rgba(110, 118, 129, 0.3); color: #6e7681; }
135            .sla-overdue { background-color: rgba(248, 81, 73, 0.2); color: #f85149; font-weight: bold; }
136            .sla-due-soon { background-color: rgba(227, 179, 65, 0.2); color: #e3b341; }
137            .sla-on-track { color: #8b949e; }
138            .sla-unknown { color: #8b949e; }
139
140            .section {
141                margin-bottom: 40px;
142            }
143
144            .tabs {
145                display: flex;
146                border-bottom: 2px solid var(--border-color);
147                margin-bottom: 20px;
148            }
149
150            .tab {
151                padding: 10px 20px;
152                cursor: pointer;
153                border-bottom: 2px solid transparent;
154                margin-bottom: -2px;
155            }
156
157            .tab:hover {
158                color: var(--accent-color);
159            }
160
161            .tab.active {
162                border-bottom-color: var(--accent-color);
163                color: var(--accent-color);
164            }
165
166            .footer {
167                margin-top: 40px;
168                padding-top: 20px;
169                border-top: 1px solid var(--border-color);
170                font-size: 0.9em;
171                color: #a6adc8;
172            }
173        </style>
174        "#
175    }
176}
177
178impl Default for HtmlReporter {
179    fn default() -> Self {
180        Self::new()
181    }
182}
183
184impl ReportGenerator for HtmlReporter {
185    fn generate_diff_report(
186        &self,
187        result: &DiffResult,
188        _old_sbom: &NormalizedSbom,
189        _new_sbom: &NormalizedSbom,
190        config: &ReportConfig,
191    ) -> Result<String, ReportError> {
192        let mut html = String::new();
193
194        let title = config
195            .title
196            .clone()
197            .unwrap_or_else(|| "SBOM Diff Report".to_string());
198
199        // HTML header
200        writeln!(html, "<!DOCTYPE html>")?;
201        writeln!(html, "<html lang=\"en\">")?;
202        writeln!(html, "<head>")?;
203        writeln!(html, "    <meta charset=\"UTF-8\">")?;
204        writeln!(
205            html,
206            "    <meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\">"
207        )?;
208        writeln!(html, "    <title>{}</title>", escape_html(&title))?;
209        if self.include_styles {
210            writeln!(html, "{}", self.get_styles())?;
211        }
212        writeln!(html, "</head>")?;
213        writeln!(html, "<body>")?;
214        writeln!(html, "<div class=\"container\">")?;
215
216        // Header
217        writeln!(html, "<div class=\"header\">")?;
218        writeln!(html, "    <h1>{}</h1>", escape_html(&title))?;
219        writeln!(
220            html,
221            "    <p>Generated by sbom-tools v{} on {}</p>",
222            env!("CARGO_PKG_VERSION"),
223            chrono::Utc::now().format("%Y-%m-%d %H:%M:%S UTC")
224        )?;
225        writeln!(html, "</div>")?;
226
227        // Summary cards
228        writeln!(html, "<div class=\"summary-cards\">")?;
229        writeln!(html, "    <div class=\"card\">")?;
230        writeln!(
231            html,
232            "        <div class=\"card-title\">Components Added</div>"
233        )?;
234        writeln!(
235            html,
236            "        <div class=\"card-value added\">+{}</div>",
237            result.summary.components_added
238        )?;
239        writeln!(html, "    </div>")?;
240
241        writeln!(html, "    <div class=\"card\">")?;
242        writeln!(
243            html,
244            "        <div class=\"card-title\">Components Removed</div>"
245        )?;
246        writeln!(
247            html,
248            "        <div class=\"card-value removed\">-{}</div>",
249            result.summary.components_removed
250        )?;
251        writeln!(html, "    </div>")?;
252
253        writeln!(html, "    <div class=\"card\">")?;
254        writeln!(
255            html,
256            "        <div class=\"card-title\">Components Modified</div>"
257        )?;
258        writeln!(
259            html,
260            "        <div class=\"card-value modified\">~{}</div>",
261            result.summary.components_modified
262        )?;
263        writeln!(html, "    </div>")?;
264
265        writeln!(html, "    <div class=\"card\">")?;
266        writeln!(
267            html,
268            "        <div class=\"card-title\">Vulns Introduced</div>"
269        )?;
270        writeln!(
271            html,
272            "        <div class=\"card-value critical\">{}</div>",
273            result.summary.vulnerabilities_introduced
274        )?;
275        writeln!(html, "    </div>")?;
276
277        writeln!(html, "    <div class=\"card\">")?;
278        writeln!(
279            html,
280            "        <div class=\"card-title\">Semantic Score</div>"
281        )?;
282        writeln!(
283            html,
284            "        <div class=\"card-value\">{:.1}</div>",
285            result.semantic_score
286        )?;
287        writeln!(html, "    </div>")?;
288        writeln!(html, "</div>")?;
289
290        // Component changes section
291        if config.includes(ReportType::Components) && !result.components.is_empty() {
292            writeln!(html, "<div class=\"section\">")?;
293            writeln!(html, "    <h2>Component Changes</h2>")?;
294            writeln!(html, "    <table>")?;
295            writeln!(html, "        <thead>")?;
296            writeln!(html, "            <tr>")?;
297            writeln!(html, "                <th>Status</th>")?;
298            writeln!(html, "                <th>Name</th>")?;
299            writeln!(html, "                <th>Old Version</th>")?;
300            writeln!(html, "                <th>New Version</th>")?;
301            writeln!(html, "                <th>Ecosystem</th>")?;
302            writeln!(html, "            </tr>")?;
303            writeln!(html, "        </thead>")?;
304            writeln!(html, "        <tbody>")?;
305
306            for comp in &result.components.added {
307                writeln!(html, "            <tr>")?;
308                writeln!(
309                    html,
310                    "                <td><span class=\"badge badge-added\">Added</span></td>"
311                )?;
312                writeln!(html, "                <td>{}</td>", escape_html(&comp.name))?;
313                writeln!(html, "                <td>-</td>")?;
314                writeln!(
315                    html,
316                    "                <td>{}</td>",
317                    escape_html_opt(comp.new_version.as_deref())
318                )?;
319                writeln!(
320                    html,
321                    "                <td>{}</td>",
322                    escape_html_opt(comp.ecosystem.as_deref())
323                )?;
324                writeln!(html, "            </tr>")?;
325            }
326
327            for comp in &result.components.removed {
328                writeln!(html, "            <tr>")?;
329                writeln!(
330                    html,
331                    "                <td><span class=\"badge badge-removed\">Removed</span></td>"
332                )?;
333                writeln!(html, "                <td>{}</td>", escape_html(&comp.name))?;
334                writeln!(
335                    html,
336                    "                <td>{}</td>",
337                    escape_html_opt(comp.old_version.as_deref())
338                )?;
339                writeln!(html, "                <td>-</td>")?;
340                writeln!(
341                    html,
342                    "                <td>{}</td>",
343                    escape_html_opt(comp.ecosystem.as_deref())
344                )?;
345                writeln!(html, "            </tr>")?;
346            }
347
348            for comp in &result.components.modified {
349                writeln!(html, "            <tr>")?;
350                writeln!(
351                    html,
352                    "                <td><span class=\"badge badge-modified\">Modified</span></td>"
353                )?;
354                writeln!(html, "                <td>{}</td>", escape_html(&comp.name))?;
355                writeln!(
356                    html,
357                    "                <td>{}</td>",
358                    escape_html_opt(comp.old_version.as_deref())
359                )?;
360                writeln!(
361                    html,
362                    "                <td>{}</td>",
363                    escape_html_opt(comp.new_version.as_deref())
364                )?;
365                writeln!(
366                    html,
367                    "                <td>{}</td>",
368                    escape_html_opt(comp.ecosystem.as_deref())
369                )?;
370                writeln!(html, "            </tr>")?;
371            }
372
373            writeln!(html, "        </tbody>")?;
374            writeln!(html, "    </table>")?;
375            writeln!(html, "</div>")?;
376        }
377
378        // Vulnerability changes section
379        if config.includes(ReportType::Vulnerabilities)
380            && !result.vulnerabilities.introduced.is_empty()
381        {
382            writeln!(html, "<div class=\"section\">")?;
383            writeln!(html, "    <h2>Introduced Vulnerabilities</h2>")?;
384            writeln!(html, "    <table>")?;
385            writeln!(html, "        <thead>")?;
386            writeln!(html, "            <tr>")?;
387            writeln!(html, "                <th>ID</th>")?;
388            writeln!(html, "                <th>Severity</th>")?;
389            writeln!(html, "                <th>CVSS</th>")?;
390            writeln!(html, "                <th>SLA</th>")?;
391            writeln!(html, "                <th>Type</th>")?;
392            writeln!(html, "                <th>Component</th>")?;
393            writeln!(html, "                <th>Version</th>")?;
394            writeln!(html, "            </tr>")?;
395            writeln!(html, "        </thead>")?;
396            writeln!(html, "        <tbody>")?;
397
398            for vuln in &result.vulnerabilities.introduced {
399                let badge_class = match vuln.severity.to_lowercase().as_str() {
400                    "critical" => "badge-critical",
401                    "high" => "badge-high",
402                    "medium" => "badge-medium",
403                    _ => "badge-low",
404                };
405                let (depth_label, depth_class) = match vuln.component_depth {
406                    Some(1) => ("Direct", "badge-direct"),
407                    Some(_) => ("Transitive", "badge-transitive"),
408                    None => ("-", ""),
409                };
410                writeln!(html, "            <tr>")?;
411                writeln!(html, "                <td>{}</td>", escape_html(&vuln.id))?;
412                writeln!(
413                    html,
414                    "                <td><span class=\"badge {}\">{}</span></td>",
415                    badge_class,
416                    escape_html(&vuln.severity)
417                )?;
418                writeln!(
419                    html,
420                    "                <td>{}</td>",
421                    vuln.cvss_score
422                        .map(|s| format!("{:.1}", s))
423                        .unwrap_or_else(|| "-".to_string())
424                )?;
425                // SLA cell
426                let (sla_text, sla_class) = format_sla_html(vuln);
427                if sla_class.is_empty() {
428                    writeln!(html, "                <td>{}</td>", sla_text)?;
429                } else {
430                    writeln!(
431                        html,
432                        "                <td><span class=\"{}\">{}</span></td>",
433                        sla_class, sla_text
434                    )?;
435                }
436                if depth_class.is_empty() {
437                    writeln!(html, "                <td>{}</td>", depth_label)?;
438                } else {
439                    writeln!(
440                        html,
441                        "                <td><span class=\"badge {}\">{}</span></td>",
442                        depth_class, depth_label
443                    )?;
444                }
445                writeln!(
446                    html,
447                    "                <td>{}</td>",
448                    escape_html(&vuln.component_name)
449                )?;
450                writeln!(
451                    html,
452                    "                <td>{}</td>",
453                    escape_html_opt(vuln.version.as_deref())
454                )?;
455                writeln!(html, "            </tr>")?;
456            }
457
458            writeln!(html, "        </tbody>")?;
459            writeln!(html, "    </table>")?;
460            writeln!(html, "</div>")?;
461        }
462
463        // Footer
464        writeln!(html, "<div class=\"footer\">")?;
465        writeln!(html, "    <p>Generated by <a href=\"https://github.com/binarly-io/sbom-tools\">sbom-tools</a></p>")?;
466        writeln!(html, "</div>")?;
467
468        writeln!(html, "</div>")?;
469        writeln!(html, "</body>")?;
470        writeln!(html, "</html>")?;
471
472        Ok(html)
473    }
474
475    fn generate_view_report(
476        &self,
477        sbom: &NormalizedSbom,
478        config: &ReportConfig,
479    ) -> Result<String, ReportError> {
480        use std::collections::HashSet;
481
482        let mut html = String::new();
483
484        let title = config
485            .title
486            .clone()
487            .unwrap_or_else(|| "SBOM Report".to_string());
488
489        // Compute statistics
490        let total_components = sbom.component_count();
491        let vulnerable_components: Vec<_> = sbom
492            .components
493            .values()
494            .filter(|c| !c.vulnerabilities.is_empty())
495            .collect();
496        let vuln_component_count = vulnerable_components.len();
497        let total_vulns: usize = sbom
498            .components
499            .values()
500            .map(|c| c.vulnerabilities.len())
501            .sum();
502        let ecosystems: HashSet<_> = sbom
503            .components
504            .values()
505            .filter_map(|c| c.ecosystem.as_ref())
506            .collect();
507        let licenses: HashSet<String> = sbom
508            .components
509            .values()
510            .flat_map(|c| c.licenses.declared.iter().map(|l| l.expression.clone()))
511            .collect();
512
513        // HTML header
514        writeln!(html, "<!DOCTYPE html>")?;
515        writeln!(html, "<html lang=\"en\">")?;
516        writeln!(html, "<head>")?;
517        writeln!(html, "    <meta charset=\"UTF-8\">")?;
518        writeln!(
519            html,
520            "    <meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\">"
521        )?;
522        writeln!(html, "    <title>{}</title>", escape_html(&title))?;
523        if self.include_styles {
524            writeln!(html, "{}", self.get_styles())?;
525        }
526        writeln!(html, "</head>")?;
527        writeln!(html, "<body>")?;
528        writeln!(html, "<div class=\"container\">")?;
529
530        // Header
531        writeln!(html, "<div class=\"header\">")?;
532        writeln!(html, "    <h1>{}</h1>", escape_html(&title))?;
533        if let Some(ref name) = sbom.document.name {
534            writeln!(html, "    <p>Document: {}</p>", escape_html(name))?;
535        }
536        writeln!(
537            html,
538            "    <p>Generated by sbom-tools v{} on {}</p>",
539            env!("CARGO_PKG_VERSION"),
540            chrono::Utc::now().format("%Y-%m-%d %H:%M:%S UTC")
541        )?;
542        writeln!(html, "</div>")?;
543
544        // Summary cards
545        writeln!(html, "<div class=\"summary-cards\">")?;
546
547        writeln!(html, "    <div class=\"card\">")?;
548        writeln!(
549            html,
550            "        <div class=\"card-title\">Total Components</div>"
551        )?;
552        writeln!(
553            html,
554            "        <div class=\"card-value\">{}</div>",
555            total_components
556        )?;
557        writeln!(html, "    </div>")?;
558
559        writeln!(html, "    <div class=\"card\">")?;
560        writeln!(
561            html,
562            "        <div class=\"card-title\">Vulnerable Components</div>"
563        )?;
564        let vuln_class = if vuln_component_count > 0 {
565            "critical"
566        } else {
567            ""
568        };
569        writeln!(
570            html,
571            "        <div class=\"card-value {}\">{}</div>",
572            vuln_class, vuln_component_count
573        )?;
574        writeln!(html, "    </div>")?;
575
576        writeln!(html, "    <div class=\"card\">")?;
577        writeln!(
578            html,
579            "        <div class=\"card-title\">Total Vulnerabilities</div>"
580        )?;
581        let total_vuln_class = if total_vulns > 0 { "critical" } else { "" };
582        writeln!(
583            html,
584            "        <div class=\"card-value {}\">{}</div>",
585            total_vuln_class, total_vulns
586        )?;
587        writeln!(html, "    </div>")?;
588
589        writeln!(html, "    <div class=\"card\">")?;
590        writeln!(html, "        <div class=\"card-title\">Ecosystems</div>")?;
591        writeln!(
592            html,
593            "        <div class=\"card-value\">{}</div>",
594            ecosystems.len()
595        )?;
596        writeln!(html, "    </div>")?;
597
598        writeln!(html, "    <div class=\"card\">")?;
599        writeln!(
600            html,
601            "        <div class=\"card-title\">Unique Licenses</div>"
602        )?;
603        writeln!(
604            html,
605            "        <div class=\"card-value\">{}</div>",
606            licenses.len()
607        )?;
608        writeln!(html, "    </div>")?;
609
610        writeln!(html, "</div>")?;
611
612        // Components table
613        if config.includes(ReportType::Components) && total_components > 0 {
614            writeln!(html, "<div class=\"section\">")?;
615            writeln!(html, "    <h2>Components</h2>")?;
616            writeln!(html, "    <table>")?;
617            writeln!(html, "        <thead>")?;
618            writeln!(html, "            <tr>")?;
619            writeln!(html, "                <th>Name</th>")?;
620            writeln!(html, "                <th>Version</th>")?;
621            writeln!(html, "                <th>Ecosystem</th>")?;
622            writeln!(html, "                <th>License</th>")?;
623            writeln!(html, "                <th>Vulnerabilities</th>")?;
624            writeln!(html, "            </tr>")?;
625            writeln!(html, "        </thead>")?;
626            writeln!(html, "        <tbody>")?;
627
628            // Sort components by name for consistent output
629            let mut components: Vec<_> = sbom.components.values().collect();
630            components.sort_by(|a, b| a.name.cmp(&b.name));
631
632            for comp in components {
633                let license_str = comp
634                    .licenses
635                    .declared
636                    .first()
637                    .map(|l| l.expression.as_str())
638                    .unwrap_or("-");
639                let vuln_count = comp.vulnerabilities.len();
640                let vuln_badge = if vuln_count > 0 {
641                    format!(
642                        "<span class=\"badge badge-critical\">{}</span>",
643                        vuln_count
644                    )
645                } else {
646                    "0".to_string()
647                };
648
649                writeln!(html, "            <tr>")?;
650                writeln!(html, "                <td>{}</td>", escape_html(&comp.name))?;
651                writeln!(
652                    html,
653                    "                <td>{}</td>",
654                    escape_html_opt(comp.version.as_deref())
655                )?;
656                writeln!(
657                    html,
658                    "                <td>{}</td>",
659                    comp.ecosystem
660                        .as_ref()
661                        .map(|e| escape_html(&format!("{:?}", e)))
662                        .unwrap_or_else(|| "-".to_string())
663                )?;
664                writeln!(html, "                <td>{}</td>", escape_html(license_str))?;
665                writeln!(html, "                <td>{}</td>", vuln_badge)?;
666                writeln!(html, "            </tr>")?;
667            }
668
669            writeln!(html, "        </tbody>")?;
670            writeln!(html, "    </table>")?;
671            writeln!(html, "</div>")?;
672        }
673
674        // Vulnerabilities table
675        if config.includes(ReportType::Vulnerabilities) && total_vulns > 0 {
676            writeln!(html, "<div class=\"section\">")?;
677            writeln!(html, "    <h2>Vulnerabilities</h2>")?;
678            writeln!(html, "    <table>")?;
679            writeln!(html, "        <thead>")?;
680            writeln!(html, "            <tr>")?;
681            writeln!(html, "                <th>ID</th>")?;
682            writeln!(html, "                <th>Severity</th>")?;
683            writeln!(html, "                <th>CVSS</th>")?;
684            writeln!(html, "                <th>Component</th>")?;
685            writeln!(html, "                <th>Version</th>")?;
686            writeln!(html, "            </tr>")?;
687            writeln!(html, "        </thead>")?;
688            writeln!(html, "        <tbody>")?;
689
690            // Collect all vulnerabilities with their component info
691            let mut all_vulns: Vec<VulnRow<'_>> = sbom
692                .components
693                .values()
694                .flat_map(|comp| {
695                    comp.vulnerabilities.iter().map(move |v| {
696                        (
697                            v.id.as_str(),
698                            &v.severity,
699                            v.cvss.first().map(|c| c.base_score),
700                            comp.name.as_str(),
701                            comp.version.as_deref(),
702                        )
703                    })
704                })
705                .collect();
706
707            // Sort by severity (critical first)
708            all_vulns.sort_by(|a, b| {
709                let sev_order = |s: &Option<crate::model::Severity>| match s {
710                    Some(crate::model::Severity::Critical) => 0,
711                    Some(crate::model::Severity::High) => 1,
712                    Some(crate::model::Severity::Medium) => 2,
713                    Some(crate::model::Severity::Low) => 3,
714                    Some(crate::model::Severity::Info) => 4,
715                    _ => 5,
716                };
717                sev_order(a.1).cmp(&sev_order(b.1))
718            });
719
720            for (id, severity, cvss, comp_name, version) in all_vulns {
721                let (badge_class, sev_str) = match severity {
722                    Some(crate::model::Severity::Critical) => ("badge-critical", "Critical"),
723                    Some(crate::model::Severity::High) => ("badge-high", "High"),
724                    Some(crate::model::Severity::Medium) => ("badge-medium", "Medium"),
725                    Some(crate::model::Severity::Low) => ("badge-low", "Low"),
726                    Some(crate::model::Severity::Info) => ("badge-low", "Info"),
727                    _ => ("badge-low", "Unknown"),
728                };
729
730                writeln!(html, "            <tr>")?;
731                writeln!(html, "                <td>{}</td>", escape_html(id))?;
732                writeln!(
733                    html,
734                    "                <td><span class=\"badge {}\">{}</span></td>",
735                    badge_class, sev_str
736                )?;
737                writeln!(
738                    html,
739                    "                <td>{}</td>",
740                    cvss.map(|s| format!("{:.1}", s))
741                        .unwrap_or_else(|| "-".to_string())
742                )?;
743                writeln!(html, "                <td>{}</td>", escape_html(comp_name))?;
744                writeln!(
745                    html,
746                    "                <td>{}</td>",
747                    escape_html_opt(version)
748                )?;
749                writeln!(html, "            </tr>")?;
750            }
751
752            writeln!(html, "        </tbody>")?;
753            writeln!(html, "    </table>")?;
754            writeln!(html, "</div>")?;
755        }
756
757        // Footer
758        writeln!(html, "<div class=\"footer\">")?;
759        writeln!(html, "    <p>Generated by <a href=\"https://github.com/binarly-io/sbom-tools\">sbom-tools</a></p>")?;
760        writeln!(html, "</div>")?;
761
762        writeln!(html, "</div>")?;
763        writeln!(html, "</body>")?;
764        writeln!(html, "</html>")?;
765
766        Ok(html)
767    }
768
769    fn format(&self) -> ReportFormat {
770        ReportFormat::Html
771    }
772}
773
774/// Format SLA status for HTML display
775fn format_sla_html(vuln: &VulnerabilityDetail) -> (String, &'static str) {
776    match vuln.sla_status() {
777        SlaStatus::Overdue(days) => (format!("{}d late", days), "sla-overdue"),
778        SlaStatus::DueSoon(days) => (format!("{}d left", days), "sla-due-soon"),
779        SlaStatus::OnTrack(days) => (format!("{}d left", days), "sla-on-track"),
780        SlaStatus::NoDueDate => {
781            let text = vuln
782                .days_since_published
783                .map(|d| format!("{}d old", d))
784                .unwrap_or_else(|| "-".to_string());
785            (text, "sla-unknown")
786        }
787    }
788}