From 61ad71ff626d6d986e8fac2d157fa73243688f73 Mon Sep 17 00:00:00 2001 From: crozzy Date: Fri, 14 Nov 2025 15:42:50 -0800 Subject: [PATCH] photon: add dynamic distribution discovery The photon ecosystem still had statically defined distributions which meant that it has gotten stale over time. This change add the ability to dynamically discover those distributions. Signed-off-by: crozzy --- photon/distributionscanner.go | 42 ++++-------- photon/distributionscanner_test.go | 38 +++++++++-- photon/parser.go | 2 +- photon/photon.go | 13 +++- photon/releases.go | 58 +++++----------- photon/updaterset.go | 105 ++++++++++++++++++++++++++--- test/periodic/updater_test.go | 7 +- updater/defaults/defaults.go | 2 +- 8 files changed, 176 insertions(+), 91 deletions(-) diff --git a/photon/distributionscanner.go b/photon/distributionscanner.go index 3d2bb0d27..abc926d78 100644 --- a/photon/distributionscanner.go +++ b/photon/distributionscanner.go @@ -12,9 +12,6 @@ import ( "github.com/quay/claircore/indexer" ) -// Photon provides one security database file per major version. So far, there are 3 versions -// Photon 1.0, Photon 2.0 and Photon 3.0 - const ( scannerName = "photon" scannerVersion = "v0.0.1" @@ -26,28 +23,9 @@ const ( photonReleasePath = `etc/photon-release` ) -type photonRegex struct { - release Release - regexp *regexp.Regexp -} - -var photonRegexes = []photonRegex{ - { - release: Photon1, - // regex for /etc/os-release - regexp: regexp.MustCompile(`^.*"VMware Photon"\sVERSION="1.0"`), - }, - { - release: Photon2, - // regex for /etc/os-release - regexp: regexp.MustCompile(`^.*"VMware Photon OS"\sVERSION="2.0"`), - }, - { - release: Photon3, - // regex for /etc/os-release - regexp: regexp.MustCompile(`^.*"VMware Photon OS"\sVERSION="3.0"`), - }, -} +var ( + photonVersionRe = regexp.MustCompile(`VMware Photon(?: OS)?"\s*VERSION="([0-9]+\.[0-9]+)"`) +) var ( _ indexer.DistributionScanner = (*DistributionScanner)(nil) @@ -99,10 +77,14 @@ func (ds *DistributionScanner) Scan(ctx context.Context, l *claircore.Layer) ([] // // separated into its own method to aid testing. func (ds *DistributionScanner) parse(buff *bytes.Buffer) *claircore.Distribution { - for _, ur := range photonRegexes { - if ur.regexp.Match(buff.Bytes()) { - return releaseToDist(ur.release) - } + b := buff.Bytes() + m := photonVersionRe.FindSubmatch(b) + if len(m) < 2 { + return nil + } + ver := string(m[1]) + if ver == "" { + return nil } - return nil + return mkDist(ver) } diff --git a/photon/distributionscanner_test.go b/photon/distributionscanner_test.go index 6d2fc9d67..27a7b5bee 100644 --- a/photon/distributionscanner_test.go +++ b/photon/distributionscanner_test.go @@ -34,6 +34,24 @@ ANSI_COLOR="1;34" HOME_URL="https://vmware.github.io/photon/" BUG_REPORT_URL="https://github.com/vmware/photon/issues"`) +var photon4OSRelease []byte = []byte(`NAME="VMware Photon OS" +VERSION="4.0" +ID=photon +VERSION_ID="4.0" +PRETTY_NAME="VMware Photon OS/Linux" +ANSI_COLOR="1;34" +HOME_URL="https://vmware.github.io/photon/" +BUG_REPORT_URL="https://github.com/vmware/photon/issues"`) + +var photon5OSRelease []byte = []byte(`NAME="VMware Photon OS" +VERSION="5.0" +ID=photon +VERSION_ID="5.0" +PRETTY_NAME="VMware Photon OS/Linux" +ANSI_COLOR="1;34" +HOME_URL="https://vmware.github.io/photon/" +BUG_REPORT_URL="https://github.com/vmware/photon/issues"`) + func TestDistributionScanner(t *testing.T) { table := []struct { name string @@ -42,26 +60,36 @@ func TestDistributionScanner(t *testing.T) { }{ { name: "photon 1.0", - release: Photon1, + release: Release("1.0"), osRelease: photon1OSRelease, }, { name: "photon 2.0", - release: Photon2, + release: Release("2.0"), osRelease: photon2OSRelease, }, { name: "photon 3.0", - release: Photon3, + release: Release("3.0"), osRelease: photon3OSRelease, }, + { + name: "photon 4.0", + release: Release("4.0"), + osRelease: photon4OSRelease, + }, + { + name: "photon 5.0", + release: Release("5.0"), + osRelease: photon5OSRelease, + }, } for _, tt := range table { t.Run(tt.name, func(t *testing.T) { scanner := DistributionScanner{} dist := scanner.parse(bytes.NewBuffer(tt.osRelease)) - if !cmp.Equal(dist, releaseToDist(tt.release)) { - t.Fatalf("%v", cmp.Diff(dist, releaseToDist(tt.release))) + if !cmp.Equal(dist, mkDist(string(tt.release))) { + t.Fatalf("%v", cmp.Diff(dist, mkDist(string(tt.release)))) } }) } diff --git a/photon/parser.go b/photon/parser.go index 7467b1462..39dc9b3b3 100644 --- a/photon/parser.go +++ b/photon/parser.go @@ -42,7 +42,7 @@ func (u *Updater) Parse(ctx context.Context, r io.ReadCloser) ([]*claircore.Vuln // each updater is configured to parse a photon release // specific xml database. we'll use the updater's release // to map the parsed vulnerabilities - Dist: releaseToDist(u.release), + Dist: mkDist(string(u.release)), }, }, nil } diff --git a/photon/photon.go b/photon/photon.go index ad27585f0..308bbef30 100644 --- a/photon/photon.go +++ b/photon/photon.go @@ -3,6 +3,7 @@ package photon import ( "fmt" "net/url" + "strings" "github.com/quay/claircore/libvuln/driver" "github.com/quay/claircore/pkg/ovalutil" @@ -43,11 +44,21 @@ func NewUpdater(r Release, opts ...Option) (*Updater, error) { } } if u.Fetcher.URL == nil { + // Default to gzip-compressed Photon OVAL filenames: + // com.vmware.phsa-photon.xml.gz + s := string(u.release) + maj := s + if i := strings.IndexByte(s, '.'); i >= 0 { + maj = s[:i] + } + filename := "com.vmware.phsa-photon" + maj + ".xml.gz" var err error - u.Fetcher.URL, err = upstreamBase.Parse("com.vmware.phsa-" + string(u.release) + ".xml") + u.Fetcher.URL, err = upstreamBase.Parse(filename) if err != nil { return nil, err } + // Configure default compression to gzip. + u.Fetcher.Compression = ovalutil.CompressionGzip } return u, nil } diff --git a/photon/releases.go b/photon/releases.go index 8468aa6c5..388fb71e8 100644 --- a/photon/releases.go +++ b/photon/releases.go @@ -1,51 +1,23 @@ package photon -import "github.com/quay/claircore" +import ( + "sync" -// Release indicates the Photon release OVAL database to pull from. -type Release string - -// These are some known Releases. -const ( - Photon1 Release = `photon1` - Photon2 Release = `photon2` - Photon3 Release = `photon3` + "github.com/quay/claircore" ) -var photon1Dist = &claircore.Distribution{ - Name: "VMware Photon OS", - Version: "1.0", - VersionID: "1.0", - PrettyName: "VMware Photon OS/Linux", - DID: "photon", -} - -var photon2Dist = &claircore.Distribution{ - Name: "VMware Photon OS", - Version: "2.0", - VersionID: "2.0", - PrettyName: "VMware Photon OS/Linux", - DID: "photon", -} +// Release indicates the Photon release OVAL database to pull from. +type Release string -var photon3Dist = &claircore.Distribution{ - Name: "VMware Photon OS", - Version: "3.0", - VersionID: "3.0", - PrettyName: "VMware Photon OS/Linux", - DID: "photon", -} +var distCache sync.Map // key: version string (e.g., "1.0"), value: *claircore.Distribution -func releaseToDist(r Release) *claircore.Distribution { - switch r { - case Photon1: - return photon1Dist - case Photon2: - return photon2Dist - case Photon3: - return photon3Dist - default: - // return empty dist - return &claircore.Distribution{} - } +func mkDist(ver string) *claircore.Distribution { + v, _ := distCache.LoadOrStore(ver, &claircore.Distribution{ + Name: "VMware Photon OS", + Version: ver, + VersionID: ver, + PrettyName: "VMware Photon OS/Linux", + DID: "photon", + }) + return v.(*claircore.Distribution) } diff --git a/photon/updaterset.go b/photon/updaterset.go index 14c41a259..3575bfec5 100644 --- a/photon/updaterset.go +++ b/photon/updaterset.go @@ -3,25 +3,112 @@ package photon import ( "context" "fmt" + "io" + "net/http" + "net/url" + "regexp" + "strings" "github.com/quay/claircore/libvuln/driver" ) -var photonReleases = []Release{ - Photon1, - Photon2, - Photon3, +// UpdaterSet dynamically discovers available Photon OVAL databases from the +// upstream index and returns one updater per discovered major release. +// +// Discovery rules: +// - Match files named com.vmware.phsa-photon.xml.gz +// Factory implements a dynamic UpdaterSetFactory for Photon that discovers +// available OVAL feeds and constructs per-release updaters. +type Factory struct { + c *http.Client + base *url.URL } -func UpdaterSet(_ context.Context) (driver.UpdaterSet, error) { +var ( + _ driver.UpdaterSetFactory = (*Factory)(nil) + _ driver.Configurable = (*Factory)(nil) +) + +// FactoryConfig is the configuration accepted by the Factory. +// +// By convention, this is keyed by the string "photon". +type FactoryConfig struct { + // URL indicates the base URL for the OVAL layout. It should have a trailing slash. + URL string `json:"url" yaml:"url"` +} + +// NewFactory returns an unconfigured Factory. +func NewFactory(_ context.Context) (*Factory, error) { + return &Factory{}, nil +} + +// Configure implements driver.Configurable. +func (f *Factory) Configure(_ context.Context, cf driver.ConfigUnmarshaler, c *http.Client) error { + f.c = c + var cfg FactoryConfig + if err := cf(&cfg); err != nil { + return err + } + u := upstreamBase.String() + if cfg.URL != "" { + u = cfg.URL + if !strings.HasSuffix(u, "/") { + u += "/" + } + } + var err error + f.base, err = url.Parse(u) + return err +} + +// UpdaterSet dynamically discovers available Photon OVAL databases from the +// configured index and returns one updater per discovered major release. +// +// This will match files named com.vmware.phsa-photon.xml.gz +func (f *Factory) UpdaterSet(ctx context.Context) (driver.UpdaterSet, error) { us := driver.NewUpdaterSet() - for _, release := range photonReleases { - u, err := NewUpdater(release) + c := f.c + if c == nil { + c = http.DefaultClient + } + base := f.base + if base == nil { + base = upstreamBase + } + + res, err := c.Get(base.String()) + if err != nil { + return us, fmt.Errorf("photon: discovery request failed: %w", err) + } + defer res.Body.Close() + if res.StatusCode != http.StatusOK { + return us, fmt.Errorf("photon: unexpected status from index: %s", res.Status) + } + body, err := io.ReadAll(res.Body) + if err != nil { + return us, fmt.Errorf("photon: reading index body: %w", err) + } + + re := regexp.MustCompile(`href="com\.vmware\.phsa-photon(\d+)\.xml\.gz"`) + matches := re.FindAllStringSubmatch(string(body), -1) + if len(matches) == 0 { + return us, fmt.Errorf("photon: no OVAL entries discovered at index") + } + for _, m := range matches { + if len(m) < 2 { + continue + } + filename := "com.vmware.phsa-photon" + m[1] + ".xml.gz" + u, err := base.Parse(filename) if err != nil { - return us, fmt.Errorf("failed to create updater: %v", err) + return us, fmt.Errorf("photon: building feed url: %w", err) } - err = us.Add(u) + rel := Release(m[1] + ".0") + up, err := NewUpdater(rel, WithURL(u.String(), "gz")) if err != nil { + return us, fmt.Errorf("photon: creating updater for %s: %w", rel, err) + } + if err := us.Add(up); err != nil { return us, err } } diff --git a/test/periodic/updater_test.go b/test/periodic/updater_test.go index fb6b64849..bc675d6f7 100644 --- a/test/periodic/updater_test.go +++ b/test/periodic/updater_test.go @@ -80,7 +80,12 @@ func TestOracle(t *testing.T) { func TestPhoton(t *testing.T) { ctx := zlog.Test(context.Background(), t) - set, err := photon.UpdaterSet(ctx) + fac := new(photon.Factory) + err := fac.Configure(ctx, noopConfigure, pkgClient) + if err != nil { + t.Fatal(err) + } + set, err := fac.UpdaterSet(ctx) if err != nil { t.Fatal(err) } diff --git a/updater/defaults/defaults.go b/updater/defaults/defaults.go index f3abbcbb8..1bc6623f5 100644 --- a/updater/defaults/defaults.go +++ b/updater/defaults/defaults.go @@ -60,7 +60,7 @@ func inner(ctx context.Context) error { updater.Register("rhel-vex", new(vex.Factory)) updater.Register("aws", driver.UpdaterSetFactoryFunc(aws.UpdaterSet)) updater.Register("oracle", driver.UpdaterSetFactoryFunc(oracle.UpdaterSet)) - updater.Register("photon", driver.UpdaterSetFactoryFunc(photon.UpdaterSet)) + updater.Register("photon", new(photon.Factory)) updater.Register("suse", new(suse.Factory)) cvssSet := driver.NewUpdaterSet()