Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions pkg/features/list.go
Original file line number Diff line number Diff line change
Expand Up @@ -194,4 +194,7 @@ var (

// ScannerV4RedHatVEXNotAffected enables filtering image vulnerabilities using Red Hat VEX known_not_affected assertions.
ScannerV4RedHatVEXNotAffected = registerFeature("Scanner V4 will filter image vulnerabilities using Red Hat VEX known_not_affected assertions", "ROX_SCANNER_V4_RED_HAT_VEX_NOT_AFFECTED")

// ScannerV4Dedupe de-duplicates packages and vulnerabilities from appearing in scan results.
ScannerV4Dedupe = registerFeature("Deduplicate packages and vulnerabilities found in Scanner V4 results.", "ROX_SCANNER_V4_DEDUPE")
)
214 changes: 188 additions & 26 deletions pkg/scanners/scannerv4/convert.go
Original file line number Diff line number Diff line change
@@ -1,7 +1,11 @@
package scannerv4

import (
"cmp"
"fmt"
"regexp"
"slices"
"strconv"
"strings"

v4 "github.com/stackrox/rox/generated/internalapi/scanner/v4"
Expand All @@ -22,6 +26,9 @@ import (
// Claircore or StackRox updater names.
const vulnDataSourceDelimiter = "::"

// digitSegment matches contiguous runs of digits for numeric segment comparisons.
var digitSegment = regexp.MustCompile(`\d+`)

func imageScan(metadata *storage.ImageMetadata, report *v4.VulnerabilityReport, scannerVersion string) *storage.ImageScan {
layerSHAToIndex := clair.BuildSHAToIndexMap(metadata)
if features.ScannerV4RedHatVEXNotAffected.Enabled() {
Expand All @@ -48,8 +55,34 @@ func componentsWithLayerMap(metadata *storage.ImageMetadata, report *v4.Vulnerab
pkgs[pkg.GetId()] = pkg
}
}
// Filter out packages that should not become user-facing components.
// Unreferenced source packages are kept defensively.
dedupe := features.ScannerV4Dedupe.Enabled()
var referencedSourceIDs set.StringSet
if dedupe {
referencedSourceIDs = set.NewStringSet()
for _, pkg := range pkgs {
if pkg.GetKind() != "binary" {
continue
}
if srcID := pkg.GetSource().GetId(); srcID != "" {
referencedSourceIDs.Add(srcID)
}
}
}

components := make([]*storage.EmbeddedImageScanComponent, 0, len(pkgs))
for id, pkg := range pkgs {
if dedupe {
switch pkg.GetKind() {
case "ancestry":
continue
case "source":
if referencedSourceIDs.Contains(id) {
continue
}
}
}
vulnIDs := report.GetPackageVulnerabilities()[id].GetValues()

var (
Expand All @@ -67,7 +100,7 @@ func componentsWithLayerMap(metadata *storage.ImageMetadata, report *v4.Vulnerab
Name: pkg.GetName(),
Version: pkg.GetVersion(),
Architecture: pkg.GetArch(),
Vulns: vulnerabilities(report.GetVulnerabilities(), vulnIDs, envOS(env, report)),
Vulns: vulnerabilities(report.GetVulnerabilities(), vulnIDs, envOS(env, report), pkg.GetFixedInVersion()),
FixedBy: pkg.GetFixedInVersion(),
Source: source,
Location: location,
Expand Down Expand Up @@ -177,13 +210,20 @@ func layerIndex(layerSHAToIndex map[string]int32, env *v4.Environment) *storage.
}
}

func vulnerabilities(vulnerabilities map[string]*v4.VulnerabilityReport_Vulnerability, ids []string, envOS string) []*storage.EmbeddedVulnerability {
func vulnerabilities(vulnerabilities map[string]*v4.VulnerabilityReport_Vulnerability, ids []string, envOS string, pkgFixedByVersion string) []*storage.EmbeddedVulnerability {
if len(vulnerabilities) == 0 || len(ids) == 0 {
return nil
}

dedupe := features.ScannerV4Dedupe.Enabled()

vulns := make([]*storage.EmbeddedVulnerability, 0, len(ids))
uniqueVulns := set.NewStringSet()
var cveNameToIdx map[string]int
if dedupe {
cveNameToIdx = make(map[string]int, len(ids))
}

for _, id := range ids {
if !uniqueVulns.Add(id) {
// Already saw this vulnerability, so ignore it.
Expand All @@ -196,38 +236,57 @@ func vulnerabilities(vulnerabilities map[string]*v4.VulnerabilityReport_Vulnerab
continue
}

// TODO(ROX-20355): Populate last modified once the API is available.
vuln := &storage.EmbeddedVulnerability{
Cve: ccVuln.GetName(),
Advisory: advisory(ccVuln.GetAdvisory()),
Summary: ccVuln.GetDescription(),
// TODO(ROX-26547)
// The link field will be overwritten if preferred CVSS source is available
Link: link(ccVuln.GetLink()),
PublishedOn: ccVuln.GetIssued(),
// LastModified: ,
VulnerabilityType: storage.EmbeddedVulnerability_IMAGE_VULNERABILITY,
Severity: normalizedSeverity(ccVuln.GetNormalizedSeverity()),
Epss: epss(ccVuln.GetEpssMetrics()),
FixAvailableTimestamp: ccVuln.GetFixedDate(),
Datasource: vulnDataSource(ccVuln, envOS),
}
if err := setScoresAndScoreVersions(vuln, ccVuln.GetCvssMetrics()); err != nil {
utils.Should(err)
}
maybeOverwriteSeverity(vuln)
if ccVuln.GetFixedInVersion() != "" {
vuln.SetFixedBy = &storage.EmbeddedVulnerability_FixedBy{
FixedBy: ccVuln.GetFixedInVersion(),
name := ccVuln.GetName()

// Multiple Scanner V4 vulns from different sources can share the
// same CVE identifier. Merge duplicates into a single entry.
if dedupe && name != "" {
if idx, exists := cveNameToIdx[name]; exists {
candidate := buildEmbeddedVulnerability(ccVuln, envOS)
mergeFixFields(vulns[idx], candidate, pkgFixedByVersion)
mergeScoringFields(vulns[idx], candidate)
continue
}
cveNameToIdx[name] = len(vulns)
}

vulns = append(vulns, vuln)
vulns = append(vulns, buildEmbeddedVulnerability(ccVuln, envOS))
}

return vulns
}

// buildEmbeddedVulnerability converts a single v4 vulnerability into its
// storage representation, populating all fields from the v4 source.
func buildEmbeddedVulnerability(ccVuln *v4.VulnerabilityReport_Vulnerability, envOS string) *storage.EmbeddedVulnerability {
// TODO(ROX-20355): Populate last modified once the API is available.
vuln := &storage.EmbeddedVulnerability{
Cve: ccVuln.GetName(),
Advisory: advisory(ccVuln.GetAdvisory()),
Summary: ccVuln.GetDescription(),
// TODO(ROX-26547)
// The link field will be overwritten if preferred CVSS source is available
Link: link(ccVuln.GetLink()),
PublishedOn: ccVuln.GetIssued(),
// LastModified: ,
VulnerabilityType: storage.EmbeddedVulnerability_IMAGE_VULNERABILITY,
Severity: normalizedSeverity(ccVuln.GetNormalizedSeverity()),
Epss: epss(ccVuln.GetEpssMetrics()),
FixAvailableTimestamp: ccVuln.GetFixedDate(),
Datasource: vulnDataSource(ccVuln, envOS),
}
if err := setScoresAndScoreVersions(vuln, ccVuln.GetCvssMetrics()); err != nil {
utils.Should(err)
}
maybeOverwriteSeverity(vuln)
if ccVuln.GetFixedInVersion() != "" {
vuln.SetFixedBy = &storage.EmbeddedVulnerability_FixedBy{
FixedBy: ccVuln.GetFixedInVersion(),
}
}
return vuln
}

// vulnDataSource builds a string that uniquely identifies a vulnerability's datasource.
// The datasource represents CVE uniqueness and can be used to associate a CVE with
// other data, such as fixed date.
Expand Down Expand Up @@ -587,3 +646,106 @@ func notes(report *v4.VulnerabilityReport) []storage.ImageScan_Note {

return notes
}

// mergeFixFields overwrites fix-related fields on dst when src has more
// recent or more complete fix data. Priority: later fix date, has fix over
// doesn't, matches package-level fix version, higher version by numeric
// comparison.
func mergeFixFields(dst, src *storage.EmbeddedVulnerability, pkgFixedByVersion string) {
c := cmp.Or(
protocompat.CompareTimestamps(src.GetFixAvailableTimestamp(), dst.GetFixAvailableTimestamp()),
compareFixVersions(src.GetFixedBy(), dst.GetFixedBy(), pkgFixedByVersion),
)
if c > 0 {
applyFixFields(dst, src)
}
}

// compareFixVersions returns positive when a represents a more complete or
// higher fix version than b. Priority: having a fix over not, matching
// pkgFixedBy, higher version by numeric comparison.
func compareFixVersions(a, b, pkgFixedBy string) int {
aHasFix, bHasFix := a != "", b != ""
if aHasFix != bHasFix {
if aHasFix {
return 1
}
return -1
}
if !aHasFix {
return 0
}
if pkgFixedBy != "" && (a == pkgFixedBy) != (b == pkgFixedBy) {
if a == pkgFixedBy {
return 1
}
return -1
}
// Reaching here means both have a fix, neither matches pkgFixedBy (or it
// is empty), and the versions disagree. This is rare — it requires two
// sources to report different fix versions for the same CVE. Use a
// deterministic numeric comparison so the result is stable across runs.
if a != b {
c := compareNumericSegments(a, b)
winner := a
if c < 0 {
winner = b
}
log.Debugf("fix version mismatch during dedup: %q vs %q, picking %q via numeric comparison", a, b, winner)
return c
}
Comment thread
jvdm marked this conversation as resolved.
return 0
}

// applyFixFields overwrites fix-related fields on dst from src.
func applyFixFields(dst, src *storage.EmbeddedVulnerability) {
dst.Advisory = src.GetAdvisory()
dst.Datasource = src.GetDatasource()
dst.FixAvailableTimestamp = src.GetFixAvailableTimestamp()
dst.SetFixedBy = src.GetSetFixedBy()
}

// compareNumericSegments compares two strings by extracting their numeric
// segments and comparing left-to-right, falling back to lexicographic order.
func compareNumericSegments(a, b string) int {
if c := slices.Compare(splitVersionNumbers(a), splitVersionNumbers(b)); c != 0 {
return c
}
return cmp.Compare(a, b)
}

func splitVersionNumbers(v string) []int {
matches := digitSegment.FindAllString(v, -1)
nums := make([]int, 0, len(matches))
for _, m := range matches {
n, _ := strconv.Atoi(m)
nums = append(nums, n)
}
return nums
}

// mergeScoringFields overwrites scoring-related fields on dst when src has more
// complete or higher-severity scoring data. Priority: more CVSS metrics, higher
// severity, higher CVSS base score.
func mergeScoringFields(dst, src *storage.EmbeddedVulnerability) {
c := cmp.Or(
cmp.Compare(len(src.GetCvssMetrics()), len(dst.GetCvssMetrics())),
cmp.Compare(src.GetSeverity(), dst.GetSeverity()),
cmp.Compare(src.GetCvss(), dst.GetCvss()),
)
if c <= 0 {
return
}

dst.Summary = src.GetSummary()
dst.Severity = src.GetSeverity()
dst.CvssV2 = src.GetCvssV2()
dst.CvssV3 = src.GetCvssV3()
dst.Cvss = src.GetCvss()
dst.ScoreVersion = src.GetScoreVersion()
dst.CvssMetrics = src.GetCvssMetrics()
dst.NvdCvss = src.GetNvdCvss()
dst.Link = src.GetLink()
dst.PublishedOn = src.GetPublishedOn()
dst.Epss = src.GetEpss()
}
Loading
Loading