Skip to content

Commit 023e63a

Browse files
committed
add Ubuntu ESM (Ubuntu Pro) vulnerability matching
Handles Ubuntu ESM as a distro `esm` channel, modeled on the existing RHEL EUS support. When a scanned Ubuntu image is Pro/ESM-enabled, a two-pass match resolves base disclosures against the ESM-channel fixes, so users see `fixed in ...+esm1` instead of a bare wont-fix. Non-Pro scans are unchanged: the same CVEs still show as vulnerable/wont-fix. Off by default. Turns on when ESM is detected on the scanned OS or via `--distro ubuntu:XX.YY+esm`. This consumes the `ubuntu:XX.YY+esm` records the ubuntu vunnel provider now emits. No DB schema change: the `os` transformer already splits the channel suffix, same as RHEL EUS. Note: the Pro/ESM detection this relies on lives in syft (anchore/syft#5028). The `syft` dependency here is temporarily pinned to that PR's branch, not a released version, and needs re-pinning once #5028 merges. FIPS / Realtime / BlueField Pro tiers are intentionally out of scope for now. Signed-off-by: Alex Goodman <wagoodman@users.noreply.github.com>
1 parent fc88fad commit 023e63a

21 files changed

Lines changed: 904 additions & 19 deletions

File tree

cmd/grype/cli/commands/root.go

Lines changed: 21 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -430,8 +430,9 @@ func getProviderConfig(opts *options.Grype) pkg.ProviderConfig {
430430

431431
func getFixChannels(fixChannelOpts options.FixChannels) distro.FixChannels {
432432
// use the API defaults as a starting point, then overlay the application options
433-
eusOptions := distro.DefaultFixChannels().Get("eus")
433+
defaults := distro.DefaultFixChannels()
434434

435+
eusOptions := defaults.Get("eus")
435436
if eusOptions == nil {
436437
panic("default fix channels do not contain Red Hat EUS channel")
437438
}
@@ -441,16 +442,34 @@ func getFixChannels(fixChannelOpts options.FixChannels) distro.FixChannels {
441442
eusOptions.Versions = version.MustGetConstraint(fixChannelOpts.RedHatEUS.Versions, version.SemanticFormat)
442443
}
443444

445+
esmOptions := defaults.Get("esm")
446+
if esmOptions == nil {
447+
panic("default fix channels do not contain Ubuntu ESM channel")
448+
}
449+
450+
esmOptions.Apply = distro.FixChannelEnabled(fixChannelOpts.UbuntuESM.Apply)
451+
// note: esm's default Versions is nil (no version window); only override when the user explicitly sets one
452+
if fixChannelOpts.UbuntuESM.Versions != "" {
453+
esmOptions.Versions = version.MustGetConstraint(fixChannelOpts.UbuntuESM.Versions, version.SemanticFormat)
454+
}
455+
444456
return []distro.FixChannel{
445457
{
446458
// information inherent to the channel (part of the API defaults)
447-
Name: "eus",
459+
Name: eusOptions.Name,
448460
IDs: eusOptions.IDs,
449461

450462
// user configurable options
451463
Versions: eusOptions.Versions,
452464
Apply: eusOptions.Apply,
453465
},
466+
{
467+
Name: esmOptions.Name,
468+
IDs: esmOptions.IDs,
469+
470+
Versions: esmOptions.Versions,
471+
Apply: esmOptions.Apply,
472+
},
454473
}
455474
}
456475

cmd/grype/cli/commands/root_test.go

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,12 @@ func Test_getProviderConfig(t *testing.T) {
6666
Apply: "auto",
6767
Versions: version.MustGetConstraint(">= 8.0", version.SemanticFormat),
6868
},
69+
{
70+
Name: "esm",
71+
IDs: []string{"ubuntu"},
72+
Apply: "auto",
73+
Versions: nil,
74+
},
6975
},
7076
},
7177
},

cmd/grype/cli/options/fix_channels.go

Lines changed: 25 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import (
66

77
"github.com/anchore/clio"
88
"github.com/anchore/grype/grype/distro"
9+
"github.com/anchore/grype/grype/version"
910
)
1011

1112
type FixChannelEnabled string
@@ -15,6 +16,9 @@ type FixChannels struct {
1516

1617
// EUS is the Extended Update Support channel for RHEL
1718
RedHatEUS FixChannel `yaml:"redhat-eus" json:"redhat-eus" mapstructure:"redhat-eus"`
19+
20+
// UbuntuESM is the Extended Security Maintenance (Ubuntu Pro) channel for Ubuntu
21+
UbuntuESM FixChannel `yaml:"ubuntu-esm" json:"ubuntu-esm" mapstructure:"ubuntu-esm"`
1822
}
1923

2024
type FixChannel struct {
@@ -40,21 +44,41 @@ func (o *FixChannel) PostLoad() error {
4044

4145
func DefaultFixChannels() FixChannels {
4246
rhelEUS := distro.DefaultFixChannels().Get("eus")
43-
4447
if rhelEUS == nil {
4548
panic("default fix channels do not contain Red Hat EUS channel")
4649
}
4750

51+
ubuntuESM := distro.DefaultFixChannels().Get("esm")
52+
if ubuntuESM == nil {
53+
panic("default fix channels do not contain Ubuntu ESM channel")
54+
}
55+
4856
// use API defaults for the CLI configuration
4957
return FixChannels{
5058
RedHatEUS: FixChannel{
5159
Apply: string(rhelEUS.Apply),
5260
Versions: rhelEUS.Versions.Value(),
5361
},
62+
UbuntuESM: FixChannel{
63+
Apply: string(ubuntuESM.Apply),
64+
// note: esm has a nil Versions constraint (no version window), so do not call .Value() on it
65+
Versions: constraintValue(ubuntuESM.Versions),
66+
},
67+
}
68+
}
69+
70+
// constraintValue returns the string form of a version constraint, tolerating a nil constraint (a channel with no
71+
// version window, such as Ubuntu ESM).
72+
func constraintValue(c version.Constraint) string {
73+
if c == nil {
74+
return ""
5475
}
76+
return c.Value()
5577
}
5678

5779
func (o *FixChannels) DescribeFields(descriptions clio.FieldDescriptionSet) {
5880
descriptions.Add(&o.RedHatEUS, `whether to always enable, disable, or automatically detect when to use Red Hat Extended Update Support (EUS) vulnerability data`)
5981
descriptions.Add(&o.RedHatEUS.Apply, `whether fixes from this channel should be considered, options are "never", "always", or "auto" (conditionally applied based on SBOM data)`)
82+
descriptions.Add(&o.UbuntuESM, `whether to always enable, disable, or automatically detect when to use Ubuntu Extended Security Maintenance (ESM / Ubuntu Pro) vulnerability data`)
83+
descriptions.Add(&o.UbuntuESM.Apply, `whether fixes from this channel should be considered, options are "never", "always", or "auto" (conditionally applied based on SBOM data)`)
6084
}

go.mod

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@ require (
1818
github.com/anchore/go-version v1.2.2-0.20210903204242-51efa5b487c4
1919
github.com/anchore/packageurl-go v0.2.0
2020
github.com/anchore/stereoscope v0.2.2
21-
github.com/anchore/syft v1.46.0
21+
github.com/anchore/syft v1.46.1-0.20260702183437-ba661e83a7e5
2222
github.com/aquasecurity/go-pep440-version v0.0.1
2323
github.com/araddon/dateparse v0.0.0-20210429162001-6b43995a97de
2424
github.com/bitnami/go-version v0.0.0-20250505154626-452e8c5ee607
@@ -339,6 +339,7 @@ require (
339339
google.golang.org/grpc v1.80.0 // indirect
340340
google.golang.org/protobuf v1.36.12-0.20260120151049-f2248ac996af // indirect
341341
gopkg.in/warnings.v0 v0.1.2 // indirect
342+
howett.net/plist v1.0.1 // indirect
342343
modernc.org/libc v1.72.3 // indirect
343344
modernc.org/mathutil v1.7.1 // indirect
344345
modernc.org/memory v1.11.0 // indirect

go.sum

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -154,8 +154,8 @@ github.com/anchore/packageurl-go v0.2.0 h1:CkrM4RMUwrEGAiE1OVlxaZNzWj0TuHRey7o4T
154154
github.com/anchore/packageurl-go v0.2.0/go.mod h1:2JCgOQMIsqZ7TmliXG4PnUthPJAKE3mWQbsW2XHjAOE=
155155
github.com/anchore/stereoscope v0.2.2 h1:SGTLGoF6GHmKEn9Bb6LYpzzgz9nhvMZgN/c4fTSQjYY=
156156
github.com/anchore/stereoscope v0.2.2/go.mod h1:ylJXJKebLctP7u9ewguB1d0zUETJxvAA7r2DiuljDTM=
157-
github.com/anchore/syft v1.46.0 h1:qIXMOJMlznWsWgzOT6xzK8aDhpOG7wnkbA0BS2UKF0c=
158-
github.com/anchore/syft v1.46.0/go.mod h1:LwBimjMV20nC1zAsN5jzpGTg/6f0X4Acvvx1WymA53g=
157+
github.com/anchore/syft v1.46.1-0.20260702183437-ba661e83a7e5 h1:sWURwg0BkDlGUo+lktVFpgPtdy7QqbsrojLnpoYT4ew=
158+
github.com/anchore/syft v1.46.1-0.20260702183437-ba661e83a7e5/go.mod h1:D2EesMQiokuYW5w5y7tVM4pNYGsaYxqNIImffSSgGnY=
159159
github.com/andreyvit/diff v0.0.0-20170406064948-c7f18ee00883/go.mod h1:rCTlJbsFo29Kk6CurOXKm700vrz8f0KW0JNfpkRJY/8=
160160
github.com/andybalholm/brotli v1.2.0 h1:ukwgCxwYrmACq68yiUqwIWnGY0cTPox/M94sVwToPjQ=
161161
github.com/andybalholm/brotli v1.2.0/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY=
@@ -651,6 +651,7 @@ github.com/invopop/jsonschema v0.14.0 h1:MHQqLhvpNUZfw+hM3AZDYK7jxO8FZoQeQM77g8i
651651
github.com/invopop/jsonschema v0.14.0/go.mod h1:ygm6C2EaVNMBDPpaPlnOA2pFAxBnxGjFlMZABxm9n2I=
652652
github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 h1:BQSFePA1RWJOlocH6Fxy8MmwDt+yVQYULKfN0RoTN8A=
653653
github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99/go.mod h1:1lJo3i6rXxKeerYnT8Nvf0QmHCRC1n8sfWVwXF2Frvo=
654+
github.com/jessevdk/go-flags v1.4.0/go.mod h1:4FA24M0QyGHXBuZZK/XkWh8h0e1EYbRYJSGM75WSRxI=
654655
github.com/jinzhu/copier v0.4.0 h1:w3ciUoD19shMCRargcpm0cm91ytaBhDvuRpz1ODO/U8=
655656
github.com/jinzhu/copier v0.4.0/go.mod h1:DfbEm0FYsaqBcKcFuvmOZb218JkPGtvSHsKg8S8hyyg=
656657
github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E=
@@ -1560,6 +1561,7 @@ gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI=
15601561
gopkg.in/ini.v1 v1.66.2/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k=
15611562
gopkg.in/warnings.v0 v0.1.2 h1:wFXVbFY8DY5/xOe1ECiWdKCzZlxgshcYVNkBHstARME=
15621563
gopkg.in/warnings.v0 v0.1.2/go.mod h1:jksf8JmL6Qr/oQM2OXTHunEvvTAsrWBLb6OOjuVWRNI=
1564+
gopkg.in/yaml.v1 v1.0.0-20140924161607-9f9df34309c0/go.mod h1:WDnlLJ4WF5VGsH/HVa3CI79GS0ol3YnhVnKP89i0kNg=
15631565
gopkg.in/yaml.v2 v2.2.1/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
15641566
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
15651567
gopkg.in/yaml.v2 v2.2.3/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
@@ -1584,6 +1586,8 @@ honnef.co/go/tools v0.0.0-20190523083050-ea95bdfd59fc/go.mod h1:rf3lG4BRIbNafJWh
15841586
honnef.co/go/tools v0.0.1-2019.2.3/go.mod h1:a3bituU0lyd329TUQxRnasdCoJDkEUEAqEt0JzvZhAg=
15851587
honnef.co/go/tools v0.0.1-2020.1.3/go.mod h1:X/FiERA/W4tHapMX5mGpAtMSVEeEUOyHaw9vFzvIQ3k=
15861588
honnef.co/go/tools v0.0.1-2020.1.4/go.mod h1:X/FiERA/W4tHapMX5mGpAtMSVEeEUOyHaw9vFzvIQ3k=
1589+
howett.net/plist v1.0.1 h1:37GdZ8tP09Q35o9ych3ehygcsL+HqKSwzctveSlarvM=
1590+
howett.net/plist v1.0.1/go.mod h1:lqaXoTrLY4hg8tnEzNru53gicrbv7rrk+2xJA/7hw9g=
15871591
modernc.org/cc/v4 v4.28.2 h1:3tQ0lf2ADtoby2EtSP+J7IE2SHwEJdP8ioR59wx7XpY=
15881592
modernc.org/cc/v4 v4.28.2/go.mod h1:OnovgIhbbMXMu1aISnJ0wvVD1KnW+cAUJkIrAWh+kVI=
15891593
modernc.org/ccgo/v4 v4.34.0 h1:yRLPFZieg532OT4rp4JFNIVcquwalMX26G95WQDqwCQ=

grype/db/v6/build/transformers/os/transform_test.go

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1103,6 +1103,21 @@ func TestGetOperatingSystem(t *testing.T) {
11031103
Channel: "eus",
11041104
},
11051105
},
1106+
{
1107+
name: "includes channel (ubuntu esm), preserves zero-padded minor",
1108+
osName: "ubuntu",
1109+
osID: "ubuntu",
1110+
osVersion: "22.04",
1111+
channel: "esm",
1112+
expected: &db.OperatingSystem{
1113+
Name: "ubuntu",
1114+
ReleaseID: "ubuntu",
1115+
MajorVersion: "22",
1116+
MinorVersion: "04",
1117+
Codename: "jammy",
1118+
Channel: "esm",
1119+
},
1120+
},
11061121
}
11071122

11081123
for _, tt := range tests {
@@ -1192,6 +1207,16 @@ func TestGetOSInfo(t *testing.T) {
11921207
channel: "eus",
11931208
},
11941209
},
1210+
{
1211+
name: "ubuntu + esm",
1212+
group: "ubuntu:22.04+esm",
1213+
expected: osInfo{
1214+
name: "ubuntu",
1215+
id: "ubuntu",
1216+
version: "22.04",
1217+
channel: "esm",
1218+
},
1219+
},
11951220
}
11961221

11971222
for _, tt := range tests {

grype/db/v6/operating_system_store_test.go

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,8 @@ func TestOperatingSystemStore_ResolveOperatingSystem(t *testing.T) {
1919

2020
ubuntu2004 := &OperatingSystem{Name: "ubuntu", ReleaseID: "ubuntu", MajorVersion: "20", MinorVersion: "04", LabelVersion: "focal"}
2121
ubuntu2010 := &OperatingSystem{Name: "ubuntu", MajorVersion: "20", MinorVersion: "10", LabelVersion: "groovy"}
22+
ubuntu1604 := &OperatingSystem{Name: "ubuntu", ReleaseID: "ubuntu", MajorVersion: "16", MinorVersion: "04", LabelVersion: "xenial"}
23+
ubuntu1604esm := &OperatingSystem{Name: "ubuntu", ReleaseID: "ubuntu", MajorVersion: "16", MinorVersion: "04", LabelVersion: "xenial", Channel: "esm"}
2224
rhel8 := &OperatingSystem{Name: "rhel", ReleaseID: "rhel", MajorVersion: "8"}
2325
rhel81 := &OperatingSystem{Name: "rhel", ReleaseID: "rhel", MajorVersion: "8", MinorVersion: "1"}
2426
debian10 := &OperatingSystem{Name: "debian", ReleaseID: "debian", MajorVersion: "10"}
@@ -40,6 +42,8 @@ func TestOperatingSystemStore_ResolveOperatingSystem(t *testing.T) {
4042
operatingSystems := []*OperatingSystem{
4143
ubuntu2004,
4244
ubuntu2010,
45+
ubuntu1604,
46+
ubuntu1604esm,
4347
rhel8,
4448
rhel81,
4549
debian10,
@@ -75,6 +79,25 @@ func TestOperatingSystemStore_ResolveOperatingSystem(t *testing.T) {
7579
},
7680
expected: []OperatingSystem{*ubuntu2004},
7781
},
82+
{
83+
name: "base ubuntu query returns only the vanilla (non-esm) channel row",
84+
os: OSSpecifier{
85+
Name: "ubuntu",
86+
MajorVersion: "16",
87+
MinorVersion: "04",
88+
},
89+
expected: []OperatingSystem{*ubuntu1604}, // important! the +esm row is NOT returned
90+
},
91+
{
92+
name: "ubuntu esm channel query returns only the esm row",
93+
os: OSSpecifier{
94+
Name: "ubuntu",
95+
MajorVersion: "16",
96+
MinorVersion: "04",
97+
Channel: "esm",
98+
},
99+
expected: []OperatingSystem{*ubuntu1604esm}, // important! the base row is NOT returned
100+
},
78101
{
79102
name: "specific distro with major and minor version (missing left padding)",
80103
os: OSSpecifier{

grype/distro/distro_test.go

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -270,6 +270,51 @@ func Test_NewDistroFromRelease(t *testing.T) {
270270
major: "9",
271271
minor: "4",
272272
},
273+
{
274+
name: "esm hinted at as attribute (ubuntu pro), nil version window applies",
275+
release: linux.Release{
276+
ID: "ubuntu",
277+
Version: "16.04",
278+
ExtendedSupport: true,
279+
},
280+
channels: testFixChannels(),
281+
expected: &Distro{
282+
Type: Ubuntu,
283+
Version: "16.04",
284+
Channels: names("esm"),
285+
},
286+
major: "16",
287+
minor: "04",
288+
},
289+
{
290+
name: "esm not applied for non-pro ubuntu (auto, no extended support)",
291+
release: linux.Release{
292+
ID: "ubuntu",
293+
Version: "16.04",
294+
},
295+
channels: testFixChannels(),
296+
expected: &Distro{
297+
Type: Ubuntu,
298+
Version: "16.04",
299+
},
300+
major: "16",
301+
minor: "04",
302+
},
303+
{
304+
name: "esm embedded in the version",
305+
release: linux.Release{
306+
ID: "ubuntu",
307+
Version: "16.04+esm",
308+
},
309+
channels: testFixChannels(),
310+
expected: &Distro{
311+
Type: Ubuntu,
312+
Version: "16.04",
313+
Channels: names("esm"),
314+
},
315+
major: "16",
316+
minor: "04",
317+
},
273318
{
274319
name: "v versionID prefix postmarketos",
275320
release: linux.Release{

grype/distro/fix_channel.go

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,14 @@ func DefaultFixChannels() FixChannels {
4949
Apply: ChannelConditionallyEnabled,
5050
Versions: version.MustGetConstraint(">= 8.0", version.SemanticFormat),
5151
},
52+
{
53+
Name: "esm",
54+
IDs: []string{"ubuntu"},
55+
Apply: ChannelConditionallyEnabled,
56+
// nil is deliberate: esm-apps covers universe for the full support window (including in-support
57+
// years), so there is no version gate like RHEL EUS. applyChannels already guards Versions != nil.
58+
Versions: nil,
59+
},
5260
}
5361
}
5462

grype/distro/fix_channel_test.go

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,14 +11,20 @@ import (
1111
func TestDefaultFixChannels(t *testing.T) {
1212
channels := DefaultFixChannels()
1313

14-
// this seems like a silly test, however, it is critical to ensure that the default channels have EUS with expected values
14+
// this seems like a silly test, however, it is critical to ensure that the default channels have EUS and ESM with expected values
1515
expected := FixChannels{
1616
{
1717
Name: "eus",
1818
IDs: []string{"rhel"},
1919
Apply: ChannelConditionallyEnabled,
2020
Versions: version.MustGetConstraint(">= 8.0", version.SemanticFormat),
2121
},
22+
{
23+
Name: "esm",
24+
IDs: []string{"ubuntu"},
25+
Apply: ChannelConditionallyEnabled,
26+
Versions: nil, // deliberate: no version window for ESM (esm-apps covers universe for the full support window)
27+
},
2228
}
2329

2430
if diff := cmp.Diff(expected, channels); diff != "" {

0 commit comments

Comments
 (0)