Skip to content

Commit 9bb3832

Browse files
committed
add tests
Signed-off-by: Alex Goodman <wagoodman@users.noreply.github.com>
1 parent 023e63a commit 9bb3832

8 files changed

Lines changed: 852 additions & 25 deletions

File tree

grype/distro/distro_test.go

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -315,6 +315,29 @@ func Test_NewDistroFromRelease(t *testing.T) {
315315
major: "16",
316316
minor: "04",
317317
},
318+
{
319+
// force-on (GRYPE_FIX_CHANNEL_UBUNTU_ESM_APPLY=always): esm applies even for a base ubuntu with no
320+
// extended-support hint and no +esm suffix.
321+
name: "esm applied for non-pro ubuntu (always apply)",
322+
release: linux.Release{
323+
ID: "ubuntu",
324+
Version: "16.04",
325+
},
326+
channels: []FixChannel{
327+
{
328+
Name: "esm",
329+
IDs: []string{"ubuntu"},
330+
Apply: ChannelAlwaysEnabled, // important!
331+
},
332+
},
333+
expected: &Distro{
334+
Type: Ubuntu,
335+
Version: "16.04",
336+
Channels: names("esm"),
337+
},
338+
major: "16",
339+
minor: "04",
340+
},
318341
{
319342
name: "v versionID prefix postmarketos",
320343
release: linux.Release{

grype/matcher/dpkg/matcher_ubuntu_esm_test.go

Lines changed: 171 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,8 @@ import (
55

66
"github.com/anchore/grype/grype/distro"
77
"github.com/anchore/grype/grype/match"
8+
"github.com/anchore/grype/grype/pkg"
9+
"github.com/anchore/grype/grype/version"
810
"github.com/anchore/grype/grype/vulnerability"
911
"github.com/anchore/grype/internal/dbtest"
1012
syftPkg "github.com/anchore/syft/syft/pkg"
@@ -15,6 +17,55 @@ func newESMDistro(version string) *distro.Distro {
1517
return distro.New(distro.Ubuntu, version+"+esm", "")
1618
}
1719

20+
func TestShouldUseUbuntuESMMatching(t *testing.T) {
21+
withChannels := func(d *distro.Distro, channels ...string) *distro.Distro {
22+
d.Channels = channels
23+
return d
24+
}
25+
26+
tests := []struct {
27+
name string
28+
d *distro.Distro
29+
want bool
30+
}{
31+
{name: "nil distro", d: nil, want: false},
32+
{name: "ubuntu with esm channel", d: newESMDistro("16.04"), want: true},
33+
{name: "ubuntu without channels", d: distro.New(distro.Ubuntu, "16.04", ""), want: false},
34+
{
35+
// channel matching is case-insensitive (channels can arrive in any case)
36+
name: "ubuntu with mixed-case ESM channel",
37+
d: withChannels(distro.New(distro.Ubuntu, "16.04", ""), "ESM"),
38+
want: true,
39+
},
40+
{
41+
// a non-esm channel on Ubuntu must not trigger ESM matching
42+
name: "ubuntu with non-esm channel",
43+
d: withChannels(distro.New(distro.Ubuntu, "16.04", ""), "fips"),
44+
want: false,
45+
},
46+
{
47+
// esm should be detected even alongside other channels
48+
name: "ubuntu with esm among multiple channels",
49+
d: withChannels(distro.New(distro.Ubuntu, "16.04", ""), "fips", "esm"),
50+
want: true,
51+
},
52+
{
53+
// an esm channel only makes sense on Ubuntu; guard against it leaking onto other distros
54+
name: "non-ubuntu with esm channel",
55+
d: withChannels(distro.New(distro.Debian, "12", ""), "esm"),
56+
want: false,
57+
},
58+
}
59+
60+
for _, tt := range tests {
61+
t.Run(tt.name, func(t *testing.T) {
62+
if got := shouldUseUbuntuESMMatching(tt.d); got != tt.want {
63+
t.Errorf("shouldUseUbuntuESMMatching() = %v, want %v", got, tt.want)
64+
}
65+
})
66+
}
67+
}
68+
1869
// real values pulled from the ubuntu-esm fixture (see file header for source URLs).
1970
const (
2071
pkgWayland = "wayland"
@@ -108,6 +159,31 @@ func TestUbuntuESM_VulnerableCases(t *testing.T) {
108159
expectState: vulnerability.FixStateFixed,
109160
expectFixes: []string{"1.18.0-1ubuntu0.1"},
110161
},
162+
{
163+
// channel ON but ESM has published no fix (base pocket "None", no +esm record) -> still vulnerable via the
164+
// ESM path, reported not-fixed. This exercises the ESM merge producing a wont-fix/not-fixed outcome (the
165+
// common "Pro subscription active but package still exposed" case), distinct from the channel-off path.
166+
name: "channel on with no esm fix stays vulnerable",
167+
pkgName: "curl",
168+
pkgVersion: "7.47.0-1ubuntu2",
169+
d: newESMDistro("16.04"),
170+
expectCVE: "CVE-2016-9586",
171+
expectType: match.ExactDirectMatch,
172+
expectState: vulnerability.FixStateNotFixed,
173+
},
174+
{
175+
// installed version is MISSING the epoch that the ESM fix carries ("1:"). Under the default (zero-epoch)
176+
// strategy the missing epoch is treated as 0, so 0:...0.12 < 1:...0.13+esm1 -> vulnerable with the esm fix
177+
// surfaced. Guards the dpkg epoch-normalization path that the matcher threads MissingEpochStrategy for.
178+
name: "esm fix surfaced when installed version omits the epoch",
179+
pkgName: "openssh",
180+
pkgVersion: "8.2p1-4ubuntu0.12", // note: no "1:" epoch prefix
181+
d: newESMDistro("20.04"),
182+
expectCVE: "CVE-2025-61985",
183+
expectType: match.ExactDirectMatch,
184+
expectState: vulnerability.FixStateFixed,
185+
expectFixes: []string{"1:8.2p1-4ubuntu0.13+esm1"},
186+
},
111187
}
112188

113189
dbtest.DBs(t, "ubuntu-esm").Run(func(t *testing.T, db *dbtest.DB) {
@@ -141,22 +217,35 @@ func TestUbuntuESM_VulnerableCases(t *testing.T) {
141217
// "Distro Not Vulnerable" ignore.
142218
func TestUbuntuESM_FixedCases(t *testing.T) {
143219
tests := []struct {
144-
name string
145-
pkgName string
146-
pkgVersion string
147-
d *distro.Distro
148-
expectCVE string
149-
expectEmpty bool // base fix removes the disclosure entirely: no match AND no ignore (mirrors the EUS path)
220+
name string
221+
pkgName string
222+
pkgVersion string
223+
upstreamName string
224+
upstreamVer string
225+
d *distro.Distro
226+
expectCVE string
227+
expectEmpty bool // base fix removes the disclosure entirely: no match AND no ignore (mirrors the EUS path)
150228
}{
151229
{
152230
// installed exactly at the esm fix -> resolved: the base won't-fix disclosure is removed by the esm
153-
// resolution, producing a "Distro Not Vulnerable" ignore.
231+
// resolution, producing a "Distro Not Vulnerable" ignore keyed to the scanned package.
154232
name: "at esm fix is resolved with an ignore",
155233
pkgName: pkgWayland,
156234
pkgVersion: esmFixWaylandXenial,
157235
d: newESMDistro("16.04"),
158236
expectCVE: cveWayland,
159237
},
238+
{
239+
// same resolution reached through source indirection: the binary is at the esm fix via its upstream
240+
// source, so the disclosure resolves and the ignore is keyed to the scanned binary package.
241+
name: "at esm fix via source indirection is resolved with an ignore",
242+
pkgName: "libwayland-client0",
243+
pkgVersion: esmFixWaylandXenial,
244+
upstreamName: pkgWayland,
245+
upstreamVer: esmFixWaylandXenial,
246+
d: newESMDistro("16.04"),
247+
expectCVE: cveWayland,
248+
},
160249
{
161250
// installed at the base standard-pocket fix (channel on) -> the base disclosure itself reports not
162251
// vulnerable, so the two-pass search returns nothing at all (no leakage from esm).
@@ -173,16 +262,89 @@ func TestUbuntuESM_FixedCases(t *testing.T) {
173262

174263
for _, tt := range tests {
175264
t.Run(tt.name, func(t *testing.T) {
176-
p := dbtest.NewPackage(tt.pkgName, tt.pkgVersion, syftPkg.DebPkg).WithDistro(tt.d).Build()
265+
pkgID := pkg.ID(tt.name)
266+
b := dbtest.NewPackage(tt.pkgName, tt.pkgVersion, syftPkg.DebPkg).WithID(pkgID).WithDistro(tt.d)
267+
if tt.upstreamName != "" {
268+
b = b.WithUpstream(tt.upstreamName, tt.upstreamVer)
269+
}
270+
p := b.Build()
177271

178272
findings := db.Match(t, matcher, p)
179273
if tt.expectEmpty {
180274
findings.IsEmpty()
181275
return
182276
}
277+
// the ownership ignore must be keyed to the scanned package (not the upstream source)
183278
findings.Ignores().
184-
SelectRelatedPackageIgnore(IgnoreReasonDistroNotVulnerable, tt.expectCVE)
279+
SelectRelatedPackageIgnore(IgnoreReasonDistroNotVulnerable, tt.expectCVE).
280+
ForPackage(pkgID)
185281
})
186282
}
187283
})
188284
}
285+
286+
// TestUbuntuESM_MultipleCVEsPerPackage verifies that a single package resolving multiple ESM CVEs surfaces every one,
287+
// each with its own fix version. Exercises the Set/Merge fan-out over vulnerability IDs in the ESM path.
288+
func TestUbuntuESM_MultipleCVEsPerPackage(t *testing.T) {
289+
dbtest.DBs(t, "ubuntu-esm").Run(func(t *testing.T, db *dbtest.DB) {
290+
matcher := NewDpkgMatcher(MatcherConfig{})
291+
292+
// openssh on 20.04+esm carries two ESM-only CVEs fixed at different point releases; installed below both.
293+
p := dbtest.NewPackage("openssh", "1:8.2p1-4ubuntu0.12", syftPkg.DebPkg).
294+
WithDistro(newESMDistro("20.04")).
295+
Build()
296+
297+
findings := db.Match(t, matcher, p)
298+
findings.SkipCompleteness().OnlyHasVulnerabilities("CVE-2025-61985", "CVE-2023-38408")
299+
300+
findings.SkipCompleteness().SelectMatch("CVE-2025-61985").
301+
HasFix(vulnerability.FixStateFixed, "1:8.2p1-4ubuntu0.13+esm1")
302+
findings.SkipCompleteness().SelectMatch("CVE-2023-38408").
303+
HasFix(vulnerability.FixStateFixed, "1:8.2p1-4ubuntu0.20+esm1")
304+
})
305+
}
306+
307+
// TestUbuntuESM_MissingEpochStrategy asserts how the ESM path treats MissingEpochStrategy when the installed version
308+
// omits the epoch that the ESM fix carries. The installed build equals the fix build "1:8.2p1-4ubuntu0.13+esm1" but
309+
// lacks the "1:" prefix, so the two strategies must diverge:
310+
// - zero: missing epoch is treated as 0, so 0:... < 1:... -> still vulnerable (correct by definition).
311+
// - auto: missing epoch adopts the constraint's epoch, so 1:... == 1:... -> resolved, not vulnerable.
312+
//
313+
// NOTE: the auto case currently FAILS. The ESM two-pass search resolves fixes by comparing against the fix version
314+
// (neededFixes -> version.Is, and result filtering -> search.ByFixedVersion), both of which use a plain Compare that
315+
// ignores MissingEpochStrategy. Only constraint.Satisfied honors it, and the base disclosure here is an
316+
// always-vulnerable "None" row, so the constraint check never resolves it (the RHEL EUS path has the same gap). This
317+
// test intentionally asserts the correct behavior so it stays red until fix-version comparison honors the strategy.
318+
func TestUbuntuESM_MissingEpochStrategy(t *testing.T) {
319+
const (
320+
cve = "CVE-2025-61985"
321+
// installed at the same build as the fix "1:8.2p1-4ubuntu0.13+esm1" but with no epoch prefix
322+
installedNoEpoch = "8.2p1-4ubuntu0.13+esm1"
323+
)
324+
325+
tests := []struct {
326+
strategy version.MissingEpochStrategy
327+
expectResolve bool // true: installed is the fixed build, must not be reported
328+
}{
329+
{strategy: version.MissingEpochStrategyZero, expectResolve: false},
330+
{strategy: version.MissingEpochStrategyAuto, expectResolve: true},
331+
}
332+
333+
for _, tt := range tests {
334+
t.Run(string(tt.strategy), func(t *testing.T) {
335+
dbtest.DBs(t, "ubuntu-esm").Run(func(t *testing.T, db *dbtest.DB) {
336+
matcher := NewDpkgMatcher(MatcherConfig{MissingEpochStrategy: tt.strategy})
337+
p := dbtest.NewPackage("openssh", installedNoEpoch, syftPkg.DebPkg).
338+
WithDistro(newESMDistro("20.04")).
339+
Build()
340+
341+
findings := db.Match(t, matcher, p)
342+
if tt.expectResolve {
343+
findings.SkipCompleteness().DoesNotHaveAnyVulnerabilities(cve)
344+
} else {
345+
findings.SkipCompleteness().SelectMatch(cve)
346+
}
347+
})
348+
})
349+
}
350+
}

grype/matcher/dpkg/testdata/ubuntu-esm/db.yaml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,3 +6,6 @@ extractions:
66
- ubuntu:20.04/cve-2021-3782
77
- ubuntu:20.04/cve-2025-61985
88
- ubuntu:20.04+esm/cve-2025-61985
9+
- ubuntu:16.04/cve-2016-9586
10+
- ubuntu:20.04/cve-2023-38408
11+
- ubuntu:20.04+esm/cve-2023-38408
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
{
2+
"identifier": "ubuntu:16.04/cve-2016-9586",
3+
"item": {
4+
"Vulnerability": {
5+
"Description": "curl: no fix on Ubuntu 16.04 (xenial) in either the base pocket or ESM (esm-infra) - the base pocket is past standard support (Version \"None\") and no +esm record exists. Exercises the channel-on wont-fix path where ESM has not published a fix. Source: https://ubuntu.com/security/cves/CVE-2016-9586.json",
6+
"FixedIn": [
7+
{
8+
"Available": null,
9+
"Name": "curl",
10+
"NamespaceName": "ubuntu:16.04",
11+
"VendorAdvisory": {
12+
"NoAdvisory": false
13+
},
14+
"Version": "None",
15+
"VersionFormat": "dpkg"
16+
}
17+
],
18+
"Link": "https://ubuntu.com/security/CVE-2016-9586",
19+
"Metadata": {},
20+
"Name": "CVE-2016-9586",
21+
"NamespaceName": "ubuntu:16.04",
22+
"Severity": "Medium"
23+
}
24+
},
25+
"schema": "https://raw.githubusercontent.com/anchore/vunnel/main/schema/vulnerability/os/schema-1.1.0.json"
26+
}
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
{
2+
"identifier": "ubuntu:20.04+esm/cve-2023-38408",
3+
"item": {
4+
"Vulnerability": {
5+
"Description": "openssh: fixed on Ubuntu 20.04 (focal) via ESM esm-infra at a later point release than CVE-2025-61985. Source: https://ubuntu.com/security/cves/CVE-2023-38408.json",
6+
"FixedIn": [
7+
{
8+
"Available": null,
9+
"Name": "openssh",
10+
"NamespaceName": "ubuntu:20.04+esm",
11+
"VendorAdvisory": {
12+
"NoAdvisory": false
13+
},
14+
"Version": "1:8.2p1-4ubuntu0.20+esm1",
15+
"VersionFormat": "dpkg"
16+
}
17+
],
18+
"Link": "https://ubuntu.com/security/CVE-2023-38408",
19+
"Metadata": {},
20+
"Name": "CVE-2023-38408",
21+
"NamespaceName": "ubuntu:20.04+esm",
22+
"Severity": "High"
23+
}
24+
},
25+
"schema": "https://raw.githubusercontent.com/anchore/vunnel/main/schema/vulnerability/os/schema-1.1.0.json"
26+
}
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
{
2+
"identifier": "ubuntu:20.04/cve-2023-38408",
3+
"item": {
4+
"Vulnerability": {
5+
"Description": "openssh: second CVE on Ubuntu 20.04 (focal), fixed only via ESM esm-infra; the base 20.04 pocket has no fix (Version \"None\"). Paired with CVE-2025-61985 so a single openssh scan surfaces multiple ESM-resolved CVEs. Source: https://ubuntu.com/security/cves/CVE-2023-38408.json",
6+
"FixedIn": [
7+
{
8+
"Available": null,
9+
"Name": "openssh",
10+
"NamespaceName": "ubuntu:20.04",
11+
"VendorAdvisory": {
12+
"NoAdvisory": false
13+
},
14+
"Version": "None",
15+
"VersionFormat": "dpkg"
16+
}
17+
],
18+
"Link": "https://ubuntu.com/security/CVE-2023-38408",
19+
"Metadata": {},
20+
"Name": "CVE-2023-38408",
21+
"NamespaceName": "ubuntu:20.04",
22+
"Severity": "High"
23+
}
24+
},
25+
"schema": "https://raw.githubusercontent.com/anchore/vunnel/main/schema/vulnerability/os/schema-1.1.0.json"
26+
}

grype/matcher/dpkg/ubuntu_esm.go

Lines changed: 5 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -51,7 +51,7 @@ func shouldUseUbuntuESMMatching(d *distro.Distro) bool {
5151
//
5252
// Unlike RHEL EUS there is no cross-minor reachability problem: Ubuntu ESM is one line per LTS release and the
5353
// resolution search only pulls same-minor 'ubuntu:XX.YY' and 'ubuntu:XX.YY+esm' rows, so every '+esm' fix pulled
54-
// is reachable by construction (see filterFixesForESM).
54+
// is reachable by construction.
5555
func ubuntuESMMatches(provider result.Provider, searchPkg pkg.Package, missingEpochStrategy version.MissingEpochStrategy, extra ...vulnerability.Criteria) ([]match.Match, []match.IgnoreFilter, error) {
5656
distroWithoutESM := *searchPkg.Distro
5757
distroWithoutESM.Channels = nil // clear the ESM channel so that we can search for the base distro
@@ -113,16 +113,6 @@ func ubuntuESMMatches(provider result.Provider, searchPkg pkg.Package, missingEp
113113
return remaining.ToMatches(), internal.OwnershipIgnores(searchPkg, IgnoreReasonDistroNotVulnerable, esmFixes.Vulnerabilities()...), nil
114114
}
115115

116-
// filterFixesForESM returns its input unchanged.
117-
//
118-
// ponytail: identity reachability. Ubuntu ESM is a single line per LTS release, and the resolution search only
119-
// pulls same-minor 'ubuntu:XX.YY' and 'ubuntu:XX.YY+esm' rows, so any '+esm' fix on the scanned release is always
120-
// reachable. Add dist-tag / suffix parsing here only if a future tier (FIPS, realtime, ...) introduces
121-
// cross-series builds where a fix version could belong to a different release than the one scanned.
122-
func filterFixesForESM(fixes []*version.Version) []*version.Version {
123-
return fixes
124-
}
125-
126116
// mergeESMAdvisoriesIntoMainDisclosures returns a function that filters disclosures based on the provided advisory
127117
// information (by fix version only) and merges applicable fixes into one vulnerability record, so the final result
128118
// contains only one vulnerability record per disclosure.
@@ -196,11 +186,10 @@ func collectMatchingConstraintsDetailsAndFixState(v *version.Version, advisoryRe
196186
*state = advisory.Fix.State
197187
}
198188

199-
// get all fixes greater than current version (parses versions once)
200-
allFixes := neededFixes(v, advisory.Fix.Versions, advisory.Constraint.Format(), advisory.ID)
201-
202-
// ponytail: identity reachability for ESM (see filterFixesForESM); every same-release '+esm' fix applies.
203-
applicableFixes := filterFixesForESM(allFixes)
189+
// get all fixes greater than current version (parses versions once). unlike RHEL EUS (see filterFixesForEUS)
190+
// there is no reachability filter: Ubuntu ESM is one line per LTS release and the resolution search only pulls
191+
// same-minor 'ubuntu:XX.YY' and 'ubuntu:XX.YY+esm' rows, so every '+esm' fix is reachable by construction.
192+
applicableFixes := neededFixes(v, advisory.Fix.Versions, advisory.Constraint.Format(), advisory.ID)
204193

205194
if len(applicableFixes) == 0 {
206195
// none of the fixes on this advisory are greater than the current version, so we can skip adding fixes

0 commit comments

Comments
 (0)