From d21c5490e05cad3d94e7ecc2ca6a3909193437c7 Mon Sep 17 00:00:00 2001 From: Shane Dell <32347414+shanedell@users.noreply.github.com> Date: Thu, 15 Aug 2024 14:39:24 -0400 Subject: [PATCH] fix: fail when grype cant check for db update (#1247) Signed-off-by: Shane Dell Signed-off-by: Keith Zantow Co-authored-by: Keith Zantow --- cmd/grype/cli/commands/db.go | 5 +- cmd/grype/cli/options/database.go | 4 + grype/db/curator.go | 7 +- grype/db/curator_test.go | 171 ++++++++++++++++++++++++++++++ 4 files changed, 185 insertions(+), 2 deletions(-) diff --git a/cmd/grype/cli/commands/db.go b/cmd/grype/cli/commands/db.go index aba662ab..ce1ed09b 100644 --- a/cmd/grype/cli/commands/db.go +++ b/cmd/grype/cli/commands/db.go @@ -12,8 +12,11 @@ type DBOptions struct { } func dbOptionsDefault(id clio.Identification) *DBOptions { + dbDefaults := options.DefaultDatabase(id) + // by default, require update check success for db operations which check for updates + dbDefaults.RequireUpdateCheck = true return &DBOptions{ - DB: options.DefaultDatabase(id), + DB: dbDefaults, } } diff --git a/cmd/grype/cli/options/database.go b/cmd/grype/cli/options/database.go index 4975dc72..23e47997 100644 --- a/cmd/grype/cli/options/database.go +++ b/cmd/grype/cli/options/database.go @@ -19,6 +19,7 @@ type Database struct { ValidateByHashOnStart bool `yaml:"validate-by-hash-on-start" json:"validate-by-hash-on-start" mapstructure:"validate-by-hash-on-start"` ValidateAge bool `yaml:"validate-age" json:"validate-age" mapstructure:"validate-age"` MaxAllowedBuiltAge time.Duration `yaml:"max-allowed-built-age" json:"max-allowed-built-age" mapstructure:"max-allowed-built-age"` + RequireUpdateCheck bool `yaml:"require-update-check" json:"require-update-check" mapstructure:"require-update-check"` UpdateAvailableTimeout time.Duration `yaml:"update-available-timeout" json:"update-available-timeout" mapstructure:"update-available-timeout"` UpdateDownloadTimeout time.Duration `yaml:"update-download-timeout" json:"update-download-timeout" mapstructure:"update-download-timeout"` } @@ -41,6 +42,7 @@ func DefaultDatabase(id clio.Identification) Database { ValidateAge: true, // After this period (5 days) the db data is considered stale MaxAllowedBuiltAge: defaultMaxDBAge, + RequireUpdateCheck: false, UpdateAvailableTimeout: defaultUpdateAvailableTimeout, UpdateDownloadTimeout: defaultUpdateDownloadTimeout, } @@ -54,6 +56,7 @@ func (cfg Database) ToCuratorConfig() db.Config { ValidateByHashOnGet: cfg.ValidateByHashOnStart, ValidateAge: cfg.ValidateAge, MaxAllowedBuiltAge: cfg.MaxAllowedBuiltAge, + RequireUpdateCheck: cfg.RequireUpdateCheck, ListingFileTimeout: cfg.UpdateAvailableTimeout, UpdateTimeout: cfg.UpdateDownloadTimeout, } @@ -69,6 +72,7 @@ func (cfg *Database) DescribeFields(descriptions clio.FieldDescriptionSet) { descriptions.Add(&cfg.MaxAllowedBuiltAge, `Max allowed age for vulnerability database, age being the time since it was built Default max age is 120h (or five days)`) + descriptions.Add(&cfg.RequireUpdateCheck, `fail the scan if unable to check for database updates`) descriptions.Add(&cfg.UpdateAvailableTimeout, `Timeout for downloading GRYPE_DB_UPDATE_URL to see if the database needs to be downloaded This file is ~156KiB as of 2024-04-17 so the download should be quick; adjust as needed`) descriptions.Add(&cfg.UpdateDownloadTimeout, `Timeout for downloading actual vulnerability DB diff --git a/grype/db/curator.go b/grype/db/curator.go index 34d2cd92..0c5c1dd0 100644 --- a/grype/db/curator.go +++ b/grype/db/curator.go @@ -37,6 +37,7 @@ type Config struct { ValidateByHashOnGet bool ValidateAge bool MaxAllowedBuiltAge time.Duration + RequireUpdateCheck bool ListingFileTimeout time.Duration UpdateTimeout time.Duration } @@ -52,6 +53,7 @@ type Curator struct { validateByHashOnGet bool validateAge bool maxAllowedBuiltAge time.Duration + requireUpdateCheck bool } func NewCurator(cfg Config) (Curator, error) { @@ -81,6 +83,7 @@ func NewCurator(cfg Config) (Curator, error) { validateByHashOnGet: cfg.ValidateByHashOnGet, validateAge: cfg.ValidateAge, maxAllowedBuiltAge: cfg.MaxAllowedBuiltAge, + requireUpdateCheck: cfg.RequireUpdateCheck, }, nil } @@ -150,7 +153,9 @@ func (c *Curator) Update() (bool, error) { updateAvailable, metadata, updateEntry, err := c.IsUpdateAvailable() if err != nil { - // we want to continue if possible even if we can't check for an update + if c.requireUpdateCheck { + return false, fmt.Errorf("check for vulnerability database update failed: %+v", err) + } log.Warnf("unable to check for vulnerability database update") log.Debugf("check for vulnerability update failed: %+v", err) } diff --git a/grype/db/curator_test.go b/grype/db/curator_test.go index c652b1ef..d1bc203e 100644 --- a/grype/db/curator_test.go +++ b/grype/db/curator_test.go @@ -1,7 +1,13 @@ package db import ( + "archive/tar" "bufio" + "bytes" + "compress/gzip" + "crypto/sha256" + "encoding/hex" + "encoding/json" "errors" "fmt" "io" @@ -374,6 +380,171 @@ func TestCurator_validateStaleness(t *testing.T) { } } +func Test_requireUpdateCheck(t *testing.T) { + toJson := func(listing any) []byte { + listingContents := bytes.Buffer{} + enc := json.NewEncoder(&listingContents) + _ = enc.Encode(listing) + return listingContents.Bytes() + } + checksum := func(b []byte) string { + h := sha256.New() + h.Write(b) + return hex.EncodeToString(h.Sum(nil)) + } + makeTarGz := func(mod time.Time, contents []byte) []byte { + metadata := toJson(MetadataJSON{ + Built: mod.Format(time.RFC3339), + Version: 5, + Checksum: "sha256:" + checksum(contents), + }) + tgz := bytes.Buffer{} + gz := gzip.NewWriter(&tgz) + w := tar.NewWriter(gz) + _ = w.WriteHeader(&tar.Header{ + Name: "metadata.json", + Size: int64(len(metadata)), + Mode: 0600, + }) + _, _ = w.Write(metadata) + _ = w.WriteHeader(&tar.Header{ + Name: "vulnerability.db", + Size: int64(len(contents)), + Mode: 0600, + }) + _, _ = w.Write(contents) + _ = w.Close() + _ = gz.Close() + return tgz.Bytes() + } + + newTime := time.Date(2024, 06, 13, 17, 13, 13, 0, time.UTC) + midTime := time.Date(2022, 06, 13, 17, 13, 13, 0, time.UTC) + oldTime := time.Date(2020, 06, 13, 17, 13, 13, 0, time.UTC) + + newDB := makeTarGz(newTime, []byte("some-good-contents")) + + midMetadata := toJson(MetadataJSON{ + Built: midTime.Format(time.RFC3339), + Version: 5, + Checksum: "sha256:deadbeefcafe", + }) + + var handlerFunc http.HandlerFunc + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + handlerFunc(w, r) + })) + defer srv.Close() + + newDbURI := "/db.tar.gz" + + newListing := toJson(Listing{Available: map[int][]ListingEntry{5: {ListingEntry{ + Built: newTime, + URL: mustUrl(url.Parse(srv.URL + newDbURI)), + Checksum: "sha256:" + checksum(newDB), + }}}}) + + oldListing := toJson(Listing{Available: map[int][]ListingEntry{5: {ListingEntry{ + Built: oldTime, + URL: mustUrl(url.Parse(srv.URL + newDbURI)), + Checksum: "sha256:" + checksum(newDB), + }}}}) + + newListingURI := "/listing.json" + oldListingURI := "/oldlisting.json" + badListingURI := "/badlisting.json" + + handlerFunc = func(response http.ResponseWriter, request *http.Request) { + switch request.RequestURI { + case newListingURI: + response.WriteHeader(http.StatusOK) + _, _ = response.Write(newListing) + case oldListingURI: + response.WriteHeader(http.StatusOK) + _, _ = response.Write(oldListing) + case newDbURI: + response.WriteHeader(http.StatusOK) + _, _ = response.Write(newDB) + default: + http.Error(response, "not found", http.StatusNotFound) + } + } + + tests := []struct { + name string + config Config + dbDir map[string][]byte + wantResult bool + wantErr require.ErrorAssertionFunc + }{ + { + name: "listing with update", + config: Config{ + ListingURL: srv.URL + newListingURI, + RequireUpdateCheck: true, + }, + dbDir: map[string][]byte{ + "5/metadata.json": midMetadata, + }, + wantResult: true, + wantErr: require.NoError, + }, + { + name: "no update", + config: Config{ + ListingURL: srv.URL + oldListingURI, + RequireUpdateCheck: false, + }, + dbDir: map[string][]byte{ + "5/metadata.json": midMetadata, + }, + wantResult: false, + wantErr: require.NoError, + }, + { + name: "update error fail", + config: Config{ + ListingURL: srv.URL + badListingURI, + RequireUpdateCheck: true, + }, + wantResult: false, + wantErr: require.Error, + }, + { + name: "update error continue", + config: Config{ + ListingURL: srv.URL + badListingURI, + RequireUpdateCheck: false, + }, + wantResult: false, + wantErr: require.NoError, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + dbTmpDir := t.TempDir() + tt.config.DBRootDir = dbTmpDir + tt.config.ListingFileTimeout = 1 * time.Minute + tt.config.UpdateTimeout = 1 * time.Minute + for filePath, contents := range tt.dbDir { + fullPath := filepath.Join(dbTmpDir, filepath.FromSlash(filePath)) + err := os.MkdirAll(filepath.Dir(fullPath), 0700|os.ModeDir) + require.NoError(t, err) + err = os.WriteFile(fullPath, contents, 0700) + require.NoError(t, err) + } + c, err := NewCurator(tt.config) + require.NoError(t, err) + + result, err := c.Update() + require.Equal(t, tt.wantResult, result) + tt.wantErr(t, err) + }) + } +} + func TestCuratorTimeoutBehavior(t *testing.T) { failAfter := 10 * time.Second success := make(chan struct{})