Repository: anchore/grype
Pull request #2589 (2025-04-09 19:43:29)

Fix severity field in `db search vuln`
Today there are raw `fmt.Sprintf("%v")` values without any stringer implemented (specifically when there is a CVSS as the primary severity entry). This PR fixes this behavior to show the string severity in this case.

<img width="1791" alt="Screenshot 2025-04-09 at 3 43 05 PM" src="https://github.com/user-attachments/assets/371d5086-3c73-4c12-bb70-7cc437a718f9" />



Patch:
diff --git a/cmd/grype/cli/commands/db_search_vuln.go b/cmd/grype/cli/commands/db_search_vuln.go
index f9ea918b..22847c04 100644
--- a/cmd/grype/cli/commands/db_search_vuln.go
+++ b/cmd/grype/cli/commands/db_search_vuln.go
@@ -6,6 +6,7 @@ import (
 	"io"
 	"sort"
 	"strings"
+	"time"
 
 	"github.com/hashicorp/go-multierror"
 	"github.com/scylladb/go-set/strset"
@@ -17,7 +18,9 @@ import (
 	v6 "github.com/anchore/grype/grype/db/v6"
 	"github.com/anchore/grype/grype/db/v6/distribution"
 	"github.com/anchore/grype/grype/db/v6/installation"
+	"github.com/anchore/grype/grype/vulnerability"
 	"github.com/anchore/grype/internal/bus"
+	"github.com/anchore/grype/internal/cvss"
 )
 
 type dbSearchVulnerabilityOptions struct {
@@ -166,36 +169,14 @@ func renderDBSearchVulnerabilitiesTableRows(structuredRows []dbsearch.Vulnerabil
 
 	versionsByRow := make(map[row][]string)
 	for _, rr := range structuredRows {
-		// get the first severity value (which is ranked highest)
-		var sev string
-		if len(rr.Severities) > 0 {
-			sev = fmt.Sprintf("%s", rr.Severities[0].Value)
-		}
-
-		prov := rr.Provider
-		var versions []string
-		for _, os := range rr.OperatingSystems {
-			versions = append(versions, os.Version)
-		}
-
-		var published string
-		if rr.PublishedDate != nil && !rr.PublishedDate.IsZero() {
-			published = rr.PublishedDate.Format("2006-01-02")
-		}
-
-		var ref string
-		if len(rr.References) > 0 {
-			ref = rr.References[0].URL
-		}
-
 		r := row{
 			Vuln:                    rr.ID,
-			ProviderWithoutVersions: prov,
-			PublishedDate:           published,
-			Severity:                sev,
-			Reference:               ref,
+			ProviderWithoutVersions: rr.Provider,
+			PublishedDate:           getDate(rr.PublishedDate),
+			Severity:                getSeverity(rr.Severities),
+			Reference:               getPrimaryReference(rr.References),
 		}
-		versionsByRow[r] = append(versionsByRow[r], versions...)
+		versionsByRow[r] = append(versionsByRow[r], getOSVersions(rr.OperatingSystems)...)
 	}
 
 	var rows [][]string
@@ -220,3 +201,41 @@ func renderDBSearchVulnerabilitiesTableRows(structuredRows []dbsearch.Vulnerabil
 
 	return rows
 }
+
+func getOSVersions(oss []dbsearch.OperatingSystem) []string {
+	var versions []string
+	for _, os := range oss {
+		versions = append(versions, os.Version)
+	}
+	return versions
+}
+
+func getPrimaryReference(refs []v6.Reference) string {
+	if len(refs) > 0 {
+		return refs[0].URL
+	}
+
+	return ""
+}
+
+func getDate(t *time.Time) string {
+	if t != nil && !t.IsZero() {
+		return t.Format("2006-01-02")
+	}
+	return ""
+}
+
+func getSeverity(sevs []v6.Severity) string {
+	if len(sevs) == 0 {
+		return vulnerability.UnknownSeverity.String()
+	}
+	// get the first severity value (which is ranked highest)
+	switch v := sevs[0].Value.(type) {
+	case string:
+		return v
+	case dbsearch.CVSSSeverity:
+		return cvss.SeverityFromBaseScore(v.Metrics.BaseScore).String()
+	}
+
+	return fmt.Sprintf("%v", sevs[0].Value)
+}
diff --git a/grype/presenter/sarif/presenter.go b/grype/presenter/sarif/presenter.go
index 57cbbbe7..ac8d0b28 100644
--- a/grype/presenter/sarif/presenter.go
+++ b/grype/presenter/sarif/presenter.go
@@ -329,6 +329,20 @@ func securitySeverityValue(m models.Match) string {
 	return "0.0"
 }
 
+func levelValue(m models.Match) string {
+	severity := vulnerability.ParseSeverity(m.Vulnerability.Severity)
+	switch severity {
+	case vulnerability.CriticalSeverity:
+		return "error"
+	case vulnerability.HighSeverity:
+		return "error"
+	case vulnerability.MediumSeverity:
+		return "warning"
+	}
+
+	return "note"
+}
+
 // subtitle generates a subtitle for the given match
 func subtitle(m models.Match) string {
 	subtitle := m.Vulnerability.Description
@@ -360,6 +374,7 @@ func (p Presenter) sarifResults() []*sarif.Result {
 	for _, m := range p.document.Matches {
 		out = append(out, &sarif.Result{
 			RuleID:  sp(p.ruleID(m)),
+			Level:   sp(levelValue(m)),
 			Message: p.resultMessage(m),
 			// According to the SARIF spec, it may be correct to use AnalysisTarget.URI to indicate a logical
 			// file such as a "Dockerfile" but GitHub does not work well with this
diff --git a/grype/vulnerability_matcher.go b/grype/vulnerability_matcher.go
index 6c526693..e0889ae5 100644
--- a/grype/vulnerability_matcher.go
+++ b/grype/vulnerability_matcher.go
@@ -3,6 +3,7 @@ package grype
 import (
 	"errors"
 	"fmt"
+	"runtime/debug"
 	"strings"
 
 	"github.com/wagoodman/go-partybus"
@@ -179,7 +180,7 @@ func (m *VulnerabilityMatcher) searchDBForMatches(
 			matchAgainst = []match.Matcher{defaultMatcher}
 		}
 		for _, theMatcher := range matchAgainst {
-			matches, ignoredMatches, err := theMatcher.Match(m.VulnerabilityProvider, p)
+			matches, ignoredMatches, err := callMatcherSafely(theMatcher, m.VulnerabilityProvider, p)
 			if err != nil {
 				if match.IsFatalError(err) {
 					return match.Matches{}, err
@@ -223,6 +224,16 @@ func (m *VulnerabilityMatcher) searchDBForMatches(
 	return res, errors.Join(matcherErrs...)
 }
 
+func callMatcherSafely(m match.Matcher, vp vulnerability.Provider, p pkg.Package) (matches []match.Match, ignoredMatches []match.IgnoredMatch, err error) {
+	// handle individual matcher panics
+	defer func() {
+		if e := recover(); e != nil {
+			err = match.NewFatalError(m.Type(), fmt.Errorf("%v at:\n%s", e, string(debug.Stack())))
+		}
+	}()
+	return m.Match(vp, p)
+}
+
 func (m *VulnerabilityMatcher) findVEXMatches(context pkg.Context, remainingMatches *match.Matches, ignoredMatches []match.IgnoredMatch, progressMonitor *monitorWriter) (*match.Matches, []match.IgnoredMatch, error) {
 	if m.VexProcessor == nil {
 		log.Trace("no VEX documents provided, skipping VEX matching")
diff --git a/internal/cvss/metrics.go b/internal/cvss/metrics.go
index 2d1a0fcc..0a8fe77b 100644
--- a/internal/cvss/metrics.go
+++ b/internal/cvss/metrics.go
@@ -64,6 +64,24 @@ func ParseMetricsFromVector(vector string) (*vulnerability.CvssMetrics, error) {
 	}
 }
 
+func SeverityFromBaseScore(bs float64) vulnerability.Severity {
+	switch {
+	case bs >= 10.0:
+		return vulnerability.UnknownSeverity
+	case bs >= 9.0:
+		return vulnerability.CriticalSeverity
+	case bs >= 7.0:
+		return vulnerability.HighSeverity
+	case bs >= 4.0:
+		return vulnerability.MediumSeverity
+	case bs >= 0.1:
+		return vulnerability.LowSeverity
+	case bs > 0:
+		return vulnerability.NegligibleSeverity
+	}
+	return vulnerability.UnknownSeverity
+}
+
 // roundScore rounds the score to the nearest tenth based on first.org rounding rules
 // see https://www.first.org/cvss/v3.1/specification-document#Appendix-A---Floating-Point-Rounding
 func roundScore(score float64) float64 {
diff --git a/internal/file/copy.go b/internal/file/copy.go
index 45080444..53fcb654 100644
--- a/internal/file/copy.go
+++ b/internal/file/copy.go
@@ -11,7 +11,7 @@ import (
 
 func CopyDir(fs afero.Fs, src string, dst string) error {
 	var err error
-	var fds []os.DirEntry
+	var fds []os.FileInfo // <-- afero.ReadDir returns []os.FileInfo
 	var srcinfo os.FileInfo
 
 	if srcinfo, err = fs.Stat(src); err != nil {
@@ -22,7 +22,7 @@ func CopyDir(fs afero.Fs, src string, dst string) error {
 		return err
 	}
 
-	if fds, err = os.ReadDir(src); err != nil {
+	if fds, err = afero.ReadDir(fs, src); err != nil {
 		return err
 	}
 	for _, fd := range fds {


Test patch:
diff --git a/cmd/grype/cli/commands/db_search_vuln_test.go b/cmd/grype/cli/commands/db_search_vuln_test.go
new file mode 100644
index 00000000..74202443
--- /dev/null
+++ b/cmd/grype/cli/commands/db_search_vuln_test.go
@@ -0,0 +1,228 @@
+package commands
+
+import (
+	"testing"
+	"time"
+
+	"github.com/stretchr/testify/require"
+
+	"github.com/anchore/grype/cmd/grype/cli/commands/internal/dbsearch"
+	v6 "github.com/anchore/grype/grype/db/v6"
+	"github.com/anchore/grype/grype/vulnerability"
+)
+
+func TestGetOSVersions(t *testing.T) {
+	tests := []struct {
+		name     string
+		input    []dbsearch.OperatingSystem
+		expected []string
+	}{
+		{
+			name:     "empty list",
+			input:    []dbsearch.OperatingSystem{},
+			expected: nil,
+		},
+		{
+			name: "single os",
+			input: []dbsearch.OperatingSystem{
+				{
+					Name:    "debian",
+					Version: "11",
+				},
+			},
+			expected: []string{"11"},
+		},
+		{
+			name: "multiple os",
+			input: []dbsearch.OperatingSystem{
+				{
+					Name:    "ubuntu",
+					Version: "16.04",
+				},
+				{
+					Name:    "ubuntu",
+					Version: "22.04",
+				},
+				{
+					Name:    "ubuntu",
+					Version: "24.04",
+				},
+			},
+			expected: []string{"16.04", "22.04", "24.04"},
+		},
+	}
+
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			actual := getOSVersions(tt.input)
+			require.Equal(t, tt.expected, actual)
+		})
+	}
+}
+
+func TestGetPrimaryReference(t *testing.T) {
+	tests := []struct {
+		name     string
+		input    []v6.Reference
+		expected string
+	}{
+		{
+			name:     "empty list",
+			input:    []v6.Reference{},
+			expected: "",
+		},
+		{
+			name: "single reference",
+			input: []v6.Reference{
+				{
+					URL:  "https://example.com/vuln/123",
+					Tags: []string{"primary"},
+				},
+			},
+			expected: "https://example.com/vuln/123",
+		},
+		{
+			name: "multiple references",
+			input: []v6.Reference{
+				{
+					URL:  "https://example.com/vuln/123",
+					Tags: []string{"primary"},
+				},
+				{
+					URL:  "https://example.com/advisory/123",
+					Tags: []string{"secondary"},
+				},
+			},
+			expected: "https://example.com/vuln/123",
+		},
+	}
+
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			actual := getPrimaryReference(tt.input)
+			require.Equal(t, tt.expected, actual)
+		})
+	}
+}
+
+func TestGetDate(t *testing.T) {
+	tests := []struct {
+		name     string
+		input    *time.Time
+		expected string
+	}{
+		{
+			name:     "nil time",
+			input:    nil,
+			expected: "",
+		},
+		{
+			name:     "zero time",
+			input:    &time.Time{},
+			expected: "",
+		},
+		{
+			name:     "valid time",
+			input:    timePtr(time.Date(2023, 5, 15, 0, 0, 0, 0, time.UTC)),
+			expected: "2023-05-15",
+		},
+	}
+
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			actual := getDate(tt.input)
+			require.Equal(t, tt.expected, actual)
+		})
+	}
+}
+
+func TestGetSeverity(t *testing.T) {
+	tests := []struct {
+		name     string
+		input    []v6.Severity
+		expected string
+	}{
+		{
+			name:     "empty list",
+			input:    []v6.Severity{},
+			expected: vulnerability.UnknownSeverity.String(),
+		},
+		{
+			name: "string severity",
+			input: []v6.Severity{
+				{
+					Scheme: "HML",
+					Value:  "high",
+					Source: "nvd@nist.gov",
+					Rank:   1,
+				},
+			},
+			expected: "high",
+		},
+		{
+			name: "CVSS severity",
+			input: []v6.Severity{
+				{
+					Scheme: "CVSS_V3",
+					Value: dbsearch.CVSSSeverity{
+						Vector:  "CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H",
+						Version: "3.1",
+						Metrics: dbsearch.CvssMetrics{
+							BaseScore: 9.8,
+						},
+					},
+					Source: "nvd@nist.gov",
+					Rank:   1,
+				},
+			},
+			expected: "critical",
+		},
+		{
+			name: "other value type",
+			input: []v6.Severity{
+				{
+					Scheme: "OTHER",
+					Value:  42.0,
+					Source: "custom",
+					Rank:   1,
+				},
+			},
+			expected: "42",
+		},
+		{
+			name: "multiple severities",
+			input: []v6.Severity{
+				{
+					Scheme: "HML",
+					Value:  "high",
+					Source: "nvd@nist.gov",
+					Rank:   1,
+				},
+				{
+					Scheme: "CVSS_V3",
+					Value: dbsearch.CVSSSeverity{
+						Vector:  "CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H",
+						Version: "3.1",
+						Metrics: dbsearch.CvssMetrics{
+							BaseScore: 9.8,
+						},
+					},
+					Source: "nvd@nist.gov",
+					Rank:   2,
+				},
+			},
+			expected: "high",
+		},
+	}
+
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			actual := getSeverity(tt.input)
+			require.Equal(t, tt.expected, actual)
+		})
+	}
+}
+
+func timePtr(t time.Time) *time.Time {
+	return &t
+}
diff --git a/grype/presenter/sarif/presenter_test.go b/grype/presenter/sarif/presenter_test.go
index 50693d09..cec30818 100644
--- a/grype/presenter/sarif/presenter_test.go
+++ b/grype/presenter/sarif/presenter_test.go
@@ -286,6 +286,7 @@ func TestToSarifReport(t *testing.T) {
 			assert.Len(t, run.Results, 2)
 			result := run.Results[0]
 			assert.Equal(t, "CVE-1999-0001-package-1", *result.RuleID)
+			assert.Equal(t, "note", *result.Level)
 			assert.Len(t, result.Locations, 1)
 			location := result.Locations[0]
 			expectedLocation, ok := tc.locations[*result.RuleID]
@@ -296,6 +297,7 @@ func TestToSarifReport(t *testing.T) {
 
 			result = run.Results[1]
 			assert.Equal(t, "CVE-1999-0002-package-2", *result.RuleID)
+			assert.Equal(t, "error", *result.Level)
 			assert.Len(t, result.Locations, 1)
 			location = result.Locations[0]
 			expectedLocation, ok = tc.locations[*result.RuleID]
diff --git a/grype/presenter/sarif/test-fixtures/snapshot/TestSarifPresenter_directory.golden b/grype/presenter/sarif/test-fixtures/snapshot/TestSarifPresenter_directory.golden
index 9edb968a..fa07c7fd 100644
--- a/grype/presenter/sarif/test-fixtures/snapshot/TestSarifPresenter_directory.golden
+++ b/grype/presenter/sarif/test-fixtures/snapshot/TestSarifPresenter_directory.golden
@@ -54,6 +54,7 @@
       "results": [
         {
           "ruleId": "CVE-1999-0001-package-1",
+          "level": "note",
           "message": {
             "text": "A low vulnerability in rpm package: package-1, version 1.1.1 was found at: /some/path/somefile-1.txt"
           },
@@ -78,6 +79,7 @@
         },
         {
           "ruleId": "CVE-1999-0002-package-2",
+          "level": "error",
           "message": {
             "text": "A critical vulnerability in deb package: package-2, version 2.2.2 was found at: /some/path/somefile-2.txt"
           },
diff --git a/grype/presenter/sarif/test-fixtures/snapshot/TestSarifPresenter_image.golden b/grype/presenter/sarif/test-fixtures/snapshot/TestSarifPresenter_image.golden
index 027cd809..2de58e3e 100644
--- a/grype/presenter/sarif/test-fixtures/snapshot/TestSarifPresenter_image.golden
+++ b/grype/presenter/sarif/test-fixtures/snapshot/TestSarifPresenter_image.golden
@@ -54,6 +54,7 @@
       "results": [
         {
           "ruleId": "CVE-1999-0001-package-1",
+          "level": "note",
           "message": {
             "text": "A low vulnerability in rpm package: package-1, version 1.1.1 was found in image user-input at: somefile-1.txt"
           },
@@ -84,6 +85,7 @@
         },
         {
           "ruleId": "CVE-1999-0002-package-2",
+          "level": "error",
           "message": {
             "text": "A critical vulnerability in deb package: package-2, version 2.2.2 was found in image user-input at: somefile-2.txt"
           },
diff --git a/grype/vulnerability_matcher_test.go b/grype/vulnerability_matcher_test.go
index 9b3f66ad..baf5658b 100644
--- a/grype/vulnerability_matcher_test.go
+++ b/grype/vulnerability_matcher_test.go
@@ -1385,6 +1385,34 @@ func Test_filterMatchesUsingDistroFalsePositives(t *testing.T) {
 	}
 }
 
+type panicyMatcher struct {
+	matcherType match.MatcherType
+}
+
+func (m *panicyMatcher) PackageTypes() []syftPkg.Type {
+	return nil
+}
+
+func (m *panicyMatcher) Type() match.MatcherType {
+	return m.matcherType
+}
+
+func (m *panicyMatcher) Match(_ vulnerability.Provider, _ pkg.Package) ([]match.Match, []match.IgnoredMatch, error) {
+	panic("test panic message")
+}
+
+func TestCallMatcherSafely_RecoverFromPanic(t *testing.T) {
+	matcher := &panicyMatcher{
+		matcherType: "test-matcher",
+	}
+	_, _, err := callMatcherSafely(matcher, nil, pkg.Package{})
+
+	require.Error(t, err)
+	assert.True(t, match.IsFatalError(err))
+	require.Contains(t, err.Error(), "test panic message", "missing message")
+	require.Contains(t, err.Error(), "test-matcher", "missing matcher name")
+}
+
 type busListener struct {
 	matching monitor.Matching
 }
diff --git a/internal/cvss/metrics_test.go b/internal/cvss/metrics_test.go
index dedbc05d..83e4a19c 100644
--- a/internal/cvss/metrics_test.go
+++ b/internal/cvss/metrics_test.go
@@ -110,6 +110,86 @@ func TestParseMetricsFromVector(t *testing.T) {
 	}
 }
 
+func TestSeverityFromBaseScore(t *testing.T) {
+	tests := []struct {
+		name     string
+		score    float64
+		expected vulnerability.Severity
+	}{
+		{
+			name:     "unknown severity (exactly 10.0)",
+			score:    10.0,
+			expected: vulnerability.UnknownSeverity,
+		},
+		{
+			name:     "unknown severity (greater than 10.0)",
+			score:    10.1,
+			expected: vulnerability.UnknownSeverity,
+		},
+		{
+			name:     "critical severity (lower bound)",
+			score:    9.0,
+			expected: vulnerability.CriticalSeverity,
+		},
+		{
+			name:     "critical severity (upper bound)",
+			score:    9.9,
+			expected: vulnerability.CriticalSeverity,
+		},
+		{
+			name:     "high severity (lower bound)",
+			score:    7.0,
+			expected: vulnerability.HighSeverity,
+		},
+		{
+			name:     "high severity (upper bound)",
+			score:    8.9,
+			expected: vulnerability.HighSeverity,
+		},
+		{
+			name:     "medium severity (lower bound)",
+			score:    4.0,
+			expected: vulnerability.MediumSeverity,
+		},
+		{
+			name:     "medium severity (upper bound)",
+			score:    6.9,
+			expected: vulnerability.MediumSeverity,
+		},
+		{
+			name:     "low severity (lower bound)",
+			score:    0.1,
+			expected: vulnerability.LowSeverity,
+		},
+		{
+			name:     "low severity (upper bound)",
+			score:    3.9,
+			expected: vulnerability.LowSeverity,
+		},
+		{
+			name:     "negligible severity (between 0 and 0.1)",
+			score:    0.05,
+			expected: vulnerability.NegligibleSeverity,
+		},
+		{
+			name:     "unknown severity (exactly zero)",
+			score:    0.0,
+			expected: vulnerability.UnknownSeverity,
+		},
+		{
+			name:     "unknown severity (negative)",
+			score:    -1.0,
+			expected: vulnerability.UnknownSeverity,
+		},
+	}
+
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			assert.Equal(t, tt.expected, SeverityFromBaseScore(tt.score))
+		})
+	}
+}
+
 func ptr(f float64) *float64 {
 	return &f
 }