Skip to content

Commit

Permalink
feat(output): update HTML output to a new design (#1383)
Browse files Browse the repository at this point in the history
HTML output added #1258

New design (sample one: https://hogo6002.github.io/scanner-report/):
- Changed the color scheme to match osv.dev.
- Merged the package view and vulnerability view into a single nested
table.
- Added a layer view.
- Added a vulnerability type filter (project, OS, and uncalled).
- Added a vulnerability search bar.
- Refined the vulnerability severity count design.
- Integrated the osv.dev vulnerability page into results (allowing users
to click to view details).
- Improved the UI.

Future plan:
Use frontend framework to enhance code maintainability and readability


![image](https://github.com/user-attachments/assets/5aa1559d-3d41-4ce1-9aff-6e68c25200ac)

![image](https://github.com/user-attachments/assets/e23d7a97-b295-4d10-a8cc-1350dd33576e)

![image](https://github.com/user-attachments/assets/849dc0cf-2b12-4bc9-a08a-10b426d16155)

![image](https://github.com/user-attachments/assets/ba787cda-3e07-479a-976d-171de4db6c7e)

---------

Signed-off-by: Holly Gong <[email protected]>
  • Loading branch information
hogo6002 authored Nov 13, 2024
1 parent 9a303ec commit 9ea8aa5
Show file tree
Hide file tree
Showing 12 changed files with 1,353 additions and 14,080 deletions.
13,810 changes: 0 additions & 13,810 deletions internal/output/__snapshots__/html_test.snap

This file was deleted.

206 changes: 154 additions & 52 deletions internal/output/html.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,9 @@ package output
import (
"cmp"
"embed"
"fmt"
"html/template"
"io"
"slices"
"strconv"
"strings"

"github.com/google/osv-scanner/internal/cachedregexp"
Expand All @@ -19,14 +17,18 @@ import (

// HTMLResult represents the vulnerability scanning results for HTML report.
type HTMLResult struct {
HTMLVulnCount HTMLVulnCount
EcosystemResults []HTMLEcosystemResult
EcosystemResults []HTMLEcosystemResult
IsContainerScanning bool
AllLayers []HTMLLayerInfo
HTMLVulnTypeCount HTMLVulnTypeCount
HTMLVulnCount HTMLVulnCount
}

// HTMLEcosystemResult represents the vulnerability scanning results for an ecosystem.
type HTMLEcosystemResult struct {
Ecosystem string
Sources []HTMLSourceResult
IsOS bool
}

// HTMLSourceResult represents the vulnerability scanning results for a source file.
Expand All @@ -41,14 +43,15 @@ type HTMLSourceResult struct {

// HTMLPackageResult represents the vulnerability scanning results for a package.
type HTMLPackageResult struct {
Name string
Ecosystem string
Source string
CalledVulns []HTMLVulnResult
UncalledVulns []HTMLVulnResult
InstalledVersion string
FixedVersion string
HTMLVulnCount HTMLVulnCount
Name string
Ecosystem string
Source string
CalledVulns []HTMLVulnResult
UncalledVulns []HTMLVulnResult
InstalledVersion string
FixedVersion string
HTMLVulnCount HTMLVulnCount
HTMLPackageLayerDetail HTMLPackageLayerDetail
}

// HTMLVulnResult represents a single vulnerability.
Expand All @@ -67,15 +70,30 @@ type HTMLVulnResultSummary struct {
SeverityScore string
}

// HTMLPackageLayerDetail represents detailed layer tracing information about a package.
type HTMLPackageLayerDetail struct {
LayerCommand string
LayerCommandTooltip string
LayerID string
InBaseImage bool
}

// HTMLVulnResultDetail represents detailed information about a vulnerability.
type HTMLVulnResultDetail struct {
GroupIDs []string
CVE string
Aliases []string
LayerCommand string
LayerCommandTooltip string
LayerID string
InBaseImage string
Description string
InBaseImage bool
}

type HTMLLayerInfo struct {
Index int
LayerCommand string
LayerID string
Count HTMLVulnCount
}

// HTMLVulnCount represents the counts of vulnerabilities by severity and fixed/unfixed status
Expand All @@ -91,8 +109,16 @@ type HTMLVulnCount struct {
UnFixed int
}

type HTMLVulnTypeCount struct {
All int
OS int
Project int
Uncalled int
}

const UnfixedDescription = "No fix available"
const VersionUnsupported = "N/A"
const UnknownRating = "UNKNOWN"

// HTML templates directory
const TemplateDir = "html/*"
Expand Down Expand Up @@ -207,16 +233,30 @@ func processPackageResults(allVulns []HTMLVulnResult, groupIDs map[string]models

packageName := vuln.Summary.PackageName
packageResult, exist := packageResults[packageName]
var packageDetail HTMLPackageLayerDetail
if vuln.Detail.LayerCommand != "" {
packageDetail = HTMLPackageLayerDetail{
LayerCommand: vuln.Detail.LayerCommand,
LayerID: vuln.Detail.LayerID,
LayerCommandTooltip: vuln.Detail.LayerCommandTooltip,
InBaseImage: vuln.Detail.InBaseImage,
}
}

if !exist {
packageResult = &HTMLPackageResult{
Name: packageName,
Name: packageName,
HTMLPackageLayerDetail: packageDetail,
}
packageResults[packageName] = packageResult
}

// Get the max severity from groupInfo and increase the count
vuln.Summary.SeverityScore = groupInfo.MaxSeverity
vuln.Summary.SeverityRating, _ = severity.CalculateRating(vuln.Summary.SeverityScore)
if vuln.Summary.SeverityRating == UnknownRating {
vuln.Summary.SeverityScore = "N/A"
}

if _, isUncalled := uncalledVulnIDs[vuln.Summary.ID]; isUncalled {
packageResult.UncalledVulns = append(packageResult.UncalledVulns, vuln)
Expand All @@ -240,6 +280,9 @@ func processPackageResults(allVulns []HTMLVulnResult, groupIDs map[string]models
if len(result.CalledVulns) > 0 {
result.InstalledVersion = result.CalledVulns[0].Summary.InstalledVersion
result.FixedVersion = getMaxFixedVersion(ecosystemPrefix, result.CalledVulns)
} else {
result.InstalledVersion = result.UncalledVulns[0].Summary.InstalledVersion
result.FixedVersion = getMaxFixedVersion(ecosystemPrefix, result.UncalledVulns)
}

results = append(results, *result)
Expand All @@ -261,14 +304,16 @@ func processPackageResults(allVulns []HTMLVulnResult, groupIDs map[string]models
func processVulnerabilities(vulnPkg models.PackageVulns) []HTMLVulnResult {
vulnResults := make([]HTMLVulnResult, len(vulnPkg.Vulnerabilities))
for i, vuln := range vulnPkg.Vulnerabilities {
// Sort aliases to make sure CVE show at the first
slices.SortFunc(vuln.Aliases, identifiers.IDSortFunc)
vulnDetails := HTMLVulnResultDetail{
Aliases: vuln.Aliases,
Description: vuln.Details,
Aliases: vuln.Aliases,
}

if vulnPkg.Package.ImageOrigin != nil {
vulnDetails.LayerCommand, vulnDetails.LayerCommandTooltip = formatLayerCommand(vulnPkg.Package.ImageOrigin.OriginCommand)
vulnDetails.LayerID = vulnPkg.Package.ImageOrigin.LayerID
vulnDetails.InBaseImage = strconv.FormatBool(vulnPkg.Package.ImageOrigin.InBaseImage)
vulnDetails.InBaseImage = vulnPkg.Package.ImageOrigin.InBaseImage
}

fixedVersion := getFixVersion(vuln.Affected, vulnPkg.Package.Version, vulnPkg.Package.Name, models.Ecosystem(vulnPkg.Package.Ecosystem))
Expand Down Expand Up @@ -323,6 +368,7 @@ func buildHTMLResult(ecosystemMap map[string][]HTMLSourceResult, resultCount HTM
}

if isOSImage(ecosystem) {
ecosystemResult.IsOS = true
osResults = append(osResults, ecosystemResult)
} else {
ecosystemResults = append(ecosystemResults, ecosystemResult)
Expand All @@ -336,10 +382,86 @@ func buildHTMLResult(ecosystemMap map[string][]HTMLSourceResult, resultCount HTM

ecosystemResults = append(ecosystemResults, osResults...)

isContainerScanning := false
layers := getAllLayers(ecosystemResults)
if len(layers) > 0 {
isContainerScanning = true
}
vulnTypeCount := getVulnTypeCount(ecosystemResults)

return HTMLResult{
EcosystemResults: ecosystemResults,
HTMLVulnCount: resultCount,
EcosystemResults: ecosystemResults,
HTMLVulnCount: resultCount,
IsContainerScanning: isContainerScanning,
AllLayers: layers,
HTMLVulnTypeCount: vulnTypeCount,
}
}

func getVulnTypeCount(result []HTMLEcosystemResult) HTMLVulnTypeCount {
var vulnCount HTMLVulnTypeCount

for _, ecosystem := range result {
for _, source := range ecosystem.Sources {
if ecosystem.IsOS {
vulnCount.OS += source.HTMLVulnCount.Called
} else {
vulnCount.Project += source.HTMLVulnCount.Called
}
vulnCount.Uncalled += source.HTMLVulnCount.Uncalled
}
}

vulnCount.All = vulnCount.OS + vulnCount.Project

return vulnCount
}

func getAllLayers(result []HTMLEcosystemResult) []HTMLLayerInfo {
layerMap := make(map[string]string)
layerCount := make(map[string]HTMLVulnCount)
layerIndex := 0

for _, ecosystem := range result {
for _, source := range ecosystem.Sources {
for _, packageInfo := range source.PackageResults {
layerID := packageInfo.HTMLPackageLayerDetail.LayerID
layerCommand := packageInfo.HTMLPackageLayerDetail.LayerCommand

// Check if this layer ID and command combination is already in the map
if _, ok := layerMap[layerID]; !ok {
var resultCount HTMLVulnCount
updateCount(&resultCount, &packageInfo.HTMLVulnCount)
layerMap[layerID] = layerCommand // Store the layer ID and command
layerCount[layerID] = resultCount
layerIndex++
} else {
resultCount := layerCount[layerID]
updateCount(&resultCount, &packageInfo.HTMLVulnCount)
layerCount[layerID] = resultCount
}
}
}
}

// Convert the map to a slice of LayerInfo
layers := make([]HTMLLayerInfo, 0, len(layerMap))
i := 0
for layerID, layerCommand := range layerMap {
if layerCommand == "" {
continue
}
layers = append(layers, HTMLLayerInfo{
// TODO(gongh@): replace with the actual layer index
Index: i,
LayerCommand: layerCommand,
LayerID: layerID,
Count: layerCount[layerID],
})
i++
}

return layers
}

func updateCount(original *HTMLVulnCount, newAdded *HTMLVulnCount) {
Expand Down Expand Up @@ -459,19 +581,6 @@ func getMaxFixedVersion(ecosystemPrefix models.Ecosystem, allVulns []HTMLVulnRes
return maxFixVersion
}

func getAllVulns(packageResults []HTMLPackageResult, isCalled bool) []HTMLVulnResult {
var results []HTMLVulnResult
for _, packageResult := range packageResults {
if isCalled {
results = append(results, packageResult.CalledVulns...)
} else {
results = append(results, packageResult.UncalledVulns...)
}
}

return results
}

func getAllPackageResults(ecosystemResults []HTMLEcosystemResult) []HTMLPackageResult {
var results []HTMLPackageResult
for _, ecosystemResult := range ecosystemResults {
Expand All @@ -486,40 +595,33 @@ func getAllPackageResults(ecosystemResults []HTMLEcosystemResult) []HTMLPackageR
// formatLayerCommand formats the layer command output for better readability.
// It replaces the unreadable file ID with "UNKNOWN" and extracting the ID separately.
func formatLayerCommand(command string) (string, string) {
re := cachedregexp.MustCompile(`dir:([a-f0-9]+)`)
re := cachedregexp.MustCompile(`(dir|file):([a-f0-9]+)`)
match := re.FindStringSubmatch(command)

if len(match) > 1 {
hash := match[1]
newCommand := re.ReplaceAllString(command, "dir:UNKNOWN")
if len(match) > 2 {
prefix := match[1] // Capture "dir" or "file"
hash := match[2] // Capture the hash ID
newCommand := re.ReplaceAllString(command, prefix+":UNKNOWN")

return newCommand, "File ID: " + hash
}

return command, ""
}

func printSeverityCount(count HTMLVulnCount) string {
result := fmt.Sprintf("CRITICAL: %d, HIGH: %d, MEDIUM: %d, LOW: %d, UNKNOWN: %d", count.Critical, count.High, count.Medium, count.Low, count.Unknown)
return result
}

func printSeverityCountShort(count HTMLVulnCount) string {
return fmt.Sprintf("%dC | %dH | %dM | %dL | %dU", count.Critical, count.High, count.Medium, count.Low, count.Unknown)
}

func PrintHTMLResults(vulnResult *models.VulnerabilityResults, outputWriter io.Writer) error {
htmlResult := BuildHTMLResults(vulnResult)
vulnIndex := 0

// Parse embedded templates
funcMap := template.FuncMap{
"uniqueID": uniqueIndex(&vulnIndex),
"getAllVulns": getAllVulns,
"getAllPackageResults": getAllPackageResults,
"printSeverityCount": printSeverityCount,
"printSeverityCountShort": printSeverityCountShort,
"join": strings.Join,
"uniqueID": uniqueIndex(&vulnIndex),
"getAllPackageResults": getAllPackageResults,
"join": strings.Join,
"toLower": strings.ToLower,
"add": func(a, b int) int {
return a + b
},
}

tmpl := template.Must(template.New("").Funcs(funcMap).ParseFS(templates, TemplateDir))
Expand Down
Loading

0 comments on commit 9ea8aa5

Please sign in to comment.