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).
1970const (
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.
142218func 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+ }
0 commit comments