mirror of
https://github.com/anchore/syft.git
synced 2026-07-05 02:28:25 +02:00
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>
210 lines
8.3 KiB
Go
210 lines
8.3 KiB
Go
package os
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"regexp"
|
|
|
|
"github.com/anchore/syft/internal"
|
|
"github.com/anchore/syft/internal/log"
|
|
"github.com/anchore/syft/internal/sbomsync"
|
|
"github.com/anchore/syft/syft/file"
|
|
"github.com/anchore/syft/syft/linux"
|
|
"github.com/anchore/syft/syft/pkg"
|
|
"github.com/anchore/syft/syft/sbom"
|
|
)
|
|
|
|
func DetectFeatures(_ context.Context, resolver file.Resolver, builder sbomsync.Builder) error {
|
|
builder.(sbomsync.Accessor).WriteToSBOM(func(s *sbom.SBOM) {
|
|
if s.Artifacts.LinuxDistribution == nil {
|
|
return
|
|
}
|
|
|
|
if err := findRhelFeatures(resolver, s.Artifacts.LinuxDistribution); err != nil {
|
|
log.WithFields("error", err, "release", s.Artifacts.LinuxDistribution).Trace("error searching for extended support")
|
|
}
|
|
|
|
if err := findUbuntuFeatures(resolver, s.Artifacts.LinuxDistribution, s.Artifacts.Packages); err != nil {
|
|
log.WithFields("error", err, "release", s.Artifacts.LinuxDistribution).Trace("error searching for extended support")
|
|
}
|
|
})
|
|
|
|
return nil
|
|
}
|
|
|
|
// rhelEUSPatterns match a RHEL content manifest referencing an extended-update-support (EUS) repo.
|
|
var rhelEUSPatterns = []*regexp.Regexp{regexp.MustCompile(`baseos-eus`), regexp.MustCompile(`baseos-eus|appstream-eus`)}
|
|
|
|
func findRhelFeatures(resolver file.Resolver, release *linux.Release) error {
|
|
if release == nil || release.ID != "rhel" {
|
|
return nil
|
|
}
|
|
contentManifestFiles, err := resolver.FilesByGlob("/root/buildinfo/content_manifests/*.json")
|
|
if err != nil {
|
|
return fmt.Errorf("unable to find content manifests: %w", err)
|
|
}
|
|
for _, contentManifestFile := range contentManifestFiles {
|
|
found, err := fileMatchesAny(resolver, contentManifestFile, rhelEUSPatterns...)
|
|
if err != nil {
|
|
return fmt.Errorf("unable to read content manifest from %s: %w", contentManifestFile.RealPath, err)
|
|
}
|
|
if found {
|
|
release.ExtendedSupport = true
|
|
break
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// Ubuntu Pro (formerly Ubuntu Advantage) is Canonical's subscription that unlocks Expanded Security
|
|
// Maintenance (ESM): security fixes past a package's standard support window. we detect the two base ESM
|
|
// streams, esm-infra (the `main` repo) and esm-apps (`universe`), and collapse them into one ExtendedSupport
|
|
// signal; the infra-vs-apps split is disambiguated by the downstream consumer, not by this boolean.
|
|
//
|
|
// the other Pro streams on the same host (esm.ubuntu.com/fips, /fips-updates, /realtime) are deliberately
|
|
// excluded: they are separate compliance products with their own downstream channels, and a host can run
|
|
// them with base ESM disabled, so folding them in would be unsound.
|
|
//
|
|
// detection is deliberately forgiving: apt/status evidence disappears when ESM is disabled, but an installed
|
|
// +esmN package is durable proof ESM content is on disk. so ExtendedSupport means "ESM was or is in effect
|
|
// for this host or its content", not "currently entitled" (mere eligibility, without evidence, is not enough).
|
|
|
|
// esmAptSourcePattern matches an uncommented apt source (classic or DEB822) or auth entry pointing at the
|
|
// plain ESM streams esm.ubuntu.com/infra or /apps. it deliberately scopes to /(infra|apps) so the other
|
|
// Pro streams served from the same host (/fips, /fips-updates, /realtime) are never folded into the base
|
|
// esm channel. the [^#\n]* before the host ensures a leading `#` (comment) can never satisfy the match.
|
|
var esmAptSourcePattern = regexp.MustCompile(`(?m)^[^#\n]*esm\.ubuntu\.com/(infra|apps)`)
|
|
|
|
// esmVersionPattern matches a dpkg version carrying a numbered ESM pocket suffix, e.g. `...+esm1` or
|
|
// `...~esm2`. the trailing \d scopes this to the ESM pocket and excludes other Pro pockets (e.g. +fips).
|
|
var esmVersionPattern = regexp.MustCompile(`[~+]esm\d`)
|
|
|
|
func findUbuntuFeatures(resolver file.Resolver, release *linux.Release, packages *pkg.Collection) error {
|
|
if release == nil || release.ID != "ubuntu" {
|
|
return nil
|
|
}
|
|
|
|
// an uncommented esm.ubuntu.com apt source or an active pro esm service both prove Pro/ESM was enabled.
|
|
// these are the durable, file-based signals present on attached hosts. a read error in one signal must
|
|
// not suppress the others, so errors are collected and detection continues to the next signal.
|
|
var errs error
|
|
for _, check := range []func() (bool, error){
|
|
func() (bool, error) { return hasUbuntuESMAptEvidence(resolver) },
|
|
func() (bool, error) { return hasActiveUbuntuESMService(resolver) },
|
|
} {
|
|
found, err := check()
|
|
if err != nil {
|
|
errs = errors.Join(errs, err)
|
|
continue
|
|
}
|
|
if found {
|
|
release.ExtendedSupport = true
|
|
return errs
|
|
}
|
|
}
|
|
|
|
// fallback for images (typically not pro-attached): an installed package whose version carries an
|
|
// +esm/~esm suffix was pulled from an ESM pocket, so ESM was in effect when it was installed.
|
|
if hasInstalledESMPackage(packages) {
|
|
release.ExtendedSupport = true
|
|
}
|
|
return errs
|
|
}
|
|
|
|
// hasUbuntuESMAptEvidence reports whether any apt source (classic .list or DEB822 .sources) or auth entry
|
|
// references esm.ubuntu.com via an uncommented line.
|
|
func hasUbuntuESMAptEvidence(resolver file.Resolver) (bool, error) {
|
|
// apt source files live in a directory (classic .list or DEB822 .sources), the credentials file is a fixed path.
|
|
sourceLocations, err := resolver.FilesByGlob("/etc/apt/sources.list.d/*")
|
|
if err != nil {
|
|
return false, fmt.Errorf("unable to find apt esm sources: %w", err)
|
|
}
|
|
authLocations, err := resolver.FilesByPath("/etc/apt/auth.conf.d/90ubuntu-advantage")
|
|
if err != nil {
|
|
return false, fmt.Errorf("unable to find apt esm auth: %w", err)
|
|
}
|
|
|
|
// ponytail: a DEB822 source with `Enabled: no` would still match; the pro client removes/comments the
|
|
// file on disable rather than toggling that key, so this is an accepted false-positive ceiling. the
|
|
// auth.conf.d entry is deleted by the pro client on disable, so it is a durable positive-only signal.
|
|
for _, location := range append(sourceLocations, authLocations...) {
|
|
match, err := fileMatchesAny(resolver, location, esmAptSourcePattern)
|
|
if err != nil {
|
|
return false, err
|
|
}
|
|
if match {
|
|
return true, nil
|
|
}
|
|
}
|
|
return false, nil
|
|
}
|
|
|
|
// hasActiveUbuntuESMService reports whether the ubuntu-advantage/pro status cache shows an enabled esm service.
|
|
func hasActiveUbuntuESMService(resolver file.Resolver) (bool, error) {
|
|
locations, err := resolver.FilesByPath("/var/lib/ubuntu-advantage/status.json")
|
|
if err != nil {
|
|
return false, fmt.Errorf("unable to find ubuntu-advantage status: %w", err)
|
|
}
|
|
for _, location := range locations {
|
|
enabled, err := hasEnabledESMServiceInStatus(resolver, location)
|
|
if err != nil {
|
|
return false, err
|
|
}
|
|
if enabled {
|
|
return true, nil
|
|
}
|
|
}
|
|
return false, nil
|
|
}
|
|
|
|
func hasEnabledESMServiceInStatus(resolver file.Resolver, location file.Location) (bool, error) {
|
|
contents, err := resolver.FileContentsByLocation(location)
|
|
if err != nil {
|
|
return false, fmt.Errorf("unable to read ubuntu-advantage status from %s: %w", location.RealPath, err)
|
|
}
|
|
defer internal.CloseAndLogError(contents, location.RealPath)
|
|
|
|
var status struct {
|
|
Services []struct {
|
|
Name string `json:"name"`
|
|
Status string `json:"status"`
|
|
} `json:"services"`
|
|
}
|
|
if err := json.NewDecoder(contents).Decode(&status); err != nil {
|
|
return false, fmt.Errorf("unable to parse ubuntu-advantage status from %s: %w", location.RealPath, err)
|
|
}
|
|
|
|
// pro status values are enabled/disabled/n-a/warning/—; only "enabled" proves the stream is active.
|
|
// warning/expired states are intentionally excluded here and left to the apt-source and installed-package signals.
|
|
for _, svc := range status.Services {
|
|
if (svc.Name == "esm-infra" || svc.Name == "esm-apps") && svc.Status == "enabled" {
|
|
return true, nil
|
|
}
|
|
}
|
|
return false, nil
|
|
}
|
|
|
|
// hasInstalledESMPackage reports whether any installed dpkg package version carries an ESM pocket suffix.
|
|
func hasInstalledESMPackage(packages *pkg.Collection) bool {
|
|
if packages == nil {
|
|
return false
|
|
}
|
|
for p := range packages.Enumerate(pkg.DebPkg) {
|
|
if esmVersionPattern.MatchString(p.Version) {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
func fileMatchesAny(resolver file.Resolver, location file.Location, patterns ...*regexp.Regexp) (bool, error) {
|
|
contents, err := resolver.FileContentsByLocation(location)
|
|
if err != nil {
|
|
return false, fmt.Errorf("unable to read %s: %w", location.RealPath, err)
|
|
}
|
|
defer internal.CloseAndLogError(contents, location.RealPath)
|
|
return internal.MatchAnyFromReader(contents, patterns...)
|
|
}
|