diff --git a/grype/db/v6/db.go b/grype/db/v6/db.go index ca1e3cc8242..b744a231f0d 100644 --- a/grype/db/v6/db.go +++ b/grype/db/v6/db.go @@ -28,10 +28,12 @@ type ReadWriter interface { type Reader interface { DBMetadataStoreReader + ProviderStoreReader } type Writer interface { DBMetadataStoreWriter + ProviderStoreWriter io.Closer } diff --git a/grype/db/v6/models.go b/grype/db/v6/models.go index 098dbc2fceb..4f939be0eb1 100644 --- a/grype/db/v6/models.go +++ b/grype/db/v6/models.go @@ -6,6 +6,9 @@ func models() []any { return []any{ // non-domain info &DBMetadata{}, + + // data source info + &Provider{}, } } @@ -17,3 +20,25 @@ type DBMetadata struct { Revision int `gorm:"column:revision;not null"` Addition int `gorm:"column:addition;not null"` } + +// data source info ////////////////////////////////////////////////////// + +// Provider is the upstream data processor (usually Vunnel) that is responsible for vulnerability records. Each provider +// should be scoped to a specific vulnerability dataset, for instance, the "ubuntu" provider for all records from +// Canonicals' Ubuntu Security Notices (for all Ubuntu distro versions). +type Provider struct { + // Name of the Vunnel provider (or sub processor responsible for data records from a single specific source, e.g. "ubuntu") + ID string `gorm:"column:id;primaryKey"` + + // Version of the Vunnel provider (or sub processor equivalent) + Version string `gorm:"column:version"` + + // Processor is the name of the application that processed the data (e.g. "vunnel") + Processor string `gorm:"column:processor"` + + // DateCaptured is the timestamp which the upstream data was pulled and processed + DateCaptured *time.Time `gorm:"column:date_captured"` + + // InputDigest is a self describing hash (e.g. sha256:123... not 123...) of all data used by the provider to generate the vulnerability records + InputDigest string `gorm:"column:input_digest"` +} diff --git a/grype/db/v6/provider_store.go b/grype/db/v6/provider_store.go new file mode 100644 index 00000000000..4fafc0382ed --- /dev/null +++ b/grype/db/v6/provider_store.go @@ -0,0 +1,66 @@ +package v6 + +import ( + "errors" + "fmt" + + "gorm.io/gorm" + + "github.com/anchore/grype/internal/log" +) + +type ProviderStoreWriter interface { + AddProvider(p *Provider) error +} + +type ProviderStoreReader interface { + GetProvider(name string) (*Provider, error) +} + +type providerStore struct { + db *gorm.DB +} + +func newProviderStore(db *gorm.DB) *providerStore { + return &providerStore{ + db: db, + } +} + +func (s *providerStore) AddProvider(p *Provider) error { + log.WithFields("name", p.ID).Trace("writing provider record") + + var existingProvider Provider + result := s.db.Where("id = ? AND version = ?", p.ID, p.Version).First(&existingProvider) + if result.Error != nil && !errors.Is(result.Error, gorm.ErrRecordNotFound) { + return fmt.Errorf("failed to find provider (name=%q version=%q): %w", p.ID, p.Version, result.Error) + } + + if result.Error == nil { + // overwrite the existing provider if found + existingProvider.Processor = p.Processor + existingProvider.DateCaptured = p.DateCaptured + existingProvider.InputDigest = p.InputDigest + } else { + // create a new provider record if not found + existingProvider = *p + } + + if err := s.db.Save(&existingProvider).Error; err != nil { + return fmt.Errorf("failed to save provider (name=%q version=%q): %w", p.ID, p.Version, err) + } + + return nil +} + +func (s *providerStore) GetProvider(name string) (*Provider, error) { + log.WithFields("name", name).Trace("fetching provider record") + + var provider Provider + result := s.db.Where("id = ?", name).First(&provider) + if result.Error != nil { + return nil, fmt.Errorf("failed to fetch provider (name=%q): %w", name, result.Error) + } + + return &provider, nil +} diff --git a/grype/db/v6/provider_store_test.go b/grype/db/v6/provider_store_test.go new file mode 100644 index 00000000000..05ea2262f34 --- /dev/null +++ b/grype/db/v6/provider_store_test.go @@ -0,0 +1,94 @@ +package v6 + +import ( + "testing" + "time" + + "github.com/google/go-cmp/cmp" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestProviderStore(t *testing.T) { + now := time.Date(2021, 1, 1, 2, 3, 4, 5, time.UTC) + other := time.Date(2022, 2, 3, 4, 5, 6, 7, time.UTC) + tests := []struct { + name string + providers []Provider + wantErr require.ErrorAssertionFunc + }{ + { + name: "add new provider", + providers: []Provider{ + { + ID: "ubuntu", + Version: "1.0", + Processor: "vunnel", + DateCaptured: &now, + InputDigest: "sha256:abcd1234", + }, + }, + }, + { + name: "add existing provider", + providers: []Provider{ + { // original + ID: "ubuntu", + Version: "1.0", + Processor: "vunnel", + DateCaptured: &now, + InputDigest: "sha256:abcd1234", + }, + { // overwrite... + ID: "ubuntu", + Version: "2.0", + Processor: "something-else", + DateCaptured: &other, + InputDigest: "sha256:cdef5678", + }, + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + s := newProviderStore(setupTestDB(t)) + if tt.wantErr == nil { + tt.wantErr = require.NoError + } + for i, p := range tt.providers { + isLast := i == len(tt.providers)-1 + err := s.AddProvider(&p) + if !isLast { + require.NoError(t, err) + continue + } + + tt.wantErr(t, err) + if err != nil { + continue + } + + provider, err := s.GetProvider(p.ID) + tt.wantErr(t, err) + if err != nil { + assert.Nil(t, provider) + return + } + + require.NoError(t, err) + require.NotNil(t, provider) + if d := cmp.Diff(p, *provider); d != "" { + t.Errorf("unexpected provider (-want +got): %s", d) + } + } + }) + } +} + +func TestProviderStore_GetProvider(t *testing.T) { + s := newProviderStore(setupTestDB(t)) + p, err := s.GetProvider("fake") + require.Error(t, err) + assert.Nil(t, p) +} diff --git a/grype/db/v6/store.go b/grype/db/v6/store.go index a95ffe9bf21..22d26435e8c 100644 --- a/grype/db/v6/store.go +++ b/grype/db/v6/store.go @@ -11,6 +11,7 @@ import ( type store struct { *dbMetadataStore + *providerStore db *gorm.DB config Config write bool @@ -30,6 +31,7 @@ func newStore(cfg Config, write bool) (*store, error) { return &store{ dbMetadataStore: newDBMetadataStore(db), + providerStore: newProviderStore(db), db: db, config: cfg, write: write,