Skip to content

Commit a31de84

Browse files
committed
fix dpkg epoch zero-vs-auto bug
Signed-off-by: Alex Goodman <wagoodman@users.noreply.github.com>
1 parent 72af31b commit a31de84

8 files changed

Lines changed: 181 additions & 10 deletions

File tree

grype/matcher/dpkg/matcher_ubuntu_esm_test.go

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -316,10 +316,6 @@ func TestUbuntuESM_MultipleCVEsPerPackage(t *testing.T) {
316316
// always-vulnerable "None" row, so the constraint check never resolves it (the RHEL EUS path has the same gap). This
317317
// test intentionally asserts the correct behavior so it stays red until fix-version comparison honors the strategy.
318318
func TestUbuntuESM_MissingEpochStrategy(t *testing.T) {
319-
t.Skip("known gap: ESM/EUS fix-version comparison ignores MissingEpochStrategy (neededFixes -> version.Is and " +
320-
"search.ByFixedVersion use plain Compare). Assertions below encode the correct behavior; remove this skip once " +
321-
"fix-version comparison honors the strategy.")
322-
323319
const (
324320
cve = "CVE-2025-61985"
325321
// installed at the same build as the fix "1:8.2p1-4ubuntu0.13+esm1" but with no epoch prefix
@@ -344,7 +340,10 @@ func TestUbuntuESM_MissingEpochStrategy(t *testing.T) {
344340

345341
findings := db.Match(t, matcher, p)
346342
if tt.expectResolve {
343+
// installed is the fixed build: no match, and a "Distro Not Vulnerable" ignore is emitted instead
347344
findings.SkipCompleteness().DoesNotHaveAnyVulnerabilities(cve)
345+
findings.Ignores().
346+
SelectRelatedPackageIgnore(IgnoreReasonDistroNotVulnerable, cve)
348347
} else {
349348
findings.SkipCompleteness().SelectMatch(cve)
350349
}

grype/matcher/rpm/rhel_eus_test.go

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1053,6 +1053,41 @@ func TestRedhatEUSMatches_LowerReachableFixResolvesDespiteHigherFix(t *testing.T
10531053
})
10541054
}
10551055

1056+
// TestRedhatEUSMatches_MissingEpochStrategy documents that the rpm EUS binary path deliberately does NOT behave like
1057+
// the dpkg ESM path for a missing epoch - so RHEL EUS does not share the deb epoch bug (see the dpkg twin
1058+
// TestUbuntuESM_MissingEpochStrategy).
1059+
//
1060+
// nginx on 9.4+eus is fixed at "1:1.20.1-14.el9_4" (non-zero epoch); the installed build is the same but omits the
1061+
// epoch. Unlike dpkg, the rpm binary matcher fills a missing epoch with an explicit 0 (matchPackage ->
1062+
// addEpochIfApplicable), because per RedHat a binary rpm with no epoch genuinely IS epoch 0. That makes the install
1063+
// "0:1.20.1-14.el9_4", which is a real, older version lineage than the epoch-1 fix, so the package is correctly
1064+
// reported vulnerable under BOTH strategies - auto never fires because the epoch is explicit (not missing) by the
1065+
// time comparison happens. (auto only matters on the rpm source/upstream path, which drops epochs on purpose.)
1066+
func TestRedhatEUSMatches_MissingEpochStrategy(t *testing.T) {
1067+
const (
1068+
cve = "CVE-2024-7347"
1069+
// same build as the fix "1:1.20.1-14.el9_4" but with no epoch prefix; the binary matcher normalizes this to 0:
1070+
installedNoEpoch = "1.20.1-14.el9_4"
1071+
)
1072+
1073+
// both strategies report vulnerable: the missing epoch is normalized to an explicit 0, so 0:... < 1:... regardless
1074+
for _, strategy := range []version.MissingEpochStrategy{version.MissingEpochStrategyZero, version.MissingEpochStrategyAuto} {
1075+
t.Run(string(strategy), func(t *testing.T) {
1076+
dbtest.DBs(t, "rhel9-eus").
1077+
SelectOnly(cve).
1078+
Run(func(t *testing.T, db *dbtest.DB) {
1079+
matcher := NewRpmMatcher(MatcherConfig{MissingEpochStrategy: strategy})
1080+
p := dbtest.NewPackage("nginx", installedNoEpoch, syftPkg.RpmPkg).
1081+
WithDistro(newEUSDistro("9.4")).
1082+
Build()
1083+
1084+
findings := db.Match(t, matcher, p)
1085+
findings.SkipCompleteness().SelectMatch(cve)
1086+
})
1087+
})
1088+
}
1089+
}
1090+
10561091
// newEUSDistro creates a properly initialized RHEL EUS distro using distro.New().
10571092
// This ensures MajorVersion()/MinorVersion() work correctly.
10581093
// Pass version like "9.4" (the "+eus" channel suffix is added automatically).

grype/matcher/rpm/testdata/rhel9-eus/db.yaml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,3 +16,5 @@ extractions:
1616
- rhel:8/cve-2016-9840
1717
- rhel:8/cve-2020-7788
1818
- rhel:8.4+eus/cve-2020-7788
19+
- rhel:9/cve-2024-7347
20+
- rhel:9.4+eus/cve-2024-7347
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
{
2+
"identifier": "rhel:9.4+eus/cve-2024-7347",
3+
"item": {
4+
"Vulnerability": {
5+
"Description": "nginx: synthetic EUS fixture record fixed on rhel:9.4+eus at a non-zero epoch (1:). Paired with the rhel:9 \"None\" base record to exercise MissingEpochStrategy on the EUS fix-comparison path.",
6+
"FixedIn": [
7+
{
8+
"Available": {
9+
"date": "2026-04-22",
10+
"kind": "first-observed"
11+
},
12+
"Module": null,
13+
"Name": "nginx",
14+
"NamespaceName": "rhel:9.4+eus",
15+
"VendorAdvisory": {
16+
"AdvisorySummary": [
17+
{
18+
"ID": "RHSA-2026:0001",
19+
"Link": "https://access.redhat.com/errata/RHSA-2026:0001"
20+
}
21+
],
22+
"NoAdvisory": false
23+
},
24+
"Version": "1:1.20.1-14.el9_4",
25+
"VersionFormat": "rpm"
26+
}
27+
],
28+
"Link": "https://access.redhat.com/security/cve/CVE-2024-7347",
29+
"Metadata": {},
30+
"Name": "CVE-2024-7347",
31+
"NamespaceName": "rhel:9.4+eus",
32+
"Severity": "Medium"
33+
}
34+
},
35+
"schema": "https://raw.githubusercontent.com/anchore/vunnel/main/schema/vulnerability/os/schema-1.1.0.json"
36+
}
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
{
2+
"identifier": "rhel:9/cve-2024-7347",
3+
"item": {
4+
"Vulnerability": {
5+
"Description": "nginx: synthetic EUS fixture record for a package that carries a non-zero rpm epoch. The base rhel:9 pocket has no fix (Version \"None\"); the fix is only published on 9.4+eus. Used to exercise MissingEpochStrategy on the EUS fix-comparison path (auto vs zero) when the installed version omits the epoch the fix carries.",
6+
"FixedIn": [
7+
{
8+
"Module": null,
9+
"Name": "nginx",
10+
"NamespaceName": "rhel:9",
11+
"VendorAdvisory": {
12+
"AdvisorySummary": [],
13+
"NoAdvisory": false
14+
},
15+
"Version": "None",
16+
"VersionFormat": "rpm"
17+
}
18+
],
19+
"Link": "https://access.redhat.com/security/cve/CVE-2024-7347",
20+
"Metadata": {},
21+
"Name": "CVE-2024-7347",
22+
"NamespaceName": "rhel:9",
23+
"Severity": "Medium"
24+
}
25+
},
26+
"schema": "https://raw.githubusercontent.com/anchore/vunnel/main/schema/vulnerability/os/schema-1.1.0.json"
27+
}

grype/search/version_constraint.go

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -69,12 +69,14 @@ func ByFixedVersion(v version.Version) vulnerability.Criteria {
6969
return false, "", nil
7070
}
7171
for _, fixVersion := range vuln.Fix.Versions {
72-
cmp, e := version.New(fixVersion, v.Format).Compare(&v)
72+
// compare with v (the package) as the receiver so its embedded ComparisonConfig (e.g. missing-epoch
73+
// strategy) is honored; cmp >= 0 means the installed version is at or past the fix, so it is fixed.
74+
cmp, e := v.Compare(version.New(fixVersion, v.Format))
7375
if e != nil {
7476
err = e
7577
}
76-
if cmp <= 0 {
77-
// fix version is less than or equal to the provided version, so is considered fixed
78+
if cmp >= 0 {
79+
// installed version is greater than or equal to the fix version, so is considered fixed
7880
return true, fmt.Sprintf("fix version %v is less than %v", v, fixVersion), err
7981
}
8082
}

grype/version/version.go

Lines changed: 18 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -90,6 +90,21 @@ func (v Version) String() string {
9090
return fmt.Sprintf("%s (%s)", v.Raw, v.Format)
9191
}
9292

93+
// compareUsing runs the comparison honoring v's embedded ComparisonConfig for formats that support it
94+
// (currently rpm/deb missing-epoch handling), falling back to a plain comparison otherwise. v is always the
95+
// package side, other the constraint/fix side, matching CompareWithConfig's asymmetric auto-epoch semantics.
96+
func (v Version) compareUsing(comparator Comparator, other *Version) (int, error) {
97+
if v.Config.MissingEpochStrategy != "" {
98+
switch c := comparator.(type) {
99+
case rpmVersion:
100+
return c.CompareWithConfig(other, v.Config)
101+
case debVersion:
102+
return c.CompareWithConfig(other, v.Config)
103+
}
104+
}
105+
return comparator.Compare(other)
106+
}
107+
93108
// Compare compares this version to another version.
94109
// This returns -1, 0, or 1 if this version is smaller,
95110
// equal, or larger than the other version, respectively.
@@ -102,7 +117,7 @@ func (v Version) Compare(other *Version) (int, error) {
102117
comparator, err := v.getComparator(v.Format)
103118
if err == nil {
104119
// if the package version, v was able to compare without error, return the result
105-
result, err = comparator.Compare(other)
120+
result, err = v.compareUsing(comparator, other)
106121
if err == nil {
107122
// no error returned for package version or db version, return the result
108123
return result, nil
@@ -113,7 +128,7 @@ func (v Version) Compare(other *Version) (int, error) {
113128
originalErr := err
114129
comparator, err = v.getComparator(other.Format)
115130
if err == nil {
116-
result, err = comparator.Compare(other)
131+
result, err = v.compareUsing(comparator, other)
117132
if err == nil {
118133
return result, nil
119134
}
@@ -138,7 +153,7 @@ func (v *Version) Is(op Operator, other *Version) (bool, error) {
138153
return false, fmt.Errorf("unable to get comparator for %s: %w", v.Format, err)
139154
}
140155

141-
result, err := comparator.Compare(other)
156+
result, err := v.compareUsing(comparator, other)
142157
if err != nil {
143158
return false, fmt.Errorf("unable to compare versions %s and %s: %w", v, other, err)
144159
}

grype/version/version_test.go

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -361,3 +361,58 @@ func TestVersion_Is_AllOperators(t *testing.T) {
361361
})
362362
}
363363
}
364+
365+
// TestVersion_Compare_HonorsEmbeddedConfig verifies that Version.Compare and Version.Is route through the
366+
// config-aware comparison when the receiver carries a MissingEpochStrategy (deb/rpm), rather than silently
367+
// ignoring it. The classic case: an installed version missing the epoch that the constraint/fix carries.
368+
func TestVersion_Compare_HonorsEmbeddedConfig(t *testing.T) {
369+
tests := []struct {
370+
name string
371+
format Format
372+
pkg string // receiver (package), missing epoch
373+
other string // fix/constraint, carries epoch
374+
strategy MissingEpochStrategy
375+
wantCmp int
376+
}{
377+
{
378+
name: "deb auto adopts constraint epoch - equal builds compare equal",
379+
format: DebFormat,
380+
pkg: "8.2p1-4ubuntu0.13+esm1",
381+
other: "1:8.2p1-4ubuntu0.13+esm1",
382+
strategy: MissingEpochStrategyAuto,
383+
wantCmp: 0,
384+
},
385+
{
386+
name: "deb zero treats missing epoch as 0 - lower than epoch 1",
387+
format: DebFormat,
388+
pkg: "8.2p1-4ubuntu0.13+esm1",
389+
other: "1:8.2p1-4ubuntu0.13+esm1",
390+
strategy: MissingEpochStrategyZero,
391+
wantCmp: -1,
392+
},
393+
{
394+
name: "rpm auto adopts constraint epoch - equal builds compare equal",
395+
format: RpmFormat,
396+
pkg: "5.14.0-427.68.1.el9_4",
397+
other: "1:5.14.0-427.68.1.el9_4",
398+
strategy: MissingEpochStrategyAuto,
399+
wantCmp: 0,
400+
},
401+
}
402+
403+
for _, tt := range tests {
404+
t.Run(tt.name, func(t *testing.T) {
405+
pkgV := NewWithConfig(tt.pkg, tt.format, ComparisonConfig{MissingEpochStrategy: tt.strategy})
406+
other := New(tt.other, tt.format)
407+
408+
cmp, err := pkgV.Compare(other)
409+
require.NoError(t, err)
410+
assert.Equal(t, tt.wantCmp, cmp, "Compare")
411+
412+
// Is must agree with Compare (spot-check the equality operator)
413+
isEq, err := pkgV.Is(EQ, other)
414+
require.NoError(t, err)
415+
assert.Equal(t, tt.wantCmp == 0, isEq, "Is(EQ)")
416+
})
417+
}
418+
}

0 commit comments

Comments
 (0)