diff --git a/oracle/factory.go b/oracle/factory.go new file mode 100644 index 000000000..90605f19f --- /dev/null +++ b/oracle/factory.go @@ -0,0 +1,110 @@ +package oracle + +import ( + "context" + "fmt" + "io" + "log/slog" + "net/http" + "regexp" + "strings" + "time" + + "github.com/quay/claircore/libvuln/driver" +) + +// FactoryConfig configures the Oracle Factory. +type FactoryConfig struct { + // URL indicates the index root. It should have a trailing slash. + URL string `json:"url" yaml:"url"` +} + +// indexURL is the Oracle OVAL index root. +// +//doc:url updater +const indexURL = `https://linux.oracle.com/security/oval/` + +// Factory provides a driver.UpdaterSetFactory for Oracle with an injected client. +type Factory struct { + c *http.Client + base string +} + +// Configure implements driver.Configurable. +func (f *Factory) Configure(ctx context.Context, cf driver.ConfigUnmarshaler, c *http.Client) error { + f.c = c + var cfg FactoryConfig + if err := cf(&cfg); err != nil { + return err + } + if cfg.URL != "" { + f.base = cfg.URL + } else { + f.base = indexURL + } + return nil +} + +// UpdaterSet implements driver.UpdaterSetFactory with inlined discovery logic. +func (f *Factory) UpdaterSet(ctx context.Context) (driver.UpdaterSet, error) { + us := driver.NewUpdaterSet() + + cl := f.c + if cl == nil { + slog.InfoContext(ctx, "unconfigured") + return us, nil + } + base := f.base + if base == "" { + base = indexURL + } + if !strings.HasSuffix(base, "/") { + base += "/" + } + + req, err := http.NewRequestWithContext(ctx, http.MethodGet, base, nil) + if err != nil { + return us, fmt.Errorf("oracle: unable to construct request: %w", err) + } + res, err := cl.Do(req) + if err != nil { + return us, fmt.Errorf("oracle: error requesting %q: %w", base, err) + } + defer res.Body.Close() + if res.StatusCode != http.StatusOK { + return us, fmt.Errorf("oracle: unexpected status requesting OVAL dir: %v", res.Status) + } + + body, err := io.ReadAll(res.Body) + if err != nil { + return us, fmt.Errorf("oracle: unable to read index body: %w", err) + } + re := regexp.MustCompile(`href="(com\.oracle\.elsa-(\d{4})\.xml\.bz2)"`) + matches := re.FindAllStringSubmatch(string(body), -1) + if len(matches) == 0 { + return us, fmt.Errorf("oracle: no OVAL entries discovered at index") + } + seen := map[int]struct{}{} + cutoff := time.Now().Year() - 9 + for _, m := range matches { + var y int + fmt.Sscanf(m[2], "%d", &y) + if y < cutoff { + continue + } + if _, ok := seen[y]; ok { + continue + } + seen[y] = struct{}{} + uri := base + m[1] + up, err := NewUpdater(y, WithURL(uri, "bzip2")) + if err != nil { + return us, fmt.Errorf("oracle: unable to create updater for %d: %w", y, err) + } + if err := us.Add(up); err != nil { + return us, err + } + slog.DebugContext(ctx, "oracle: added updater", "name", up.Name()) + } + return us, nil +} diff --git a/oracle/factory_test.go b/oracle/factory_test.go new file mode 100644 index 000000000..8871a1ffc --- /dev/null +++ b/oracle/factory_test.go @@ -0,0 +1,120 @@ +package oracle + +import ( + "context" + "net/http" + "net/http/httptest" + "strconv" + "strings" + "testing" + "time" + + "github.com/quay/claircore/pkg/ovalutil" +) + +func TestUpdaterSetDynamicDiscovery(t *testing.T) { + t.Parallel() + ctx := context.Background() + now := time.Now().Year() + + cases := []struct { + name string + entries []string + wantYears []int + wantErr bool + }{ + { + name: "happy-path-two-years-dedupe-and-filter", + entries: []string{ + `com.oracle.elsa-` + strconv.Itoa(now) + `.xml.bz2`, + `com.oracle.elsa-` + strconv.Itoa(now-5) + `.xml.bz2`, + `com.oracle.elsa-` + strconv.Itoa(now-15) + `.xml.bz2`, + `com.oracle.elsa-` + strconv.Itoa(now) + `.xml.bz2`, + }, + wantYears: []int{now, now - 5}, + }, + { + name: "no-matches", + entries: []string{`unrelated.txt`}, + wantYears: nil, + wantErr: true, + }, + } + + for _, tt := range cases { + tt := tt + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + body := `` + strings.Join(tt.entries, "\n") + `` + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(body)) + })) + defer srv.Close() + + // Configure factory with test URL/client. + f := &Factory{} + err := f.Configure(ctx, func(v any) error { + if cfg, ok := v.(*FactoryConfig); ok { + cfg.URL = strings.TrimSuffix(srv.URL, "/") + "/" + } + return nil + }, srv.Client()) + if err != nil { + t.Fatalf("configure: %v", err) + } + + us, err := f.UpdaterSet(ctx) + if tt.wantErr { + if err == nil { + t.Fatalf("expected error, got nil") + } + return + } + if err != nil { + t.Fatalf("UpdaterSet: %v", err) + } + ups := us.Updaters() + if len(ups) != len(tt.wantYears) { + t.Fatalf("unexpected updater count: got %d want %d", len(ups), len(tt.wantYears)) + } + want := map[string]bool{} + for _, y := range tt.wantYears { + want[strconv.Itoa(y)] = false + } + for _, u := range ups { + up, ok := u.(*Updater) + if !ok { + t.Fatalf("unexpected updater type: %T", u) + } + n := up.Name() + parts := strings.Split(n, "-") + if len(parts) < 3 { + t.Fatalf("unexpected updater name format: %q", n) + } + yr := parts[1] + if _, ok := want[yr]; !ok { + t.Fatalf("unexpected year in updater name: %q", n) + } + want[yr] = true + // URL and compression + base := strings.TrimSuffix(srv.URL, "/") + "/" + if up.Fetcher.URL == nil { + t.Fatalf("nil URL for updater %q", n) + } + if !strings.HasPrefix(up.Fetcher.URL.String(), base+`com.oracle.elsa-`) || + !strings.HasSuffix(up.Fetcher.URL.String(), `.xml.bz2`) { + t.Fatalf("unexpected URL: %q", up.Fetcher.URL) + } + if up.Fetcher.Compression != ovalutil.CompressionBzip2 { + t.Fatalf("unexpected compression: got %v want %v", up.Fetcher.Compression, ovalutil.CompressionBzip2) + } + } + for yr, ok := range want { + if !ok { + t.Fatalf("missing updater for year %s", yr) + } + } + }) + } +} diff --git a/oracle/updater.go b/oracle/updater.go index 86cc54ee4..ce3b8d8d8 100644 --- a/oracle/updater.go +++ b/oracle/updater.go @@ -9,12 +9,6 @@ import ( "github.com/quay/claircore/pkg/ovalutil" ) -const ( - allDB = `https://linux.oracle.com/security/oval/com.oracle.elsa-all.xml.bz2` - //doc:url updater - baseURL = `https://linux.oracle.com/security/oval/com.oracle.elsa-%d.xml.bz2` -) - // Updater implements driver.Updater for Oracle Linux. type Updater struct { year int @@ -26,21 +20,11 @@ type Option func(*Updater) error // NewUpdater returns an updater configured according to the provided Options. // -// If year is -1, the "all" database will be pulled. +// The URL and compression are expected to be set via WithURL by the UpdaterSet. func NewUpdater(year int, opts ...Option) (*Updater, error) { - uri := allDB - if year != -1 { - uri = fmt.Sprintf(baseURL, year) - } u := Updater{ year: year, } - var err error - u.Fetcher.URL, err = url.Parse(uri) - if err != nil { - return nil, err - } - u.Fetcher.Compression = ovalutil.CompressionBzip2 for _, o := range opts { if err := o(&u); err != nil { return nil, err diff --git a/oracle/updater_test.go b/oracle/updater_test.go index 62a29bc8c..04c79c739 100644 --- a/oracle/updater_test.go +++ b/oracle/updater_test.go @@ -14,7 +14,7 @@ func TestFetch(t *testing.T) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { http.ServeFile(w, r, "testdata/com.oracle.elsa-2018.xml") })) - u, err := NewUpdater(-1, WithURL(srv.URL, "")) + u, err := NewUpdater(2018, WithURL(srv.URL, "")) if err != nil { t.Fatal(err) } diff --git a/oracle/updaterset.go b/oracle/updaterset.go deleted file mode 100644 index bac242c60..000000000 --- a/oracle/updaterset.go +++ /dev/null @@ -1,24 +0,0 @@ -package oracle - -import ( - "context" - "fmt" - "time" - - "github.com/quay/claircore/libvuln/driver" -) - -func UpdaterSet(_ context.Context) (driver.UpdaterSet, error) { - us := driver.NewUpdaterSet() - for year, lim := 2007, time.Now().Year(); year <= lim; year++ { - u, err := NewUpdater(year) - if err != nil { - return us, fmt.Errorf("unable to create oracle updater: %v", err) - } - err = us.Add(u) - if err != nil { - return us, err - } - } - return us, nil -} diff --git a/test/periodic/updater_test.go b/test/periodic/updater_test.go index f74019644..a47cecb1c 100644 --- a/test/periodic/updater_test.go +++ b/test/periodic/updater_test.go @@ -70,7 +70,12 @@ func TestDebian(t *testing.T) { func TestOracle(t *testing.T) { ctx := test.Logging(t) - set, err := oracle.UpdaterSet(ctx) + fac := new(oracle.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..da76667dc 100644 --- a/updater/defaults/defaults.go +++ b/updater/defaults/defaults.go @@ -59,7 +59,7 @@ func inner(ctx context.Context) error { updater.Register("osv", new(osv.Factory)) updater.Register("rhel-vex", new(vex.Factory)) updater.Register("aws", driver.UpdaterSetFactoryFunc(aws.UpdaterSet)) - updater.Register("oracle", driver.UpdaterSetFactoryFunc(oracle.UpdaterSet)) + updater.Register("oracle", new(oracle.Factory)) updater.Register("photon", driver.UpdaterSetFactoryFunc(photon.UpdaterSet)) updater.Register("suse", new(suse.Factory))