feat: add HTML report output format#23
Conversation
Adds --format html for generating self-contained HTML reports suitable for auditors, compliance documentation, and non-technical stakeholders. Both diff mode (two SBOMs) and stats mode (single SBOM) are supported. The HTML report includes: - Side-by-side SBOM comparison overview - Summary cards for added/removed/changed counts - Key findings section - Drift summary with severity badges - Dependency depth risk table - Policy violation tables (errors + warnings) - Collapsible component detail tables with type and license info - XSS-safe output (all values HTML-escaped) The report is completely self-contained (embedded CSS, no external dependencies) and can be opened directly in any browser, attached to emails, or stored as compliance artifacts. Closes rezmoss#4
There was a problem hiding this comment.
Pull request overview
This PR adds HTML report output format support to sbomlyze, enabling generation of self-contained, audit-friendly HTML reports. The implementation follows established patterns from other output formats (markdown, SARIF, JUnit) and includes comprehensive test coverage.
Changes:
- Added
FormatHTMLconstant and HTML generation functions (GenerateHTMLfor diffs,GenerateHTMLStatsfor single SBOMs) - Integrated HTML format into CLI with
--format htmlflag and updated help text - Added 13 comprehensive tests covering structure, XSS protection, policy violations, drift summary, and edge cases
Reviewed changes
Copilot reviewed 7 out of 7 changed files in this pull request and generated 5 comments.
Show a summary per file
| File | Description |
|---|---|
| internal/output/format.go | Adds FormatHTML constant to enum of output formats |
| internal/output/html.go | Implements HTML generation with embedded CSS, proper escaping, and support for both diff and stats modes |
| internal/output/html_test.go | Comprehensive test suite covering valid structure, XSS protection, component rendering, policy violations, and edge cases |
| cmd/sbomlyze/main.go | Integrates HTML format into switch statement for both single-file and diff modes |
| internal/cli/usage.go | Updates help text to include HTML format in list and examples |
| testdata/snapshots/no_args.stderr | Updates help text snapshot to include HTML format |
| testdata/snapshots/help_long.stderr | Updates long help text snapshot to include HTML format |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| fmt.Fprintf(sb, "<tr><td><strong>Hash Coverage</strong></td><td>%s</td><td>%s</td></tr>\n", | ||
| formatPct(b.Stats.WithHashes, b.Stats.TotalComponents), | ||
| formatPct(a.Stats.WithHashes, a.Stats.TotalComponents)) | ||
| sb.WriteString("</table>\n") |
There was a problem hiding this comment.
The HTML diff overview is missing CPE Coverage, which is included in both the text and markdown formats. This creates an inconsistency across output formats. The CPE Coverage metric should be added to the overview table to match the completeness of other formats.
| sb.WriteString("<h2>SBOM Comparison</h2>\n<table>\n") | ||
| sb.WriteString("<tr><th></th><th>Before</th><th>After</th></tr>\n") | ||
| fmt.Fprintf(sb, "<tr><td><strong>File</strong></td><td>%s</td><td>%s</td></tr>\n", | ||
| html.EscapeString(filepath.Base(b.FileName)), html.EscapeString(filepath.Base(a.FileName))) | ||
| fmt.Fprintf(sb, "<tr><td><strong>File Size</strong></td><td>%s</td><td>%s</td></tr>\n", | ||
| formatFileSize(b.FileSize), formatFileSize(a.FileSize)) | ||
| fmt.Fprintf(sb, "<tr><td><strong>Format</strong></td><td>%s</td><td>%s</td></tr>\n", | ||
| html.EscapeString(orNone(b.Info.ToolName)), html.EscapeString(orNone(a.Info.ToolName))) | ||
| fmt.Fprintf(sb, "<tr><td><strong>Components</strong></td><td>%d</td><td>%d</td></tr>\n", | ||
| b.Stats.TotalComponents, a.Stats.TotalComponents) | ||
| fmt.Fprintf(sb, "<tr><td><strong>PURL Coverage</strong></td><td>%s</td><td>%s</td></tr>\n", | ||
| formatPct(b.Stats.WithPURL, b.Stats.TotalComponents), | ||
| formatPct(a.Stats.WithPURL, a.Stats.TotalComponents)) | ||
| fmt.Fprintf(sb, "<tr><td><strong>License Coverage</strong></td><td>%s</td><td>%s</td></tr>\n", | ||
| formatPct(b.Stats.TotalComponents-b.Stats.WithoutLicense, b.Stats.TotalComponents), | ||
| formatPct(a.Stats.TotalComponents-a.Stats.WithoutLicense, a.Stats.TotalComponents)) | ||
| fmt.Fprintf(sb, "<tr><td><strong>Hash Coverage</strong></td><td>%s</td><td>%s</td></tr>\n", | ||
| formatPct(b.Stats.WithHashes, b.Stats.TotalComponents), | ||
| formatPct(a.Stats.WithHashes, a.Stats.TotalComponents)) | ||
| sb.WriteString("</table>\n") |
There was a problem hiding this comment.
The HTML diff overview is missing several fields that are present in text and markdown formats: OS (OSPrettyName), Source (SourceName), and Source Type (SourceType). This creates inconsistency across output formats. These fields should be added to the overview table for completeness.
| func writeHTMLRow(sb *strings.Builder, col1, col2 string) { | ||
| fmt.Fprintf(sb, "<tr><td>%s</td><td>%s</td></tr>\n", col1, col2) | ||
| } |
There was a problem hiding this comment.
The writeHTMLRow helper function is called with unescaped data from parsed SBOM files in multiple places (lines 125-129, 151, 167, 190). Since SBOM files can contain arbitrary user-controlled strings, this creates XSS vulnerabilities. All data passed to writeHTMLRow should be HTML-escaped before being passed in, or writeHTMLRow itself should escape its parameters. The safest approach would be to have writeHTMLRow perform the escaping to prevent future mistakes.
| fmt.Fprintf(sb, "<details>\n<summary>%s (%d)</summary>\n<table>\n", | ||
| html.EscapeString(title), len(comps)) |
There was a problem hiding this comment.
The class parameter in writeHTMLComponentSection is passed but never used in the function body. This is dead code that should be removed to improve code clarity.
| fmt.Fprintf(sb, "<details>\n<summary>%s (%d)</summary>\n<table>\n", | |
| html.EscapeString(title), len(comps)) | |
| classAttr := "" | |
| if class != "" { | |
| classAttr = fmt.Sprintf(" class=\"%s\"", html.EscapeString(class)) | |
| } | |
| fmt.Fprintf(sb, "<details%s>\n<summary>%s (%d)</summary>\n<table>\n", | |
| classAttr, html.EscapeString(title), len(comps)) |
| func writeHTMLCardStyled(sb *strings.Builder, num, label, color string) { | ||
| fmt.Fprintf(sb, "<div class=\"summary-card\" style=\"border-color: %s;\"><div class=\"num\" style=\"color: %s;\">%s</div><div class=\"label\">%s</div></div>\n", | ||
| color, color, html.EscapeString(num), html.EscapeString(label)) |
There was a problem hiding this comment.
The color parameter in writeHTMLCardStyled is used directly in inline CSS without validation or escaping. While currently only called with the constant "var(--red)", this creates a potential CSS injection vulnerability if the function is ever called with user-controlled data. Consider validating that the color parameter matches expected CSS variable syntax or is from a whitelist.
| func writeHTMLCardStyled(sb *strings.Builder, num, label, color string) { | |
| fmt.Fprintf(sb, "<div class=\"summary-card\" style=\"border-color: %s;\"><div class=\"num\" style=\"color: %s;\">%s</div><div class=\"label\">%s</div></div>\n", | |
| color, color, html.EscapeString(num), html.EscapeString(label)) | |
| // sanitizeCSSColor ensures that only safe CSS variable references of the form | |
| // var(--name) are allowed, where name consists of [A-Za-z0-9_-]+. | |
| // If the input does not match this pattern, a safe default is returned. | |
| func sanitizeCSSColor(color string) string { | |
| const defaultColor = "var(--red)" | |
| if strings.HasPrefix(color, "var(--") && strings.HasSuffix(color, ")") { | |
| name := color[len("var(--") : len(color)-1] | |
| if name == "" { | |
| return defaultColor | |
| } | |
| for _, ch := range name { | |
| if (ch >= 'a' && ch <= 'z') || | |
| (ch >= 'A' && ch <= 'Z') || | |
| (ch >= '0' && ch <= '9') || | |
| ch == '-' || ch == '_' { | |
| continue | |
| } | |
| return defaultColor | |
| } | |
| return color | |
| } | |
| return defaultColor | |
| } | |
| func writeHTMLCardStyled(sb *strings.Builder, num, label, color string) { | |
| safeColor := sanitizeCSSColor(color) | |
| fmt.Fprintf(sb, "<div class=\"summary-card\" style=\"border-color: %s;\"><div class=\"num\" style=\"color: %s;\">%s</div><div class=\"label\">%s</div></div>\n", | |
| safeColor, safeColor, html.EscapeString(num), html.EscapeString(label)) |
Adds
--format htmlfor self-contained HTML reports (diff + stats mode). Embedded CSS, no external deps, XSS-safe. 13 tests passing.Closes #4