From 56bf0ec25bd6e545619eb4579876e6af9709653a Mon Sep 17 00:00:00 2001 From: Starttoaster Date: Tue, 7 Jul 2026 11:31:45 -0700 Subject: [PATCH 1/3] Add start to CVE view and more UI context filters --- internal/cve/cve.go | 314 +++++++++++++++++++++++++++ internal/mcp/cves.go | 322 ++-------------------------- internal/source/source.go | 30 +++ internal/store/store.go | 7 + internal/web/api.go | 11 + internal/web/cluster_render_test.go | 104 ++++++++- internal/web/cves.go | 116 ++++++++++ internal/web/openapi.json | 86 ++++++++ internal/web/server.go | 20 ++ internal/web/views/images/images.go | 25 +++ internal/web/views/index/index.go | 37 ++++ internal/web/views/index/types.go | 12 ++ static/cves.html | 151 +++++++++++++ static/images.html | 74 ++++++- static/index.html | 40 ++++ static/js/cluster-select.js | 40 ++++ static/js/cves-table.js | 75 +++++++ static/js/images-table.js | 73 +++++++ static/sidebar.html | 7 + 19 files changed, 1234 insertions(+), 310 deletions(-) create mode 100644 internal/cve/cve.go create mode 100644 internal/web/cves.go create mode 100644 static/cves.html create mode 100644 static/js/cves-table.js create mode 100644 static/js/images-table.js diff --git a/internal/cve/cve.go b/internal/cve/cve.go new file mode 100644 index 0000000..ea419f9 --- /dev/null +++ b/internal/cve/cve.go @@ -0,0 +1,314 @@ +// Package cve aggregates the per-image vulnerability reports into a cluster-wide, +// per-CVE rollup used by the /cves UI page, the /api/v1/cves endpoint, and the +// MCP tools. Keeping the logic here means all three surfaces rank and filter +// CVEs identically. +package cve + +import ( + "sort" + "strings" + + "github.com/aquasecurity/trivy-operator/pkg/apis/aquasecurity/v1alpha1" + + "github.com/starttoaster/trivy-operator-explorer/internal/db" + log "github.com/starttoaster/trivy-operator-explorer/internal/logger" + "github.com/starttoaster/trivy-operator-explorer/internal/utils" +) + +// AffectedImage describes one image that contains a particular CVE, along with +// the per-image instance of the vulnerability so consumers can correlate +// fixed/installed versions, package class, and per-image ignore state. +type AffectedImage struct { + Ref string `json:"ref"` + Registry string `json:"registry"` + Repository string `json:"repository"` + Tag string `json:"tag"` + Digest string `json:"digest"` + Cluster string `json:"cluster,omitempty"` + VulnerableVersion string `json:"vulnerable_version,omitempty"` + FixedVersion string `json:"fixed_version,omitempty"` + Resource string `json:"resource,omitempty"` + Class string `json:"class,omitempty"` + PackageType string `json:"package_type,omitempty"` + PkgPath string `json:"pkg_path,omitempty"` + PkgPURL string `json:"pkg_purl,omitempty"` + Score float64 `json:"score,omitempty"` + IsIgnored bool `json:"is_ignored"` + IgnoreReason string `json:"ignore_reason,omitempty"` +} + +// Aggregate is the cluster-wide rollup of a single CVE across every image it +// appears in. Severity is taken from the highest-severity occurrence. +type Aggregate struct { + CVEID string `json:"cve_id"` + Severity string `json:"severity"` + MaxScore float64 `json:"max_score"` + Title string `json:"title,omitempty"` + URL string `json:"url,omitempty"` + HasFix bool `json:"has_fix"` + Class string `json:"class,omitempty"` + PackageType string `json:"package_type,omitempty"` + AffectedImageCount int `json:"affected_image_count"` + IgnoredOnAllImages bool `json:"ignored_on_all_images"` + AffectedImages []AffectedImage `json:"affected_images"` +} + +// Pressure is the heuristic ranking used by the "pressure_desc" sort order: +// score (0-10) times the number of affected images. A rough proxy for "how +// worth my time is this CVE today". +func (a *Aggregate) Pressure() float64 { + return a.MaxScore * float64(a.AffectedImageCount) +} + +// Params encodes every filter understood when listing CVEs. All fields are +// optional; the zero value returns every CVE sorted by pressure_desc. +type Params struct { + Severity string + HasFix *bool + Class string + PackageType string + CVEID string + ShowIgnored bool + SortBy string + Limit int +} + +// Result is the response of List. +type Result struct { + Total int `json:"total"` + CVEs []*Aggregate `json:"cves"` +} + +// List aggregates, filters, sorts, and (optionally) limits the CVEs found in the +// given vulnerability reports. +func List(reports *v1alpha1.VulnerabilityReportList, p Params) Result { + aggs := build(reports, p.ShowIgnored) + + out := make([]*Aggregate, 0, len(aggs)) + for _, a := range aggs { + if p.Severity != "" && !strings.EqualFold(a.Severity, p.Severity) { + continue + } + if p.Class != "" && !strings.EqualFold(a.Class, p.Class) { + continue + } + if p.PackageType != "" && !strings.EqualFold(a.PackageType, p.PackageType) { + continue + } + if p.CVEID != "" && !strings.EqualFold(a.CVEID, p.CVEID) { + continue + } + if p.HasFix != nil && a.HasFix != *p.HasFix { + continue + } + out = append(out, a) + } + + sortAggregates(out, p.SortBy) + + total := len(out) + if p.Limit > 0 && len(out) > p.Limit { + out = out[:p.Limit] + } + return Result{Total: total, CVEs: out} +} + +// ListImagesWithCVE returns the single-CVE aggregate for the given CVE ID, or +// found=false when no scanned image contains it. +func ListImagesWithCVE(reports *v1alpha1.VulnerabilityReportList, cveID, severity string, showIgnored bool) (*Aggregate, bool) { + res := List(reports, Params{ + CVEID: cveID, + Severity: severity, + ShowIgnored: showIgnored, + SortBy: "cve_id_asc", + }) + if len(res.CVEs) == 0 { + return nil, false + } + return res.CVEs[0], true +} + +// build walks every VulnerabilityReport and produces a per-CVE aggregate. +// Ignored CVEs are loaded once per image and applied to each occurrence. When +// showIgnored is false, ignored occurrences are dropped before aggregation, so a +// CVE ignored on every image it appears on will not show up at all. +func build(reports *v1alpha1.VulnerabilityReportList, showIgnored bool) map[string]*Aggregate { + if reports == nil { + return map[string]*Aggregate{} + } + + // Cache ignored CVEs per (registry, repository, tag) so we only hit the + // database once per image even when the image appears in multiple reports. + ignoreCache := make(map[string]map[string]db.IgnoredImageVulnerability) + getIgnored := func(registry, repository, tag string) map[string]db.IgnoredImageVulnerability { + key := registry + "|" + repository + "|" + tag + if m, ok := ignoreCache[key]; ok { + return m + } + ignored, err := db.GetIgnoredCVEsForImage(registry, repository, tag) + if err != nil { + log.Logger.Error("cve.build: error getting ignored CVEs", + "registry", registry, "repository", repository, "tag", tag, "error", err.Error()) + ignored = nil + } + ignoreCache[key] = ignored + return ignored + } + + out := make(map[string]*Aggregate) + for _, item := range reports.Items { + registry, repository, tag, digest := utils.NormalizeArtifact( + item.Report.Registry.Server, + item.Report.Artifact.Repository, + item.Report.Artifact.Tag, + item.Report.Artifact.Digest, + ) + cluster := item.ObjectMeta.Labels[utils.ClusterLabel] + + seenOnImage := make(map[string]struct{}) + ignored := getIgnored(registry, repository, tag) + for _, v := range item.Report.Vulnerabilities { + if _, dup := seenOnImage[v.VulnerabilityID]; dup { + continue + } + seenOnImage[v.VulnerabilityID] = struct{}{} + + isIgnored := false + ignoreReason := "" + if ignored != nil { + if row, ok := ignored[v.VulnerabilityID]; ok { + isIgnored = true + ignoreReason = row.Reason + } + } + if isIgnored && !showIgnored { + continue + } + + score := 0.0 + if v.Score != nil { + score = *v.Score + } + + vClass, vPackageType := utils.DeriveVulnerabilityClassAndPackageType(v) + + agg, ok := out[v.VulnerabilityID] + if !ok { + agg = &Aggregate{ + CVEID: v.VulnerabilityID, + Severity: string(v.Severity), + MaxScore: score, + Title: v.Title, + URL: v.PrimaryLink, + HasFix: strings.TrimSpace(v.FixedVersion) != "", + Class: vClass, + PackageType: vPackageType, + IgnoredOnAllImages: true, + } + out[v.VulnerabilityID] = agg + } else { + if severityRank(string(v.Severity)) > severityRank(agg.Severity) { + agg.Severity = string(v.Severity) + } + if score > agg.MaxScore { + agg.MaxScore = score + } + if strings.TrimSpace(v.FixedVersion) != "" { + agg.HasFix = true + } + if agg.Class == "" { + agg.Class = vClass + } + if agg.PackageType == "" { + agg.PackageType = vPackageType + } + if agg.Title == "" { + agg.Title = v.Title + } + if agg.URL == "" { + agg.URL = v.PrimaryLink + } + } + + if !isIgnored { + agg.IgnoredOnAllImages = false + } + + agg.AffectedImages = append(agg.AffectedImages, AffectedImage{ + Ref: utils.AssembleImageRef(registry, repository, tag, digest), + Registry: registry, + Repository: repository, + Tag: tag, + Digest: digest, + Cluster: cluster, + VulnerableVersion: v.InstalledVersion, + FixedVersion: v.FixedVersion, + Resource: v.Resource, + Class: vClass, + PackageType: vPackageType, + PkgPath: v.PkgPath, + PkgPURL: v.PkgPURL, + Score: score, + IsIgnored: isIgnored, + IgnoreReason: ignoreReason, + }) + agg.AffectedImageCount = len(agg.AffectedImages) + } + } + + return out +} + +// severityRank maps trivy severity strings to a numeric rank used to pick the +// worst severity across multiple occurrences of the same CVE. +func severityRank(s string) int { + switch strings.ToUpper(strings.TrimSpace(s)) { + case "CRITICAL": + return 4 + case "HIGH": + return 3 + case "MEDIUM": + return 2 + case "LOW": + return 1 + default: + return 0 + } +} + +// sortAggregates reorders aggs in place. When sortBy is empty or unknown, +// "pressure_desc" is used. +func sortAggregates(aggs []*Aggregate, sortBy string) { + switch strings.ToLower(strings.TrimSpace(sortBy)) { + case "score_desc": + sort.SliceStable(aggs, func(i, j int) bool { + if aggs[i].MaxScore != aggs[j].MaxScore { + return aggs[i].MaxScore > aggs[j].MaxScore + } + return aggs[i].CVEID < aggs[j].CVEID + }) + case "affected_count_desc": + sort.SliceStable(aggs, func(i, j int) bool { + if aggs[i].AffectedImageCount != aggs[j].AffectedImageCount { + return aggs[i].AffectedImageCount > aggs[j].AffectedImageCount + } + return aggs[i].CVEID < aggs[j].CVEID + }) + case "cve_id_asc": + sort.SliceStable(aggs, func(i, j int) bool { + return aggs[i].CVEID < aggs[j].CVEID + }) + default: // pressure_desc + sort.SliceStable(aggs, func(i, j int) bool { + pi := aggs[i].Pressure() + pj := aggs[j].Pressure() + if pi != pj { + return pi > pj + } + if severityRank(aggs[i].Severity) != severityRank(aggs[j].Severity) { + return severityRank(aggs[i].Severity) > severityRank(aggs[j].Severity) + } + return aggs[i].CVEID < aggs[j].CVEID + }) + } +} diff --git a/internal/mcp/cves.go b/internal/mcp/cves.go index 437db81..fd724ba 100644 --- a/internal/mcp/cves.go +++ b/internal/mcp/cves.go @@ -1,226 +1,13 @@ package mcp import ( - "sort" - "strings" - "github.com/aquasecurity/trivy-operator/pkg/apis/aquasecurity/v1alpha1" - "github.com/starttoaster/trivy-operator-explorer/internal/db" + + "github.com/starttoaster/trivy-operator-explorer/internal/cve" log "github.com/starttoaster/trivy-operator-explorer/internal/logger" "github.com/starttoaster/trivy-operator-explorer/internal/source" - "github.com/starttoaster/trivy-operator-explorer/internal/utils" ) -// affectedImage describes one image that contains a particular CVE, along -// with the per-image instance of the vulnerability so LLMs can correlate -// fixed/installed versions, package class, and per-image ignore state. -type affectedImage struct { - Ref string `json:"ref"` - Registry string `json:"registry"` - Repository string `json:"repository"` - Tag string `json:"tag"` - Digest string `json:"digest"` - VulnerableVersion string `json:"vulnerable_version,omitempty"` - FixedVersion string `json:"fixed_version,omitempty"` - Resource string `json:"resource,omitempty"` - Class string `json:"class,omitempty"` - PackageType string `json:"package_type,omitempty"` - PkgPath string `json:"pkg_path,omitempty"` - PkgPURL string `json:"pkg_purl,omitempty"` - Score float64 `json:"score,omitempty"` - IsIgnored bool `json:"is_ignored"` - IgnoreReason string `json:"ignore_reason,omitempty"` -} - -// cveAggregate is the cluster-wide rollup of a single CVE across every image -// it appears in. Severity is taken from the highest-severity occurrence (in -// practice trivy reports the same severity for a CVE on every image, but we -// defensively pick the worst). -type cveAggregate struct { - CVEID string `json:"cve_id"` - Severity string `json:"severity"` - MaxScore float64 `json:"max_score"` - Title string `json:"title,omitempty"` - URL string `json:"url,omitempty"` - HasFix bool `json:"has_fix"` - Class string `json:"class,omitempty"` - PackageType string `json:"package_type,omitempty"` - AffectedImageCount int `json:"affected_image_count"` - IgnoredOnAllImages bool `json:"ignored_on_all_images"` - AffectedImages []affectedImage `json:"affected_images"` -} - -// cvePressure is the heuristic ranking used by the pressure_desc sort order -// in list_cves: score (0-10) times the number of affected images. It is a -// rough proxy for "how worth my time is this CVE today". -func (c *cveAggregate) cvePressure() float64 { - return c.MaxScore * float64(c.AffectedImageCount) -} - -// aggregateCVEs walks every VulnerabilityReport in the cluster and produces a -// per-CVE aggregate. Ignored CVEs are loaded once per image and applied to -// each occurrence so list_cves and list_images_with_cve get consistent -// is_ignored / ignore_reason metadata. -// -// The optional showIgnored flag controls whether occurrences that are -// ignored in the DB are included in the aggregate. When false (the default), -// ignored occurrences are dropped *before* aggregation, so a CVE that is -// ignored on every image it appears on will not show up at all. -func aggregateCVEs(reports *v1alpha1.VulnerabilityReportList, showIgnored bool) map[string]*cveAggregate { - if reports == nil { - return map[string]*cveAggregate{} - } - - // Cache ignored CVEs per (registry, repository, tag) so we only hit the - // database once per image even when the same image appears in multiple - // reports (different workloads). - ignoreCache := make(map[string]map[string]db.IgnoredImageVulnerability) - getIgnored := func(registry, repository, tag string) map[string]db.IgnoredImageVulnerability { - key := registry + "|" + repository + "|" + tag - if m, ok := ignoreCache[key]; ok { - return m - } - ignored, err := db.GetIgnoredCVEsForImage(registry, repository, tag) - if err != nil { - log.Logger.Error("aggregateCVEs: error getting ignored CVEs", - "registry", registry, "repository", repository, "tag", tag, - "error", err.Error()) - ignored = nil - } - ignoreCache[key] = ignored - return ignored - } - - out := make(map[string]*cveAggregate) - for _, item := range reports.Items { - // Some trivy-operator reports stuff the full image reference into the - // Tag field; normalize the artifact spec before we render or key off it. - registry, repository, tag, digest := utils.NormalizeArtifact( - item.Report.Registry.Server, - item.Report.Artifact.Repository, - item.Report.Artifact.Tag, - item.Report.Artifact.Digest, - ) - - // Track which (image, CVE) pairs we've already counted so duplicate - // vulnerability rows on the same report don't double-count. - seenOnImage := make(map[string]struct{}) - - ignored := getIgnored(registry, repository, tag) - for _, v := range item.Report.Vulnerabilities { - if _, dup := seenOnImage[v.VulnerabilityID]; dup { - continue - } - seenOnImage[v.VulnerabilityID] = struct{}{} - - isIgnored := false - ignoreReason := "" - if ignored != nil { - if row, ok := ignored[v.VulnerabilityID]; ok { - isIgnored = true - ignoreReason = row.Reason - } - } - if isIgnored && !showIgnored { - continue - } - - score := 0.0 - if v.Score != nil { - score = *v.Score - } - - // Resolve class/package_type once per occurrence, falling back to - // the packagePURL ("pkg:/...") when trivy left the explicit - // fields blank. Both the top-level aggregate and the affected_images - // entry below need to see the derived values so MCP clients can - // filter list_cves?class=os-pkgs without re-parsing purls. - vClass, vPackageType := utils.DeriveVulnerabilityClassAndPackageType(v) - - agg, ok := out[v.VulnerabilityID] - if !ok { - agg = &cveAggregate{ - CVEID: v.VulnerabilityID, - Severity: string(v.Severity), - MaxScore: score, - Title: v.Title, - URL: v.PrimaryLink, - HasFix: strings.TrimSpace(v.FixedVersion) != "", - Class: vClass, - PackageType: vPackageType, - IgnoredOnAllImages: true, - } - out[v.VulnerabilityID] = agg - } else { - if severityRank(string(v.Severity)) > severityRank(agg.Severity) { - agg.Severity = string(v.Severity) - } - if score > agg.MaxScore { - agg.MaxScore = score - } - if strings.TrimSpace(v.FixedVersion) != "" { - agg.HasFix = true - } - if agg.Class == "" { - agg.Class = vClass - } - if agg.PackageType == "" { - agg.PackageType = vPackageType - } - if agg.Title == "" { - agg.Title = v.Title - } - if agg.URL == "" { - agg.URL = v.PrimaryLink - } - } - - if !isIgnored { - agg.IgnoredOnAllImages = false - } - - agg.AffectedImages = append(agg.AffectedImages, affectedImage{ - Ref: utils.AssembleImageRef(registry, repository, tag, digest), - Registry: registry, - Repository: repository, - Tag: tag, - Digest: digest, - VulnerableVersion: v.InstalledVersion, - FixedVersion: v.FixedVersion, - Resource: v.Resource, - Class: vClass, - PackageType: vPackageType, - PkgPath: v.PkgPath, - PkgPURL: v.PkgPURL, - Score: score, - IsIgnored: isIgnored, - IgnoreReason: ignoreReason, - }) - agg.AffectedImageCount = len(agg.AffectedImages) - } - } - - return out -} - -// severityRank maps trivy severity strings to a numeric rank used to pick -// the worst severity across multiple report occurrences of the same CVE. -// Unknown values are ranked 0, so a known severity always beats unknown. -func severityRank(s string) int { - switch strings.ToUpper(strings.TrimSpace(s)) { - case "CRITICAL": - return 4 - case "HIGH": - return 3 - case "MEDIUM": - return 2 - case "LOW": - return 1 - default: - return 0 - } -} - // listCVEsParams encodes every filter understood by the list_cves MCP tool. // All fields are optional; the zero value returns every CVE in the cluster // sorted by pressure_desc. @@ -237,85 +24,24 @@ type listCVEsParams struct { } // listCVEsResult is the typed response of the list_cves MCP tool. -type listCVEsResult struct { - Total int `json:"total"` - CVEs []*cveAggregate `json:"cves"` -} +type listCVEsResult = cve.Result -// runListCVEs is the shared implementation behind the list_cves MCP tool. It -// is broken out from the tool wrapper so it can be unit-tested with a -// canned report list. +// runListCVEs is the shared implementation behind the list_cves MCP tool. func runListCVEs(reports *v1alpha1.VulnerabilityReportList, p listCVEsParams) listCVEsResult { - aggs := aggregateCVEs(reports, p.ShowIgnored) - - out := make([]*cveAggregate, 0, len(aggs)) - for _, a := range aggs { - if p.Severity != "" && !strings.EqualFold(a.Severity, p.Severity) { - continue - } - if p.Class != "" && !strings.EqualFold(a.Class, p.Class) { - continue - } - if p.PackageType != "" && !strings.EqualFold(a.PackageType, p.PackageType) { - continue - } - if p.CVEID != "" && !strings.EqualFold(a.CVEID, p.CVEID) { - continue - } - if p.HasFix != nil && a.HasFix != *p.HasFix { - continue - } - out = append(out, a) - } - - sortAggregates(out, p.SortBy) - - total := len(out) - if p.Limit > 0 && len(out) > p.Limit { - out = out[:p.Limit] - } - return listCVEsResult{Total: total, CVEs: out} -} - -// sortAggregates reorders aggs in place using the requested ordering. When -// sortBy is empty or unknown, "pressure_desc" is used. -func sortAggregates(aggs []*cveAggregate, sortBy string) { - switch strings.ToLower(strings.TrimSpace(sortBy)) { - case "score_desc": - sort.SliceStable(aggs, func(i, j int) bool { - if aggs[i].MaxScore != aggs[j].MaxScore { - return aggs[i].MaxScore > aggs[j].MaxScore - } - return aggs[i].CVEID < aggs[j].CVEID - }) - case "affected_count_desc": - sort.SliceStable(aggs, func(i, j int) bool { - if aggs[i].AffectedImageCount != aggs[j].AffectedImageCount { - return aggs[i].AffectedImageCount > aggs[j].AffectedImageCount - } - return aggs[i].CVEID < aggs[j].CVEID - }) - case "cve_id_asc": - sort.SliceStable(aggs, func(i, j int) bool { - return aggs[i].CVEID < aggs[j].CVEID - }) - default: // pressure_desc - sort.SliceStable(aggs, func(i, j int) bool { - pi := aggs[i].cvePressure() - pj := aggs[j].cvePressure() - if pi != pj { - return pi > pj - } - if severityRank(aggs[i].Severity) != severityRank(aggs[j].Severity) { - return severityRank(aggs[i].Severity) > severityRank(aggs[j].Severity) - } - return aggs[i].CVEID < aggs[j].CVEID - }) - } + return cve.List(reports, cve.Params{ + Severity: p.Severity, + HasFix: p.HasFix, + Class: p.Class, + PackageType: p.PackageType, + CVEID: p.CVEID, + ShowIgnored: p.ShowIgnored, + SortBy: p.SortBy, + Limit: p.Limit, + }) } -// listImagesWithCVEParams encodes the inputs to the list_images_with_cve -// MCP tool. CVEID is required; Severity is an optional defensive filter. +// listImagesWithCVEParams encodes the inputs to the list_images_with_cve MCP +// tool. CVEID is required; Severity is an optional defensive filter. type listImagesWithCVEParams struct { Cluster string `json:"cluster,omitempty" jsonschema:"optional cluster name to scope results to; empty means aggregate across all clusters"` CVEID string `json:"cve_id" jsonschema:"required CVE ID to look up (e.g. 'CVE-2023-1234'), case-insensitive"` @@ -325,21 +51,13 @@ type listImagesWithCVEParams struct { // listImagesWithCVEResult is the typed response for list_images_with_cve. type listImagesWithCVEResult struct { - CVE *cveAggregate `json:"cve,omitempty"` - Found bool `json:"found"` + CVE *cve.Aggregate `json:"cve,omitempty"` + Found bool `json:"found"` } func runListImagesWithCVE(reports *v1alpha1.VulnerabilityReportList, p listImagesWithCVEParams) listImagesWithCVEResult { - res := runListCVEs(reports, listCVEsParams{ - CVEID: p.CVEID, - Severity: p.Severity, - ShowIgnored: p.ShowIgnored, - SortBy: "cve_id_asc", - }) - if len(res.CVEs) == 0 { - return listImagesWithCVEResult{Found: false} - } - return listImagesWithCVEResult{Found: true, CVE: res.CVEs[0]} + agg, found := cve.ListImagesWithCVE(reports, p.CVEID, p.Severity, p.ShowIgnored) + return listImagesWithCVEResult{CVE: agg, Found: found} } // getReportsOrError loads the vulnerability reports for the given cluster (or diff --git a/internal/source/source.go b/internal/source/source.go index 9613ad0..17101cf 100644 --- a/internal/source/source.go +++ b/internal/source/source.go @@ -10,6 +10,7 @@ package source import ( "context" + "sort" "sync" "time" @@ -102,6 +103,35 @@ func ListClusters() []string { return out } +// ClusterStatus reports the freshness of a cluster's data in the cache. +type ClusterStatus struct { + Name string `json:"name"` + SyncedAt time.Time `json:"synced_at"` + Version string `json:"version"` +} + +// ClusterStatuses returns the per-cluster sync metadata (from each cluster's +// meta.json), sorted by name. Used by the UI freshness indicator. +func ClusterStatuses() []ClusterStatus { + if provider == nil { + return nil + } + provider.mu.RLock() + defer provider.mu.RUnlock() + + out := make([]ClusterStatus, 0, len(provider.clusters)) + for _, name := range provider.clusters { + st := ClusterStatus{Name: name} + if b := provider.bundles[name]; b != nil && b.Meta != nil { + st.SyncedAt = b.Meta.SyncedAt + st.Version = b.Meta.Version + } + out = append(out, st) + } + sort.Slice(out, func(i, j int) bool { return out[i].Name < out[j].Name }) + return out +} + // bundlesFor returns the bundles matching the cluster selector. An empty // cluster returns every bundle (aggregate mode). func bundlesFor(cluster string) []*store.Bundle { diff --git a/internal/store/store.go b/internal/store/store.go index 99adc6e..532bbd6 100644 --- a/internal/store/store.go +++ b/internal/store/store.go @@ -57,6 +57,7 @@ type Bundle struct { ClusterInfraAssessmentReports *v1alpha1.ClusterInfraAssessmentReportList ExposedSecretReports *v1alpha1.ExposedSecretReportList ContainerImages map[string]kube.ContainerImage + Meta *Meta } // Meta is a small manifest object written alongside each cluster's reports so @@ -229,6 +230,12 @@ func (c *Client) LoadCluster(ctx context.Context, cluster string) (*Bundle, erro } b.ContainerImages = containerImagesFromDTO(imagesDTO) + var meta Meta + if err := c.getJSON(ctx, c.key(cluster, objMeta), &meta); err != nil && !errors.Is(err, errNotFound) { + return nil, fmt.Errorf("error reading %s for cluster %s: %w", objMeta, cluster, err) + } + b.Meta = &meta + return b, nil } diff --git a/internal/web/api.go b/internal/web/api.go index a561070..7719bfb 100644 --- a/internal/web/api.go +++ b/internal/web/api.go @@ -48,6 +48,16 @@ func apiClustersHandler(w http.ResponseWriter, r *http.Request) { writeJSON(w, http.StatusOK, clusters) } +// apiStatusHandler returns per-cluster sync freshness (from each cluster's +// meta.json). The UI freshness indicator uses this. +func apiStatusHandler(w http.ResponseWriter, r *http.Request) { + statuses := source.ClusterStatuses() + if statuses == nil { + statuses = []source.ClusterStatus{} + } + writeJSON(w, http.StatusOK, statuses) +} + // writeJSON serializes body to JSON and writes it to w with the given status. func writeJSON(w http.ResponseWriter, status int, body any) { w.Header().Set("Content-Type", "application/json; charset=utf-8") @@ -154,6 +164,7 @@ func apiImagesHandler(w http.ResponseWriter, r *http.Request) { OSFamily: q.Get("os_family"), EOSL: eosl, CVEIDs: q["cve"], + Class: q.Get("class"), }) writeJSON(w, http.StatusOK, view) } diff --git a/internal/web/cluster_render_test.go b/internal/web/cluster_render_test.go index 988010c..fee8e1f 100644 --- a/internal/web/cluster_render_test.go +++ b/internal/web/cluster_render_test.go @@ -7,9 +7,11 @@ import ( "testing" assets "github.com/starttoaster/trivy-operator-explorer" + "github.com/starttoaster/trivy-operator-explorer/internal/cve" "github.com/starttoaster/trivy-operator-explorer/internal/web/content" exposedsecretsview "github.com/starttoaster/trivy-operator-explorer/internal/web/views/exposedsecrets" imagesview "github.com/starttoaster/trivy-operator-explorer/internal/web/views/images" + indexview "github.com/starttoaster/trivy-operator-explorer/internal/web/views/index" ) func init() { content.Init(assets.Static) } @@ -19,9 +21,18 @@ func init() { content.Init(assets.Static) } // the full list available as hover text, while single-cluster images show the // cluster name directly. func TestImagesTemplateClusterColumn(t *testing.T) { - funcMap := template.FuncMap{"sanitizeID": func(s string) string { - return strings.NewReplacer("/", "_", ":", "_", " ", "_", "-", "_", ".", "_").Replace(s) - }} + funcMap := template.FuncMap{ + "sanitizeID": func(s string) string { + return strings.NewReplacer("/", "_", ":", "_", " ", "_", "-", "_", ".", "_").Replace(s) + }, + "add": func(a, b int) int { return a + b }, + "pct": func(a, b int) int { + if b == 0 { + return 0 + } + return a * 100 / b + }, + } tmpl := template.Must(template.New("images.html").Funcs(funcMap).ParseFS(content.Static, "static/images.html", "static/sidebar.html")) multi := imagesview.Data{Name: "alpine", Tag: "3.19", Digest: "sha256:abc", Clusters: map[string]struct{}{"clusterB": {}, "clusterA": {}}} @@ -31,8 +42,11 @@ func TestImagesTemplateClusterColumn(t *testing.T) { PageRoute string HasFix bool ShowIgnored bool + Class string + TotalImages int + Stats indexview.View Data imagesview.View - }{PageRoute: "images", Data: imagesview.View{multi, single}} + }{PageRoute: "images", TotalImages: 2, Data: imagesview.View{multi, single}} var buf bytes.Buffer if err := tmpl.Execute(&buf, data); err != nil { @@ -69,3 +83,85 @@ func TestExposedSecretsTemplateClusterColumn(t *testing.T) { t.Errorf("expected hover tooltip listing 'clusterA, clusterB'") } } + +// TestCVEsTemplateRenders renders the CVE triage page with a single aggregate +// and its affected image, catching template/method wiring errors. +func TestCVEsTemplateRenders(t *testing.T) { + funcMap := template.FuncMap{"sanitizeID": func(s string) string { + return strings.NewReplacer("/", "_", ":", "_", " ", "_", "-", "_", ".", "_").Replace(s) + }} + tmpl := template.Must(template.New("cves.html").Funcs(funcMap).ParseFS(content.Static, "static/cves.html", "static/sidebar.html")) + + result := cve.Result{ + Total: 1, + CVEs: []*cve.Aggregate{ + { + CVEID: "CVE-2024-0001", + Severity: "CRITICAL", + MaxScore: 9.8, + HasFix: true, + Class: "os-pkgs", + PackageType: "apk", + AffectedImageCount: 1, + AffectedImages: []cve.AffectedImage{ + {Ref: "index.docker.io/library/nginx:1.27@sha256:abc", Cluster: "clusterA", Registry: "index.docker.io", Repository: "library/nginx", Tag: "1.27", Digest: "sha256:abc", VulnerableVersion: "1.0", FixedVersion: "1.1"}, + }, + }, + }, + } + + data := struct { + PageRoute string + Cluster string + Result cve.Result + Severity string + Class string + HasFix string + ShowIgnored bool + SortBy string + }{PageRoute: "cves", Result: result, SortBy: "pressure_desc"} + + var buf bytes.Buffer + if err := tmpl.Execute(&buf, data); err != nil { + t.Fatalf("execute cves.html: %v", err) + } + out := buf.String() + + if !strings.Contains(out, "CVE-2024-0001") { + t.Errorf("expected the CVE id in the output") + } + if !strings.Contains(out, "9.8") { + t.Errorf("expected the pressure/score value in the output") + } + if !strings.Contains(out, "library/nginx") { + t.Errorf("expected the affected image in the expandable detail") + } +} + +// TestIndexTemplateRenders renders the dashboard including the riskiest-images +// chart data. +func TestIndexTemplateRenders(t *testing.T) { + tmpl := template.Must(template.ParseFS(content.Static, "static/index.html", "static/sidebar.html")) + + data := indexview.View{ + CriticalVulnerabilities: 5, + HighVulnerabilities: 3, + TopImages: []indexview.TopImage{ + {Name: "nginx:1.27", Critical: 3, High: 2}, + {Name: "alpine:3.19", Critical: 1}, + }, + } + + var buf bytes.Buffer + if err := tmpl.Execute(&buf, data); err != nil { + t.Fatalf("execute index.html: %v", err) + } + out := buf.String() + + if !strings.Contains(out, "Riskiest Images") { + t.Errorf("expected the riskiest images section") + } + if !strings.Contains(out, "nginx:1.27") { + t.Errorf("expected a top image label in the chart data") + } +} diff --git a/internal/web/cves.go b/internal/web/cves.go new file mode 100644 index 0000000..32c3609 --- /dev/null +++ b/internal/web/cves.go @@ -0,0 +1,116 @@ +package web + +import ( + "html/template" + "net/http" + "strconv" + "strings" + + "github.com/starttoaster/trivy-operator-explorer/internal/cve" + log "github.com/starttoaster/trivy-operator-explorer/internal/logger" + "github.com/starttoaster/trivy-operator-explorer/internal/source" + "github.com/starttoaster/trivy-operator-explorer/internal/web/content" +) + +// parseCVEParams extracts the cluster selector and CVE list filters shared by +// the HTML and JSON CVE handlers. +func parseCVEParams(r *http.Request) (cluster string, params cve.Params) { + q := r.URL.Query() + cluster = q.Get("cluster") + + var hasFix *bool + if raw := q.Get("hasfix"); raw != "" { + if v, err := strconv.ParseBool(raw); err == nil { + hasFix = &v + } else { + log.Logger.Warn("could not parse hasfix query parameter to bool type, ignoring filter", "raw", raw, "error", err.Error()) + } + } + + limit := 0 + if raw := q.Get("limit"); raw != "" { + if v, err := strconv.Atoi(raw); err == nil && v >= 0 { + limit = v + } + } + + return cluster, cve.Params{ + Severity: q.Get("severity"), + HasFix: hasFix, + Class: q.Get("class"), + PackageType: q.Get("package_type"), + CVEID: q.Get("cve"), + ShowIgnored: parseBoolQuery("showignored", q.Get("showignored")), + SortBy: q.Get("sort_by"), + Limit: limit, + } +} + +// cvesHandler renders the cluster-wide CVE triage page. +func cvesHandler(w http.ResponseWriter, r *http.Request) { + funcMap := template.FuncMap{ + "sanitizeID": func(s string) string { + replacer := strings.NewReplacer("/", "_", ":", "_", " ", "_", "-", "_", ".", "_") + return replacer.Replace(s) + }, + } + + tmpl := template.Must(template.New("cves.html").Funcs(funcMap).ParseFS(content.Static, "static/cves.html", "static/sidebar.html")) + if tmpl == nil { + log.Logger.Error("encountered error parsing cves html template") + http.Error(w, "Internal Server Error, check server logs", http.StatusInternalServerError) + return + } + + cluster, params := parseCVEParams(r) + reports, err := source.GetVulnerabilityReportList(cluster) + if err != nil { + log.Logger.Error("error getting VulnerabilityReports", "error", err.Error()) + return + } + + result := cve.List(reports, params) + + hasFix := "" + if params.HasFix != nil { + hasFix = strconv.FormatBool(*params.HasFix) + } + + templateData := struct { + PageRoute string + Cluster string + Result cve.Result + Severity string + Class string + HasFix string + ShowIgnored bool + SortBy string + }{ + PageRoute: "cves", + Cluster: cluster, + Result: result, + Severity: params.Severity, + Class: params.Class, + HasFix: hasFix, + ShowIgnored: params.ShowIgnored, + SortBy: params.SortBy, + } + + if err := tmpl.Execute(w, templateData); err != nil { + log.Logger.Error("encountered error executing cves html template", "error", err) + http.Error(w, "Internal Server Error, check server logs", http.StatusInternalServerError) + return + } +} + +// apiCvesHandler is the JSON counterpart to the /cves page. +func apiCvesHandler(w http.ResponseWriter, r *http.Request) { + cluster, params := parseCVEParams(r) + reports, err := source.GetVulnerabilityReportList(cluster) + if err != nil { + log.Logger.Error("error getting VulnerabilityReports", "error", err.Error()) + writeJSONError(w, http.StatusInternalServerError, "failed to list vulnerability reports") + return + } + writeJSON(w, http.StatusOK, cve.List(reports, params)) +} diff --git a/internal/web/openapi.json b/internal/web/openapi.json index 7eeb5b8..ab76215 100644 --- a/internal/web/openapi.json +++ b/internal/web/openapi.json @@ -36,6 +36,19 @@ } } }, + "/api/v1/status": { + "get": { + "summary": "Per-cluster sync freshness.", + "description": "Returns, for each cluster known to the frontend, when its report bundle was last written to S3 and by what collector version.", + "responses": { + "200": { + "description": "Cluster statuses.", + "content": { "application/json": { "schema": { "type": "array", "items": { "$ref": "#/components/schemas/ClusterStatus" } } } } + }, + "500": { "$ref": "#/components/responses/InternalError" } + } + } + }, "/api/v1/openapi.json": { "get": { "summary": "Fetch this OpenAPI document.", @@ -151,6 +164,27 @@ } } }, + "/api/v1/cves": { + "get": { + "summary": "Aggregate every CVE across all scanned images.", + "description": "Cluster-wide CVE rollup. Each CVE aggregate includes the worst severity, max CVSS score, fix availability, Trivy class/package type, the count of affected images, and the per-image affected list. Sortable and filterable for triage.", + "parameters": [ + { "$ref": "#/components/parameters/Cluster" }, + { "$ref": "#/components/parameters/Severity" }, + { "$ref": "#/components/parameters/HasFix" }, + { "$ref": "#/components/parameters/ShowIgnored" }, + { "name": "class", "in": "query", "description": "Trivy package class filter.", "schema": { "type": "string", "enum": ["os-pkgs", "lang-pkgs"] } }, + { "name": "package_type", "in": "query", "description": "Package type filter (e.g. apk, dpkg, gobinary, npm).", "schema": { "type": "string" } }, + { "name": "cve", "in": "query", "description": "Exact CVE ID filter (case-insensitive).", "schema": { "type": "string" } }, + { "name": "sort_by", "in": "query", "description": "Sort order.", "schema": { "type": "string", "enum": ["pressure_desc", "score_desc", "affected_count_desc", "cve_id_asc"] } }, + { "name": "limit", "in": "query", "description": "Maximum number of CVEs to return; 0 means no limit.", "schema": { "type": "integer" } } + ], + "responses": { + "200": { "description": "Aggregated CVEs.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/CveList" } } } }, + "500": { "$ref": "#/components/responses/InternalError" } + } + } + }, "/api/v1/configaudits": { "get": { "summary": "List ConfigAuditReports.", @@ -410,6 +444,14 @@ "error": { "type": "string", "description": "Human-readable error message." } } }, + "ClusterStatus": { + "type": "object", + "properties": { + "name": { "type": "string" }, + "synced_at": { "type": "string", "format": "date-time" }, + "version": { "type": "string" } + } + }, "HealthResponse": { "type": "object", "required": ["status", "version"], @@ -496,6 +538,50 @@ "vulnerabilities": { "type": "array", "items": { "$ref": "#/components/schemas/ImageVulnerability" } } } }, + "CveAffectedImage": { + "type": "object", + "properties": { + "ref": { "type": "string" }, + "registry": { "type": "string" }, + "repository": { "type": "string" }, + "tag": { "type": "string" }, + "digest": { "type": "string" }, + "cluster": { "type": "string" }, + "vulnerable_version": { "type": "string" }, + "fixed_version": { "type": "string" }, + "resource": { "type": "string" }, + "class": { "type": "string" }, + "package_type": { "type": "string" }, + "pkg_path": { "type": "string" }, + "pkg_purl": { "type": "string" }, + "score": { "type": "number", "format": "float" }, + "is_ignored": { "type": "boolean" }, + "ignore_reason": { "type": "string" } + } + }, + "CveAggregate": { + "type": "object", + "properties": { + "cve_id": { "type": "string" }, + "severity": { "type": "string" }, + "max_score": { "type": "number", "format": "float" }, + "title": { "type": "string" }, + "url": { "type": "string" }, + "has_fix": { "type": "boolean" }, + "class": { "type": "string" }, + "package_type": { "type": "string" }, + "affected_image_count": { "type": "integer" }, + "ignored_on_all_images": { "type": "boolean" }, + "affected_images": { "type": "array", "items": { "$ref": "#/components/schemas/CveAffectedImage" } } + } + }, + "CveList": { + "type": "object", + "properties": { + "total": { "type": "integer" }, + "cves": { "type": "array", "items": { "$ref": "#/components/schemas/CveAggregate" } } + } + }, "RoleVulnerability": { "type": "object", "properties": { diff --git a/internal/web/server.go b/internal/web/server.go index c91be29..021b290 100644 --- a/internal/web/server.go +++ b/internal/web/server.go @@ -34,6 +34,7 @@ func Start(port string) error { mux.HandleFunc("/", indexHandler) mux.HandleFunc("/images", imagesHandler) mux.HandleFunc("/image", imageHandler) + mux.HandleFunc("/cves", cvesHandler) mux.HandleFunc("/configaudits", configauditsHandler) mux.HandleFunc("/configaudit", configauditHandler) mux.HandleFunc("/clusteraudits", clusterauditsHandler) @@ -54,9 +55,11 @@ func Start(port string) error { mux.HandleFunc("/api/v1/", methodGet(apiIndexHandler)) mux.HandleFunc("/api/v1/health", methodGet(apiHealthHandler)) mux.HandleFunc("/api/v1/clusters", methodGet(apiClustersHandler)) + mux.HandleFunc("/api/v1/status", methodGet(apiStatusHandler)) mux.HandleFunc("/api/v1/openapi.json", methodGet(apiOpenAPIHandler)) mux.HandleFunc("/api/v1/images", methodGet(apiImagesHandler)) mux.HandleFunc("/api/v1/image", methodGet(apiImageHandler)) + mux.HandleFunc("/api/v1/cves", methodGet(apiCvesHandler)) mux.HandleFunc("/api/v1/configaudits", methodGet(apiConfigauditsHandler)) mux.HandleFunc("/api/v1/configaudit", methodGet(apiConfigauditHandler)) mux.HandleFunc("/api/v1/clusteraudits", methodGet(apiClusterauditsHandler)) @@ -122,6 +125,13 @@ func imagesHandler(w http.ResponseWriter, r *http.Request) { replacer := strings.NewReplacer("/", "_", ":", "_", " ", "_", "-", "_", ".", "_") return replacer.Replace(s) }, + "add": func(a, b int) int { return a + b }, + "pct": func(a, b int) int { + if b == 0 { + return 0 + } + return a * 100 / b + }, } tmpl := template.Must(template.New("images.html").Funcs(funcMap).ParseFS(content.Static, "static/images.html", "static/sidebar.html")) @@ -172,18 +182,28 @@ func imagesHandler(w http.ResponseWriter, r *http.Request) { imageData := imagesview.GetView(data, imagesMap, imagesview.Filters{ HasFix: hasFixBool, ShowIgnored: showIgnoredBool, + Class: q.Get("class"), }) + // Summary stats for the cards above the table (reuses the index rollup). + stats := indexview.GetView(imageData, nil) + // Add page type to template data templateData := struct { PageRoute string HasFix bool ShowIgnored bool + Class string + TotalImages int + Stats indexview.View Data imagesview.View }{ PageRoute: "images", HasFix: hasFixBool, ShowIgnored: showIgnoredBool, + Class: q.Get("class"), + TotalImages: len(imageData), + Stats: stats, Data: imageData, } diff --git a/internal/web/views/images/images.go b/internal/web/views/images/images.go index 4e6d8e6..0144144 100644 --- a/internal/web/views/images/images.go +++ b/internal/web/views/images/images.go @@ -38,6 +38,11 @@ type Filters struct { // of the listed CVE IDs (logical AND). Ignored CVEs only count when // ShowIgnored is true (matching the existing per-image behavior). CVEIDs []string + + // Class, when non-empty, restricts results to images that contain at least + // one vulnerability of the given Trivy package class (e.g. "os-pkgs" or + // "lang-pkgs"). Case-insensitive. + Class string } // GetView converts some report data to the /images view @@ -294,6 +299,26 @@ func matchesImageLevelFilters(d Data, f Filters) bool { } } + if f.Class != "" { + hasClass := false + for _, group := range [][]Vulnerability{ + d.CriticalVulnerabilities, d.HighVulnerabilities, d.MediumVulnerabilities, d.LowVulnerabilities, + } { + for _, v := range group { + if strings.EqualFold(v.Class, f.Class) { + hasClass = true + break + } + } + if hasClass { + break + } + } + if !hasClass { + return false + } + } + return true } diff --git a/internal/web/views/index/index.go b/internal/web/views/index/index.go index aab61f7..1f6ea27 100644 --- a/internal/web/views/index/index.go +++ b/internal/web/views/index/index.go @@ -1,14 +1,21 @@ package index import ( + "sort" + complianceview "github.com/starttoaster/trivy-operator-explorer/internal/web/views/compliance" imagesview "github.com/starttoaster/trivy-operator-explorer/internal/web/views/images" ) +// topImagesLimit is how many images the dashboard "riskiest images" chart shows. +const topImagesLimit = 10 + // GetView converts some report data to the / view func GetView(vulnList imagesview.View, complianceList complianceview.View) View { var i View + var ranked []TopImage + // Process image vulnerability data for _, image := range vulnList { i.CriticalVulnerabilities += len(image.CriticalVulnerabilities) @@ -24,10 +31,40 @@ func GetView(vulnList imagesview.View, complianceList complianceview.View) View } else { i.NoEOSLCount++ } + + name := image.Name + if image.Tag != "" { + name += ":" + image.Tag + } + ti := TopImage{ + Name: name, + Critical: len(image.CriticalVulnerabilities), + High: len(image.HighVulnerabilities), + Medium: len(image.MediumVulnerabilities), + Low: len(image.LowVulnerabilities), + } + if ti.Critical+ti.High+ti.Medium+ti.Low > 0 { + ranked = append(ranked, ti) + } + } + + // Rank images by a severity-weighted score and keep the top N. + sort.SliceStable(ranked, func(a, b int) bool { + return riskScore(ranked[a]) > riskScore(ranked[b]) + }) + if len(ranked) > topImagesLimit { + ranked = ranked[:topImagesLimit] } + i.TopImages = ranked // Process compliance data i.ComplianceReports = complianceList return i } + +// riskScore is a severity-weighted heuristic used only to order the dashboard +// "riskiest images" chart. +func riskScore(t TopImage) int { + return t.Critical*1000 + t.High*100 + t.Medium*10 + t.Low +} diff --git a/internal/web/views/index/types.go b/internal/web/views/index/types.go index b44b5dc..62ec9e5 100644 --- a/internal/web/views/index/types.go +++ b/internal/web/views/index/types.go @@ -16,6 +16,18 @@ type View struct { EOSLCount int `json:"eosl_count"` NoEOSLCount int `json:"no_eosl_count"` + // TopImages is the handful of most at-risk images, for the dashboard bar chart. + TopImages []TopImage `json:"top_images"` + // Data for compliance reports ComplianceReports []complianceview.Data `json:"compliance_reports"` } + +// TopImage is a single image's severity breakdown for the "riskiest images" chart. +type TopImage struct { + Name string `json:"name"` + Critical int `json:"critical"` + High int `json:"high"` + Medium int `json:"medium"` + Low int `json:"low"` +} diff --git a/static/cves.html b/static/cves.html new file mode 100644 index 0000000..9332c73 --- /dev/null +++ b/static/cves.html @@ -0,0 +1,151 @@ + + + Explorer: CVEs + + + + + + + + + + + {{template "sidebar.html" .}} + +
+ +
+ +
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+ +
+ +
{{ .Result.Total }} CVEs
+ +
+ + + + + + + + + + + + + + {{ range $c := .Result.CVEs }} + {{ $rowID := $c.CVEID | sanitizeID }} + + + + + + + + + + + + + + {{ end }} + +
CVESeverityScoreFixClassImagesPressure
+
+ + {{ if $c.URL }}{{ $c.CVEID }}{{ else }}{{ $c.CVEID }}{{ end }} +
+
+ {{ if eq $c.Severity "CRITICAL" }}Critical + {{ else if eq $c.Severity "HIGH" }}High + {{ else if eq $c.Severity "MEDIUM" }}Medium + {{ else if eq $c.Severity "LOW" }}Low + {{ else }}{{ $c.Severity }}{{ end }} + {{ printf "%.1f" $c.MaxScore }} + {{ if $c.HasFix }}Fix available{{ else }}none{{ end }} + {{ $c.Class }}{{ if $c.PackageType }} ({{ $c.PackageType }}){{ end }}{{ $c.AffectedImageCount }}{{ printf "%.1f" $c.Pressure }}
+
+
+ + diff --git a/static/images.html b/static/images.html index 63e0c87..2c15e16 100644 --- a/static/images.html +++ b/static/images.html @@ -9,6 +9,7 @@ + @@ -17,8 +18,47 @@
+ +
+
+
Images
+
{{ .TotalImages }}
+
+
+
Critical
+
{{ .Stats.CriticalVulnerabilities }}
+
+
+
High
+
{{ .Stats.HighVulnerabilities }}
+
+
+
Medium / Low
+
{{ .Stats.MediumVulnerabilities }} / {{ .Stats.LowVulnerabilities }}
+
+
+
Fixable
+
{{ .Stats.FixAvailableCount }}
+
+
+
End of Service Life
+
{{ .Stats.EOSLCount }}
+
+
+ + +
+ + + Export CSV +
+
- +
@@ -31,6 +71,9 @@ + @@ -41,7 +84,18 @@ {{ $hasFix := .HasFix }} {{ $showIgnored := .ShowIgnored }} {{ range $data := .Data }} - + + + {{ $tot := add $data.FixAvailableCount $data.NoFixAvailableCount }} + - - +
Based On + Fixable + Vulnerabilities
@@ -68,6 +122,18 @@ {{ if .OSFamily }}{{ .OSFamily }} {{ .OSVersion }}{{ end }} {{ if .OSEndOfServiceLife }}EoSL{{ end }}
+ {{ if $tot }} +
{{ $data.FixAvailableCount }}/{{ $tot }}
+
+
+
+ {{ else }} + - + {{ end }} +
{{ if .Unscanned }}Unscanned{{ end }} @@ -94,8 +160,8 @@