Repository: anchore/grype
Pull request #2182 (2024-10-14 13:38:24)

Skip matching on packages with missing version info
As discussed on [discourse](https://anchorecommunity.discourse.group/t/grype-reporting-vulns-for-unknown-versions/174/5), grype should not be searching for packages that are missing version information as these will always yield incorrect results.

Additionally while working on this it was found that searched CPE versions were not always being raised accurately (when being overridden) -- this PR additionally fixes this behavior.


Patch:
diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml
index 22e7f27f..56ae61ed 100644
--- a/.github/workflows/release.yaml
+++ b/.github/workflows/release.yaml
@@ -158,7 +158,7 @@ jobs:
           # for updating brew formula in anchore/homebrew-syft
           GITHUB_BREW_TOKEN: ${{ secrets.ANCHOREOPS_GITHUB_OSS_WRITE_TOKEN }}
 
-      - uses: anchore/sbom-action@61119d458adab75f756bc0b9e4bde25725f86a7a # v0.17.2
+      - uses: anchore/sbom-action@f5e124a5e5e1d497a692818ae907d3c45829d033 # v0.17.3
         continue-on-error: true
         with:
           artifact-name: sbom.spdx.json
diff --git a/cmd/grype/cli/commands/root.go b/cmd/grype/cli/commands/root.go
index 90d2ecd9..e70a8c90 100644
--- a/cmd/grype/cli/commands/root.go
+++ b/cmd/grype/cli/commands/root.go
@@ -36,6 +36,7 @@ import (
 	"github.com/anchore/grype/internal/log"
 	"github.com/anchore/grype/internal/stringutil"
 	"github.com/anchore/syft/syft"
+	"github.com/anchore/syft/syft/cataloging"
 	"github.com/anchore/syft/syft/linux"
 	syftPkg "github.com/anchore/syft/syft/pkg"
 	"github.com/anchore/syft/syft/sbom"
@@ -306,6 +307,11 @@ func getProviderConfig(opts *options.Grype) pkg.ProviderConfig {
 	cfg.Packages.JavaArchive.IncludeIndexedArchives = opts.Search.IncludeIndexedArchives
 	cfg.Packages.JavaArchive.IncludeUnindexedArchives = opts.Search.IncludeUnindexedArchives
 
+	// when we run into a package with missing information like version, then this is not useful in the context
+	// of vulnerability matching. Though there will be downstream processing to handle this case, we can still
+	// save us the effort of ever attempting to match with these packages as early as possible.
+	cfg.Compliance.MissingVersion = cataloging.ComplianceActionDrop
+
 	return pkg.ProviderConfig{
 		SyftProviderConfig: pkg.SyftProviderConfig{
 			RegistryOptions:        opts.Registry.ToOptions(),
diff --git a/go.mod b/go.mod
index 2ff40de6..f9d3c1b1 100644
--- a/go.mod
+++ b/go.mod
@@ -24,7 +24,7 @@ require (
 	github.com/docker/docker v27.3.1+incompatible
 	github.com/dustin/go-humanize v1.0.1
 	github.com/facebookincubator/nvdtools v0.1.5
-	github.com/gabriel-vasile/mimetype v1.4.5
+	github.com/gabriel-vasile/mimetype v1.4.6
 	github.com/gkampitakis/go-snaps v0.5.7
 	github.com/glebarez/sqlite v1.11.0
 	github.com/go-test/deep v1.1.1
diff --git a/go.sum b/go.sum
index 5b172224..07f289a8 100644
--- a/go.sum
+++ b/go.sum
@@ -435,8 +435,8 @@ github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7z
 github.com/fsnotify/fsnotify v1.5.1/go.mod h1:T3375wBYaZdLLcVNkcVbzGHY7f1l/uK5T5Ai1i3InKU=
 github.com/fsnotify/fsnotify v1.7.0 h1:8JEhPFa5W2WU7YfeZzPNqzMP6Lwt7L2715Ggo0nosvA=
 github.com/fsnotify/fsnotify v1.7.0/go.mod h1:40Bi/Hjc2AVfZrqy+aj+yEI+/bRxZnMJyTJwOpGvigM=
-github.com/gabriel-vasile/mimetype v1.4.5 h1:J7wGKdGu33ocBOhGy0z653k/lFKLFDPJMG8Gql0kxn4=
-github.com/gabriel-vasile/mimetype v1.4.5/go.mod h1:ibHel+/kbxn9x2407k1izTA1S81ku1z/DlgOW2QE0M4=
+github.com/gabriel-vasile/mimetype v1.4.6 h1:3+PzJTKLkvgjeTbts6msPJt4DixhT4YtFNf1gtGe3zc=
+github.com/gabriel-vasile/mimetype v1.4.6/go.mod h1:JX1qVKqZd40hUPpAfiNTe0Sne7hdfKSbOqqmkq8GCXc=
 github.com/ghodss/yaml v1.0.0/go.mod h1:4dBDuWmgqj2HViK6kFavaiC9ZROes6MMH2rRYeMEF04=
 github.com/github/go-spdx/v2 v2.3.2 h1:IfdyNHTqzs4zAJjXdVQfRnxt1XMfycXoHBE2Vsm1bjs=
 github.com/github/go-spdx/v2 v2.3.2/go.mod h1:2ZxKsOhvBp+OYBDlsGnUMcchLeo2mrpEBn2L1C+U3IQ=
diff --git a/grype/matcher/rpm/matcher.go b/grype/matcher/rpm/matcher.go
index 9840f0c1..3003f582 100644
--- a/grype/matcher/rpm/matcher.go
+++ b/grype/matcher/rpm/matcher.go
@@ -126,7 +126,7 @@ func (m *Matcher) matchPackage(store vulnerability.ProviderByDistro, d *distro.D
 	// we want to ensure that the version ALWAYS has an epoch specified...
 	originalPkg := p
 
-	p.Version = addZeroEpicIfApplicable(p.Version)
+	addEpochIfApplicable(&p)
 
 	matches, err := search.ByPackageDistro(store, d, p, m.Type())
 	if err != nil {
@@ -141,9 +141,18 @@ func (m *Matcher) matchPackage(store vulnerability.ProviderByDistro, d *distro.D
 	return matches, nil
 }
 
-func addZeroEpicIfApplicable(version string) string {
-	if strings.Contains(version, ":") {
-		return version
+func addEpochIfApplicable(p *pkg.Package) {
+	meta, ok := p.Metadata.(pkg.RpmMetadata)
+	version := p.Version
+	switch {
+	case strings.Contains(version, ":"):
+		// we already have an epoch embedded in the version string
+		return
+	case ok && meta.Epoch != nil:
+		// we have an explicit epoch in the metadata
+		p.Version = fmt.Sprintf("%d:%s", *meta.Epoch, version)
+	default:
+		// no epoch was found, so we will add one
+		p.Version = "0:" + version
 	}
-	return "0:" + version
 }
diff --git a/grype/search/cpe.go b/grype/search/cpe.go
index d876b0e4..b709e7b7 100644
--- a/grype/search/cpe.go
+++ b/grype/search/cpe.go
@@ -14,6 +14,7 @@ import (
 	"github.com/anchore/grype/grype/pkg"
 	"github.com/anchore/grype/grype/version"
 	"github.com/anchore/grype/grype/vulnerability"
+	"github.com/anchore/grype/internal/log"
 	"github.com/anchore/syft/syft/cpe"
 	syftPkg "github.com/anchore/syft/syft/pkg"
 )
@@ -102,10 +103,19 @@ func ByPackageCPE(store vulnerability.ProviderByCPE, d *distro.Distro, p pkg.Pac
 			searchVersion = alpineCPEComparableVersion(searchVersion)
 		}
 
-		if searchVersion == wfn.NA || searchVersion == wfn.Any {
+		if searchVersion == wfn.NA || searchVersion == wfn.Any || isUnknownVersion(searchVersion) {
 			searchVersion = p.Version
 		}
 
+		if isUnknownVersion(searchVersion) {
+			log.WithFields("package", p.Name).Trace("skipping package with unknown version")
+			continue
+		}
+
+		// we should always show the exact CPE we searched by, not just what's in the component analysis (since we
+		// may alter the version based on above processing)
+		c.Attributes.Version = searchVersion
+
 		format := version.FormatFromPkg(p)
 
 		if format == version.JVMFormat {
diff --git a/grype/search/distro.go b/grype/search/distro.go
index 2a5cd9d3..ed59a919 100644
--- a/grype/search/distro.go
+++ b/grype/search/distro.go
@@ -3,6 +3,7 @@ package search
 import (
 	"errors"
 	"fmt"
+	"strings"
 
 	"github.com/anchore/grype/grype/distro"
 	"github.com/anchore/grype/grype/match"
@@ -17,6 +18,11 @@ func ByPackageDistro(store vulnerability.ProviderByDistro, d *distro.Distro, p p
 		return nil, nil
 	}
 
+	if isUnknownVersion(p.Version) {
+		log.WithFields("package", p.Name).Trace("skipping package with unknown version")
+		return nil, nil
+	}
+
 	verObj, err := version.NewVersionFromPkg(p)
 	if err != nil {
 		if errors.Is(err, version.ErrUnsupportedVersion) {
@@ -77,3 +83,7 @@ func ByPackageDistro(store vulnerability.ProviderByDistro, d *distro.Distro, p p
 
 	return matches, err
 }
+
+func isUnknownVersion(v string) bool {
+	return v == "" || strings.ToLower(v) == "unknown"
+}
diff --git a/grype/search/language.go b/grype/search/language.go
index b0a80646..e4034b52 100644
--- a/grype/search/language.go
+++ b/grype/search/language.go
@@ -13,6 +13,12 @@ import (
 )
 
 func ByPackageLanguage(store vulnerability.ProviderByLanguage, d *distro.Distro, p pkg.Package, upstreamMatcher match.MatcherType) ([]match.Match, error) {
+	if isUnknownVersion(p.Version) {
+		log.WithFields("package", p.Name).Trace("skipping package with unknown version")
+
+		return nil, nil
+	}
+
 	verObj, err := version.NewVersionFromPkg(p)
 	if err != nil {
 		if errors.Is(err, version.ErrUnsupportedVersion) {
diff --git a/grype/version/rpm_version.go b/grype/version/rpm_version.go
index 430b5e03..af77467f 100644
--- a/grype/version/rpm_version.go
+++ b/grype/version/rpm_version.go
@@ -2,7 +2,6 @@ package version
 
 import (
 	"fmt"
-	"math"
 	"reflect"
 	"regexp"
 	"strconv"
@@ -151,10 +150,11 @@ func compareRpmVersions(a, b string) int {
 	// get alpha/numeric segments
 	segsa := alphanumPattern.FindAllString(a, -1)
 	segsb := alphanumPattern.FindAllString(b, -1)
-	segs := int(math.Min(float64(len(segsa)), float64(len(segsb))))
+	maxSegs := max(len(segsa), len(segsb))
+	minSegs := min(len(segsa), len(segsb))
 
 	// compare each segment
-	for i := 0; i < segs; i++ {
+	for i := 0; i < minSegs; i++ {
 		a := segsa[i]
 		b := segsb[i]
 
@@ -204,11 +204,26 @@ func compareRpmVersions(a, b string) int {
 	}
 
 	// If there is a tilde in a segment past the min number of segments, find it.
-	if len(segsa) > segs && []rune(segsa[segs])[0] == '~' {
+	if len(segsa) > minSegs && []rune(segsa[minSegs])[0] == '~' {
 		return -1
-	} else if len(segsb) > segs && []rune(segsb[segs])[0] == '~' {
+	} else if len(segsb) > minSegs && []rune(segsb[minSegs])[0] == '~' {
 		return 1
 	}
+	// are the remaining segments 0s?
+	segaAll0s := true
+	segbAll0s := true
+	for i := minSegs; i < maxSegs; i++ {
+		if i < len(segsa) && segsa[i] != "0" {
+			segaAll0s = false
+		}
+		if i < len(segsb) && segsb[i] != "0" {
+			segbAll0s = false
+		}
+	}
+
+	if segaAll0s && segbAll0s {
+		return 0
+	}
 
 	// whoever has the most segments wins
 	if len(segsa) > len(segsb) {


Test patch:
diff --git a/cmd/grype/cli/commands/root_test.go b/cmd/grype/cli/commands/root_test.go
index 82ed09b7..bd1d27b8 100644
--- a/cmd/grype/cli/commands/root_test.go
+++ b/cmd/grype/cli/commands/root_test.go
@@ -12,6 +12,7 @@ import (
 	"github.com/anchore/grype/grype/pkg"
 	"github.com/anchore/stereoscope/pkg/image"
 	"github.com/anchore/syft/syft"
+	"github.com/anchore/syft/syft/cataloging"
 	"github.com/anchore/syft/syft/pkg/cataloger/binary"
 )
 
@@ -54,14 +55,18 @@ func Test_getProviderConfig(t *testing.T) {
 		want pkg.ProviderConfig
 	}{
 		{
-			name: "default-options-are-set",
+			name: "syft default api options are used",
 			opts: options.DefaultGrype(clio.Identification{
 				Name:    "test",
 				Version: "1.0",
 			}),
 			want: pkg.ProviderConfig{
 				SyftProviderConfig: pkg.SyftProviderConfig{
-					SBOMOptions: syft.DefaultCreateSBOMConfig(),
+					SBOMOptions: func() *syft.CreateSBOMConfig {
+						cfg := syft.DefaultCreateSBOMConfig()
+						cfg.Compliance.MissingVersion = cataloging.ComplianceActionDrop
+						return cfg
+					}(),
 					RegistryOptions: &image.RegistryOptions{
 						Credentials: []image.RegistryCredentials{},
 					},
diff --git a/grype/matcher/apk/matcher_test.go b/grype/matcher/apk/matcher_test.go
index 4b5ead53..475bd217 100644
--- a/grype/matcher/apk/matcher_test.go
+++ b/grype/matcher/apk/matcher_test.go
@@ -1003,7 +1003,7 @@ func TestNVDMatchBySourceIndirection(t *testing.T) {
 					Type:       match.CPEMatch,
 					Confidence: 0.9,
 					SearchedBy: search.CPEParameters{
-						CPEs:      []string{"cpe:2.3:a:musl:musl:*:*:*:*:*:*:*:*"},
+						CPEs:      []string{"cpe:2.3:a:musl:musl:1.3.2-r0:*:*:*:*:*:*:*"},
 						Namespace: "nvd:cpe",
 						Package: search.CPEPackageParameter{
 							Name:    "musl",
diff --git a/grype/matcher/rpm/matcher_test.go b/grype/matcher/rpm/matcher_test.go
index f15a5daf..ea101c42 100644
--- a/grype/matcher/rpm/matcher_test.go
+++ b/grype/matcher/rpm/matcher_test.go
@@ -360,24 +360,52 @@ func TestMatcherRpm(t *testing.T) {
 	}
 }
 
-func Test_addZeroEpicIfApplicable(t *testing.T) {
+func Test_addEpochIfApplicable(t *testing.T) {
 	tests := []struct {
-		version  string
+		name     string
+		pkg      pkg.Package
 		expected string
 	}{
 		{
-			version:  "3.26.0-6.el8",
+			name: "assume 0 epoch",
+			pkg: pkg.Package{
+				Version: "3.26.0-6.el8",
+			},
 			expected: "0:3.26.0-6.el8",
 		},
 		{
-			version:  "7:3.26.0-6.el8",
+			name: "epoch already exists in version string",
+			pkg: pkg.Package{
+				Version: "7:3.26.0-6.el8",
+			},
+			expected: "7:3.26.0-6.el8",
+		},
+		{
+			name: "epoch only exists in metadata",
+			pkg: pkg.Package{
+				Version: "3.26.0-6.el8",
+				Metadata: pkg.RpmMetadata{
+					Epoch: intRef(7),
+				},
+			},
 			expected: "7:3.26.0-6.el8",
 		},
+		{
+			name: "epoch does not exist in metadata",
+			pkg: pkg.Package{
+				Version: "3.26.0-6.el8",
+				Metadata: pkg.RpmMetadata{
+					Epoch: nil,
+				},
+			},
+			expected: "0:3.26.0-6.el8",
+		},
 	}
 	for _, test := range tests {
-		t.Run(test.version, func(t *testing.T) {
-			actualVersion := addZeroEpicIfApplicable(test.version)
-			assert.Equal(t, test.expected, actualVersion)
+		t.Run(test.name, func(t *testing.T) {
+			p := test.pkg
+			addEpochIfApplicable(&p)
+			assert.Equal(t, test.expected, p.Version)
 		})
 	}
 }
diff --git a/grype/search/cpe_test.go b/grype/search/cpe_test.go
index 65d5bea2..676b5bad 100644
--- a/grype/search/cpe_test.go
+++ b/grype/search/cpe_test.go
@@ -215,6 +215,71 @@ func TestFindMatchesByPackageCPE(t *testing.T) {
 				},
 			},
 		},
+		{
+			name: "fallback to package version",
+			p: pkg.Package{
+				CPEs: []cpe.CPE{
+					cpe.Must("cpe:2.3:*:activerecord:activerecord:unknown:rando1:*:ra:*:ruby:*:*", ""),
+					cpe.Must("cpe:2.3:*:activerecord:activerecord:unknown:rando4:*:re:*:rails:*:*", ""),
+				},
+				Name:     "activerecord",
+				Version:  "3.7.5",
+				Language: syftPkg.Ruby,
+				Type:     syftPkg.GemPkg,
+			},
+			expected: []match.Match{
+				{
+
+					Vulnerability: vulnerability.Vulnerability{
+						ID: "CVE-2017-fake-1",
+					},
+					Package: pkg.Package{
+						CPEs: []cpe.CPE{
+							cpe.Must("cpe:2.3:*:activerecord:activerecord:unknown:rando1:*:ra:*:ruby:*:*", ""),
+							cpe.Must("cpe:2.3:*:activerecord:activerecord:unknown:rando4:*:re:*:rails:*:*", ""),
+						},
+						Name:     "activerecord",
+						Version:  "3.7.5",
+						Language: syftPkg.Ruby,
+						Type:     syftPkg.GemPkg,
+					},
+					Details: []match.Detail{
+						{
+							Type:       match.CPEMatch,
+							Confidence: 0.9,
+							SearchedBy: CPEParameters{
+								Namespace: "nvd:cpe",
+								CPEs:      []string{"cpe:2.3:*:activerecord:activerecord:3.7.5:rando4:*:re:*:rails:*:*"},
+								Package: CPEPackageParameter{
+									Name:    "activerecord",
+									Version: "3.7.5",
+								},
+							},
+							Found: CPEResult{
+								CPEs:              []string{"cpe:2.3:*:activerecord:activerecord:*:*:*:*:*:rails:*:*"},
+								VersionConstraint: "< 3.7.6 (semver)",
+								VulnerabilityID:   "CVE-2017-fake-1",
+							},
+							Matcher: matcher,
+						},
+					},
+				},
+			},
+		},
+		{
+			name: "suppress matching when missing version",
+			p: pkg.Package{
+				CPEs: []cpe.CPE{
+					cpe.Must("cpe:2.3:*:activerecord:activerecord:unknown:rando1:*:ra:*:ruby:*:*", ""),
+					cpe.Must("cpe:2.3:*:activerecord:activerecord:unknown:rando4:*:re:*:rails:*:*", ""),
+				},
+				Name:     "activerecord",
+				Version:  "",
+				Language: syftPkg.Ruby,
+				Type:     syftPkg.GemPkg,
+			},
+			expected: []match.Match{},
+		},
 		{
 			name: "multiple matches",
 			p: pkg.Package{
@@ -511,7 +576,7 @@ func TestFindMatchesByPackageCPE(t *testing.T) {
 							Type:       match.CPEMatch,
 							Confidence: 0.9,
 							SearchedBy: CPEParameters{
-								CPEs:      []string{"cpe:2.3:*:sw:sw:*:*:*:*:*:*:*:*"},
+								CPEs:      []string{"cpe:2.3:*:sw:sw:0.1:*:*:*:*:*:*:*"},
 								Namespace: "nvd:cpe",
 								Package: CPEPackageParameter{
 									Name:    "sw",
@@ -567,7 +632,7 @@ func TestFindMatchesByPackageCPE(t *testing.T) {
 							Type:       match.CPEMatch,
 							Confidence: 0.9,
 							SearchedBy: CPEParameters{
-								CPEs:      []string{"cpe:2.3:*:funfun:funfun:*:*:*:*:*:python:*:*"},
+								CPEs:      []string{"cpe:2.3:*:funfun:funfun:5.2.1:*:*:*:*:python:*:*"},
 								Namespace: "nvd:cpe",
 								Package: CPEPackageParameter{
 									Name:    "funfun",
@@ -618,7 +683,7 @@ func TestFindMatchesByPackageCPE(t *testing.T) {
 							Type:       match.CPEMatch,
 							Confidence: 0.9,
 							SearchedBy: CPEParameters{
-								CPEs:      []string{"cpe:2.3:a:handlebarsjs:handlebars:*:*:*:*:*:*:*:*"},
+								CPEs:      []string{"cpe:2.3:a:handlebarsjs:handlebars:0.1:*:*:*:*:*:*:*"},
 								Namespace: "nvd:cpe",
 								Package: CPEPackageParameter{
 									Name:    "handlebars",
@@ -668,7 +733,7 @@ func TestFindMatchesByPackageCPE(t *testing.T) {
 							Type:       match.CPEMatch,
 							Confidence: 0.9,
 							SearchedBy: CPEParameters{
-								CPEs:      []string{"cpe:2.3:a:handlebarsjs:handlebars:*:*:*:*:*:*:*:*"},
+								CPEs:      []string{"cpe:2.3:a:handlebarsjs:handlebars:0.1:*:*:*:*:*:*:*"},
 								Namespace: "nvd:cpe",
 								Package: CPEPackageParameter{
 									Name:    "handlebars",
@@ -718,7 +783,7 @@ func TestFindMatchesByPackageCPE(t *testing.T) {
 							Type:       match.CPEMatch,
 							Confidence: 0.9,
 							SearchedBy: CPEParameters{
-								CPEs:      []string{"cpe:2.3:a:handlebarsjs:handlebars:*:*:*:*:*:*:*:*"},
+								CPEs:      []string{"cpe:2.3:a:handlebarsjs:handlebars:0.1:*:*:*:*:*:*:*"},
 								Namespace: "nvd:cpe",
 								Package: CPEPackageParameter{
 									Name:    "handlebars",
@@ -781,7 +846,7 @@ func TestFindMatchesByPackageCPE(t *testing.T) {
 							Type:       match.CPEMatch,
 							Confidence: 0.9,
 							SearchedBy: CPEParameters{
-								CPEs:      []string{"cpe:2.3:a:handlebarsjs:handlebars:*:*:*:*:*:*:*:*"},
+								CPEs:      []string{"cpe:2.3:a:handlebarsjs:handlebars:0.1:*:*:*:*:*:*:*"},
 								Namespace: "nvd:cpe",
 								Package: CPEPackageParameter{
 									Name:    "handlebars",
diff --git a/grype/search/distro_test.go b/grype/search/distro_test.go
index fecc7f78..b90f0e61 100644
--- a/grype/search/distro_test.go
+++ b/grype/search/distro_test.go
@@ -6,6 +6,7 @@ import (
 
 	"github.com/google/uuid"
 	"github.com/stretchr/testify/assert"
+	"github.com/stretchr/testify/require"
 
 	"github.com/anchore/grype/grype/distro"
 	"github.com/anchore/grype/grype/match"
@@ -106,8 +107,15 @@ func TestFindMatchesByPackageDistro(t *testing.T) {
 
 	store := newMockProviderByDistro()
 	actual, err := ByPackageDistro(store, d, p, match.PythonMatcher)
-	assert.NoError(t, err)
+	require.NoError(t, err)
 	assertMatchesUsingIDsForVulnerabilities(t, expected, actual)
+
+	// prove we do not search for unknown versions
+	p.Version = "unknown"
+	actual, err = ByPackageDistro(store, d, p, match.PythonMatcher)
+	require.NoError(t, err)
+	assert.Empty(t, actual)
+
 }
 
 func TestFindMatchesByPackageDistroSles(t *testing.T) {
diff --git a/grype/search/language_test.go b/grype/search/language_test.go
index 9c610a73..5dfae0f8 100644
--- a/grype/search/language_test.go
+++ b/grype/search/language_test.go
@@ -6,6 +6,7 @@ import (
 
 	"github.com/google/uuid"
 	"github.com/stretchr/testify/assert"
+	"github.com/stretchr/testify/require"
 
 	"github.com/anchore/grype/grype/match"
 	"github.com/anchore/grype/grype/pkg"
@@ -94,8 +95,9 @@ func expectedMatch(p pkg.Package, constraint string) []match.Match {
 
 func TestFindMatchesByPackageLanguage(t *testing.T) {
 	cases := []struct {
-		p          pkg.Package
-		constraint string
+		p           pkg.Package
+		constraint  string
+		assertEmpty bool
 	}{
 		{
 			constraint: "< 3.7.6 (semver)",
@@ -117,13 +119,27 @@ func TestFindMatchesByPackageLanguage(t *testing.T) {
 				Type:     syftPkg.GemPkg,
 			},
 		},
+		{
+			p: pkg.Package{
+				ID:       pkg.ID(uuid.NewString()),
+				Name:     "nokogiri",
+				Version:  "unknown",
+				Language: syftPkg.Ruby,
+				Type:     syftPkg.GemPkg,
+			},
+			assertEmpty: true,
+		},
 	}
 
 	store := newMockProviderByLanguage()
 	for _, c := range cases {
 		t.Run(c.p.Name, func(t *testing.T) {
 			actual, err := ByPackageLanguage(store, nil, c.p, match.RubyGemMatcher)
-			assert.NoError(t, err)
+			require.NoError(t, err)
+			if c.assertEmpty {
+				assert.Empty(t, actual)
+				return
+			}
 			assertMatchesUsingIDsForVulnerabilities(t, expectedMatch(c.p, c.constraint), actual)
 		})
 	}
diff --git a/grype/version/rpm_constraint_test.go b/grype/version/rpm_constraint_test.go
index 890fac21..77139361 100644
--- a/grype/version/rpm_constraint_test.go
+++ b/grype/version/rpm_constraint_test.go
@@ -60,6 +60,13 @@ func TestVersionRpmConstraint(t *testing.T) {
 		{version: "4.19.0-1.el7_5", constraint: "<= 12:4.19.0-1.el7", satisfied: false},
 		{version: "3:4.19.0-1.el7_5", constraint: "< 4.21.0-1.el7", satisfied: true},
 		{version: "4:1.2.3-3-el7_5", constraint: "< 1.2.3-el7_5~snapshot1", satisfied: false},
+		// regression https://github.com/anchore/grype/issues/398
+		{version: "8.3.1-5.el8.4", constraint: "< 0:8.3.1-5.el8.5", satisfied: true},
+		{version: "8.3.1-5.el8.40", constraint: "< 0:8.3.1-5.el8.5", satisfied: false},
+		{version: "8.3.1-5.el8", constraint: "< 0:8.3.1-5.el8.0.0", satisfied: false},
+		{version: "8.3.1-5.el8", constraint: "<= 0:8.3.1-5.el8.0.0", satisfied: true},
+		{version: "8.3.1-5.el8.0.0", constraint: "> 0:8.3.1-5.el8", satisfied: false},
+		{version: "8.3.1-5.el8.0.0", constraint: ">= 0:8.3.1-5.el8", satisfied: true},
 	}
 
 	for _, test := range tests {
diff --git a/grype/vulnerability_matcher_test.go b/grype/vulnerability_matcher_test.go
index 71be9e18..69dd4133 100644
--- a/grype/vulnerability_matcher_test.go
+++ b/grype/vulnerability_matcher_test.go
@@ -592,7 +592,7 @@ func TestVulnerabilityMatcher_FindMatches(t *testing.T) {
 							SearchedBy: search.CPEParameters{
 								Namespace: "nvd:cpe",
 								CPEs: []string{
-									"cpe:2.3:*:activerecord:activerecord:*:*:*:*:*:rails:*:*",
+									"cpe:2.3:*:activerecord:activerecord:3.7.5:*:*:*:*:rails:*:*",
 								},
 								Package: search.CPEPackageParameter{
 									Name:    "activerecord",
@@ -689,26 +689,12 @@ func TestVulnerabilityMatcher_FindMatches(t *testing.T) {
 					},
 					Package: activerecordPkg,
 					Details: match.Details{
-						{
-							Type: match.ExactDirectMatch,
-							SearchedBy: map[string]any{
-								"language":  "ruby",
-								"namespace": "github:language:ruby",
-								"package":   map[string]string{"name": "activerecord", "version": "3.7.5"},
-							},
-							Found: map[string]any{
-								"versionConstraint": "< 3.7.6 (unknown)",
-								"vulnerabilityID":   "GHSA-2014-fake-3",
-							},
-							Matcher:    "ruby-gem-matcher",
-							Confidence: 1,
-						},
 						{
 							Type: match.CPEMatch,
 							SearchedBy: search.CPEParameters{
 								Namespace: "nvd:cpe",
 								CPEs: []string{
-									"cpe:2.3:*:activerecord:activerecord:*:*:*:*:*:rails:*:*",
+									"cpe:2.3:*:activerecord:activerecord:3.7.5:*:*:*:*:rails:*:*",
 								},
 								Package: search.CPEPackageParameter{
 									Name:    "activerecord",
@@ -725,6 +711,20 @@ func TestVulnerabilityMatcher_FindMatches(t *testing.T) {
 							Matcher:    "ruby-gem-matcher",
 							Confidence: 0.9,
 						},
+						{
+							Type: match.ExactDirectMatch,
+							SearchedBy: map[string]any{
+								"language":  "ruby",
+								"namespace": "github:language:ruby",
+								"package":   map[string]string{"name": "activerecord", "version": "3.7.5"},
+							},
+							Found: map[string]any{
+								"versionConstraint": "< 3.7.6 (unknown)",
+								"vulnerabilityID":   "GHSA-2014-fake-3",
+							},
+							Matcher:    "ruby-gem-matcher",
+							Confidence: 1,
+						},
 					},
 				},
 			),
@@ -775,7 +775,7 @@ func TestVulnerabilityMatcher_FindMatches(t *testing.T) {
 							SearchedBy: search.CPEParameters{
 								Namespace: "nvd:cpe",
 								CPEs: []string{
-									"cpe:2.3:*:activerecord:activerecord:*:*:*:*:*:rails:*:*",
+									"cpe:2.3:*:activerecord:activerecord:3.7.5:*:*:*:*:rails:*:*",
 								},
 								Package: search.CPEPackageParameter{
 									Name:    "activerecord",
@@ -887,7 +887,7 @@ func TestVulnerabilityMatcher_FindMatches(t *testing.T) {
 								SearchedBy: search.CPEParameters{
 									Namespace: "nvd:cpe",
 									CPEs: []string{
-										"cpe:2.3:*:activerecord:activerecord:*:*:*:*:*:rails:*:*",
+										"cpe:2.3:*:activerecord:activerecord:3.7.5:*:*:*:*:rails:*:*",
 									},
 									Package: search.CPEPackageParameter{
 										Name:    "activerecord",
@@ -1038,7 +1038,7 @@ func TestVulnerabilityMatcher_FindMatches(t *testing.T) {
 								SearchedBy: search.CPEParameters{
 									Namespace: "nvd:cpe",
 									CPEs: []string{
-										"cpe:2.3:*:activerecord:activerecord:*:*:*:*:*:rails:*:*",
+										"cpe:2.3:*:activerecord:activerecord:3.7.5:*:*:*:*:rails:*:*",
 									},
 									Package: search.CPEPackageParameter{
 										Name:    "activerecord",
diff --git a/test/quality/.yardstick.yaml b/test/quality/.yardstick.yaml
index 2a827f89..41291ddf 100644
--- a/test/quality/.yardstick.yaml
+++ b/test/quality/.yardstick.yaml
@@ -103,7 +103,7 @@ result-sets:
 
         - name: syft
           # note: we want to use a fixed version of syft for capturing all results (NOT "latest")
-          version: v0.98.0
+          version: v1.14.0
           produces: SBOM
           refresh: false
 
@@ -144,7 +144,7 @@ result-sets:
       tools:
         - name: syft
           # note: we want to use a fixed version of syft for capturing all results (NOT "latest")
-          version: v0.98.0
+          version: v1.14.0
           produces: SBOM
           refresh: false