detect Ubuntu Pro/ESM extended support (#5028)

Mirrors the existing RHEL `ExtendedSupport` detector for Ubuntu so grype can
auto-enable its ESM distro channel on Pro/ESM-enabled scans.

`findUbuntuFeatures` sets `linux.Release.ExtendedSupport` when the scanned OS is
Ubuntu and any of these hold:

- an uncommented apt source (classic `.list` or DEB822 `.sources`, or the
  ubuntu-advantage auth entry) referencing `esm.ubuntu.com`
- an enabled `esm-infra`/`esm-apps` service in the ubuntu-advantage
  `status.json` cache
- an installed dpkg package whose version carries an `[~+]esm` pocket suffix
  (the usual in-image signal, since containers are rarely Pro-attached)

The package fallback reads `s.Artifacts.Packages` from the `DetectFeatures`
SBOM callback, which runs after package cataloging, so no new plumbing was
needed. Conservative by design: plain Ubuntu and disabled/commented ESM
evidence stay false.

Signed-off-by: Alex Goodman <wagoodman@users.noreply.github.com>
This commit is contained in:
Alex Goodman
2026-07-07 10:11:15 -04:00
committed by GitHub
parent 3e399256b3
commit 002a326c41
13 changed files with 493 additions and 11 deletions
+214
View File
@@ -2,6 +2,11 @@ package os_test
import (
"context"
"io"
stdos "os"
"path"
"path/filepath"
"slices"
"testing"
"github.com/stretchr/testify/require"
@@ -11,7 +16,9 @@ import (
"github.com/anchore/syft/internal/sbomsync"
"github.com/anchore/syft/internal/task"
"github.com/anchore/syft/syft"
"github.com/anchore/syft/syft/file"
"github.com/anchore/syft/syft/linux"
"github.com/anchore/syft/syft/pkg"
"github.com/anchore/syft/syft/sbom"
"github.com/anchore/syft/syft/source"
)
@@ -99,3 +106,210 @@ func Test_EnvironmentTask(t *testing.T) {
})
}
}
func Test_findUbuntuFeatures(t *testing.T) {
tests := []struct {
name string
dir string
releaseID string
packages []pkg.Package
expected bool
}{
{
name: "esm apt source present",
dir: "testdata/ubuntu_esm_apt",
releaseID: "ubuntu",
expected: true,
},
{
name: "esm apt source in DEB822 .sources format",
dir: "testdata/ubuntu_esm_deb822",
releaseID: "ubuntu",
expected: true,
},
{
name: "esm evidence in apt auth.conf.d",
dir: "testdata/ubuntu_esm_authconf",
releaseID: "ubuntu",
expected: true,
},
{
name: "active esm service in ubuntu-advantage status",
dir: "testdata/ubuntu_esm_status",
releaseID: "ubuntu",
expected: true,
},
{
name: "only esm-infra enabled (esm-apps disabled)",
dir: "testdata/ubuntu_esm_infra_only",
releaseID: "ubuntu",
expected: true,
},
{
name: "malformed status.json does not panic or match",
dir: "testdata/ubuntu_esm_malformed",
releaseID: "ubuntu",
expected: false,
},
{
name: "installed +esm package version",
dir: "testdata/ubuntu_plain",
releaseID: "ubuntu",
packages: []pkg.Package{
{Name: "openssl", Version: "1.1.1f-1ubuntu2.19+esm1", Type: pkg.DebPkg},
},
expected: true,
},
{
name: "installed ~esm package version",
dir: "testdata/ubuntu_plain",
releaseID: "ubuntu",
packages: []pkg.Package{
{Name: "libcap2", Version: "1:2.32-1ubuntu0.1~esm1", Type: pkg.DebPkg},
},
expected: true,
},
{
name: "plain ubuntu with no esm evidence",
dir: "testdata/ubuntu_plain",
releaseID: "ubuntu",
packages: []pkg.Package{
{Name: "openssl", Version: "1.1.1f-1ubuntu2.19", Type: pkg.DebPkg},
},
expected: false,
},
{
name: "commented esm source and disabled esm service",
dir: "testdata/ubuntu_esm_disabled",
releaseID: "ubuntu",
expected: false,
},
{
name: "esm disabled but +esm package remains installed",
dir: "testdata/ubuntu_esm_disabled",
releaseID: "ubuntu",
packages: []pkg.Package{
{Name: "openssl", Version: "1.1.1f-1ubuntu2.19+esm1", Type: pkg.DebPkg},
},
expected: true, // durable proof: ESM content is on disk even though the channel is now off
},
{
name: "fips-only pro host is not plain esm",
dir: "testdata/ubuntu_fips_only",
releaseID: "ubuntu",
expected: false, // esm.ubuntu.com/fips must not be folded into the base esm channel
},
{
name: "non-ubuntu is unaffected by esm evidence",
dir: "testdata/ubuntu_esm_apt",
releaseID: "debian",
expected: false,
},
{
name: "non-ubuntu is unaffected by esm package version",
dir: "testdata/ubuntu_plain",
releaseID: "debian",
packages: []pkg.Package{
{Name: "openssl", Version: "1.1.1f-1ubuntu2.19+esm1", Type: pkg.DebPkg},
},
expected: false,
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
resolver := fixtureResolverForDir(t, test.dir)
s := sbom.SBOM{}
s.Artifacts.LinuxDistribution = &linux.Release{ID: test.releaseID}
s.Artifacts.Packages = pkg.NewCollection(test.packages...)
err := os.DetectFeatures(context.Background(), resolver, sbomsync.NewBuilder(&s))
require.NoError(t, err)
require.Equal(t, test.expected, s.Artifacts.LinuxDistribution.ExtendedSupport)
})
}
}
// a read error in the apt-source signal must not suppress the ubuntu-advantage status signal; the three
// signals are meant to be independent fallbacks.
func Test_findUbuntuFeatures_signalErrorDoesNotSuppressOthers(t *testing.T) {
resolver := errOnGlobResolver{
fixtureResolver: fixtureResolverForDir(t, "testdata/ubuntu_esm_status").(fixtureResolver),
errOnGlob: "/etc/apt/sources.list.d/*",
}
s := sbom.SBOM{}
s.Artifacts.LinuxDistribution = &linux.Release{ID: "ubuntu"}
s.Artifacts.Packages = pkg.NewCollection()
require.NoError(t, os.DetectFeatures(context.Background(), resolver, sbomsync.NewBuilder(&s)))
// the apt glob errors, but the status.json signal (esm-infra enabled) should still be consulted
require.True(t, s.Artifacts.LinuxDistribution.ExtendedSupport)
}
// errOnGlobResolver injects a failure for a specific glob to simulate an unreadable apt directory.
type errOnGlobResolver struct {
fixtureResolver
errOnGlob string
}
func (r errOnGlobResolver) FilesByGlob(patterns ...string) ([]file.Location, error) {
if slices.Contains(patterns, r.errOnGlob) {
return nil, stdos.ErrPermission
}
return r.fixtureResolver.FilesByGlob(patterns...)
}
// fixtureResolver maps in-image logical paths to on-disk fixture files so DetectFeatures can be driven
// without building a container image.
type fixtureResolver struct {
file.Resolver // unused methods; nil is fine since only the three below are called
files map[string]string
}
func (r fixtureResolver) FilesByGlob(patterns ...string) ([]file.Location, error) {
var out []file.Location
for logical := range r.files {
for _, p := range patterns {
if ok, _ := path.Match(p, logical); ok {
out = append(out, file.NewLocation(logical))
break
}
}
}
return out, nil
}
func (r fixtureResolver) FilesByPath(paths ...string) ([]file.Location, error) {
var out []file.Location
for _, p := range paths {
if _, ok := r.files[p]; ok {
out = append(out, file.NewLocation(p))
}
}
return out, nil
}
func (r fixtureResolver) FileContentsByLocation(l file.Location) (io.ReadCloser, error) {
return stdos.Open(r.files[l.RealPath])
}
func fixtureResolverForDir(t *testing.T, dir string) file.Resolver {
t.Helper()
files := map[string]string{}
require.NoError(t, filepath.WalkDir(dir, func(p string, d stdos.DirEntry, err error) error {
if err != nil || d.IsDir() {
return err
}
rel, err := filepath.Rel(dir, p)
if err != nil {
return err
}
files["/"+filepath.ToSlash(rel)] = p
return nil
}))
return fixtureResolver{files: files}
}