diff --git a/grype/db/v6/affected_cpe_store.go b/grype/db/v6/affected_cpe_store.go new file mode 100644 index 00000000000..7974ba79a7c --- /dev/null +++ b/grype/db/v6/affected_cpe_store.go @@ -0,0 +1,106 @@ +package v6 + +import ( + "encoding/json" + "fmt" + + "gorm.io/gorm" + + "github.com/anchore/grype/internal/log" +) + +type AffectedCPEStoreWriter interface { + AddAffectedCPEs(packages ...*AffectedCPEHandle) error +} + +type AffectedCPEStoreReader interface { + GetCPEsByProduct(packageName string, config *GetAffectedCPEOptions) ([]AffectedCPEHandle, error) +} + +type GetAffectedCPEOptions struct { + PreloadCPE bool + PreloadBlob bool +} + +type affectedCPEStore struct { + db *gorm.DB + blobStore *blobStore +} + +func newAffectedCPEStore(db *gorm.DB, bs *blobStore) *affectedCPEStore { + return &affectedCPEStore{ + db: db, + blobStore: bs, + } +} + +// AddAffectedCPEs adds one or more affected CPEs to the store +func (s *affectedCPEStore) AddAffectedCPEs(packages ...*AffectedCPEHandle) error { + for _, pkg := range packages { + if err := s.blobStore.addBlobable(pkg); err != nil { + return fmt.Errorf("unable to add affected package blob: %w", err) + } + + if err := s.db.Create(pkg).Error; err != nil { + return fmt.Errorf("unable to add affected CPE: %w", err) + } + } + return nil +} + +// GetCPEsByProduct retrieves a single AffectedCPEHandle by product name +func (s *affectedCPEStore) GetCPEsByProduct(packageName string, config *GetAffectedCPEOptions) ([]AffectedCPEHandle, error) { + if config == nil { + config = &GetAffectedCPEOptions{} + } + + log.WithFields("product", packageName).Trace("fetching AffectedCPE record") + + var pkgs []AffectedCPEHandle + query := s.db. + Joins("JOIN cpes ON cpes.id = affected_cpe_handles.cpe_id"). + Where("cpes.product = ?", packageName) + + query = s.handlePreload(query, *config) + + err := query.Find(&pkgs).Error + if err != nil { + return nil, fmt.Errorf("unable to fetch affected package record: %w", err) + } + + if config.PreloadBlob { + for i := range pkgs { + err := s.attachBlob(&pkgs[i]) + if err != nil { + return nil, fmt.Errorf("unable to attach blob %#v: %w", pkgs[i], err) + } + } + } + + return pkgs, nil +} + +func (s *affectedCPEStore) handlePreload(query *gorm.DB, config GetAffectedCPEOptions) *gorm.DB { + if config.PreloadCPE { + query = query.Preload("CPE") + } + + return query +} + +// attachBlob attaches the BlobValue to the AffectedCPEHandle +func (s *affectedCPEStore) attachBlob(cpe *AffectedCPEHandle) error { + var blobValue *AffectedPackageBlob + + rawValue, err := s.blobStore.getBlobValue(cpe.BlobID) + if err != nil { + return fmt.Errorf("unable to fetch blob value for affected CPE: %w", err) + } + + if err := json.Unmarshal([]byte(rawValue), &blobValue); err != nil { + return fmt.Errorf("unable to unmarshal blob value: %w", err) + } + + cpe.BlobValue = blobValue + return nil +} diff --git a/grype/db/v6/affected_cpe_store_test.go b/grype/db/v6/affected_cpe_store_test.go new file mode 100644 index 00000000000..ccb0ec91cd1 --- /dev/null +++ b/grype/db/v6/affected_cpe_store_test.go @@ -0,0 +1,100 @@ +package v6 + +import ( + "testing" + + "github.com/google/go-cmp/cmp" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestAffectedCPEStore_AddAffectedCPEs(t *testing.T) { + db := setupTestDB(t) + bw := newBlobStore(db) + s := newAffectedCPEStore(db, bw) + + cpe1 := &AffectedCPEHandle{ + VulnerabilityID: 1, + CpeID: 1, + CPE: &Cpe{ + Type: "a", + Vendor: "vendor-1", + Product: "product-1", + Edition: "edition-1", + }, + BlobValue: &AffectedPackageBlob{ + CVEs: []string{"CVE-2023-5678"}, + }, + } + + cpe2 := testAffectedCPEHandle() + + err := s.AddAffectedCPEs(cpe1, cpe2) + require.NoError(t, err) + + var result1 AffectedCPEHandle + err = db.Where("cpe_id = ?", 1).First(&result1).Error + require.NoError(t, err) + assert.Equal(t, cpe1.VulnerabilityID, result1.VulnerabilityID) + assert.Equal(t, cpe1.ID, result1.ID) + assert.Equal(t, cpe1.BlobID, result1.BlobID) + assert.Nil(t, result1.BlobValue) // since we're not preloading any fields on the fetch + + var result2 AffectedCPEHandle + err = db.Where("cpe_id = ?", 2).First(&result2).Error + require.NoError(t, err) + assert.Equal(t, cpe2.VulnerabilityID, result2.VulnerabilityID) + assert.Equal(t, cpe2.ID, result2.ID) + assert.Equal(t, cpe2.BlobID, result2.BlobID) + assert.Nil(t, result2.BlobValue) // since we're not preloading any fields on the fetch +} + +func TestAffectedCPEStore_GetCPEsByProduct(t *testing.T) { + db := setupTestDB(t) + bw := newBlobStore(db) + s := newAffectedCPEStore(db, bw) + + cpe := testAffectedCPEHandle() + err := s.AddAffectedCPEs(cpe) + require.NoError(t, err) + + results, err := s.GetCPEsByProduct(cpe.CPE.Product, nil) + require.NoError(t, err) + + expected := []AffectedCPEHandle{*cpe} + require.Len(t, results, len(expected)) + result := results[0] + assert.Equal(t, cpe.CpeID, result.CpeID) + assert.Equal(t, cpe.ID, result.ID) + assert.Equal(t, cpe.BlobID, result.BlobID) + require.Nil(t, result.BlobValue) // since we're not preloading any fields on the fetch + + // fetch again with blob & cpe preloaded + results, err = s.GetCPEsByProduct(cpe.CPE.Product, &GetAffectedCPEOptions{PreloadCPE: true, PreloadBlob: true}) + require.NoError(t, err) + require.Len(t, results, len(expected)) + result = results[0] + assert.NotNil(t, result.BlobValue) + if d := cmp.Diff(*cpe, result); d != "" { + t.Errorf("unexpected result (-want +got):\n%s", d) + } +} + +func testAffectedCPEHandle() *AffectedCPEHandle { + return &AffectedCPEHandle{ + CPE: &Cpe{ + Type: "application", + Vendor: "vendor", + Product: "product", + Edition: "edition", + Language: "language", + SoftwareEdition: "software_edition", + TargetHardware: "target_hardware", + TargetSoftware: "target_software", + Other: "other", + }, + BlobValue: &AffectedPackageBlob{ + CVEs: []string{"CVE-2024-4321"}, + }, + } +} diff --git a/grype/db/v6/affected_package_store.go b/grype/db/v6/affected_package_store.go index e812a16798d..056e95cb66d 100644 --- a/grype/db/v6/affected_package_store.go +++ b/grype/db/v6/affected_package_store.go @@ -12,7 +12,7 @@ import ( var NoDistroSpecified = &DistroSpecifier{} var AnyDistroSpecified *DistroSpecifier -type GetAffectedOptions struct { +type GetAffectedPackageOptions struct { PreloadOS bool PreloadPackage bool PreloadBlob bool @@ -32,7 +32,7 @@ type AffectedPackageStoreWriter interface { } type AffectedPackageStoreReader interface { - GetAffectedPackagesByName(packageName string, config *GetAffectedOptions) ([]AffectedPackageHandle, error) + GetAffectedPackagesByName(packageName string, config *GetAffectedPackageOptions) ([]AffectedPackageHandle, error) } type affectedPackageStore struct { @@ -68,9 +68,9 @@ func (s *affectedPackageStore) AddAffectedPackages(packages ...*AffectedPackageH return nil } -func (s *affectedPackageStore) GetAffectedPackagesByName(packageName string, config *GetAffectedOptions) ([]AffectedPackageHandle, error) { +func (s *affectedPackageStore) GetAffectedPackagesByName(packageName string, config *GetAffectedPackageOptions) ([]AffectedPackageHandle, error) { if config == nil { - config = &GetAffectedOptions{} + config = &GetAffectedPackageOptions{} } log.WithFields("name", packageName, "distro", distroDisplay(config.Distro)).Trace("fetching AffectedPackage record") @@ -82,7 +82,7 @@ func (s *affectedPackageStore) GetAffectedPackagesByName(packageName string, con return s.getNonDistroPackageByName(packageName, *config) } -func (s *affectedPackageStore) getNonDistroPackageByName(packageName string, config GetAffectedOptions) ([]AffectedPackageHandle, error) { +func (s *affectedPackageStore) getNonDistroPackageByName(packageName string, config GetAffectedPackageOptions) ([]AffectedPackageHandle, error) { var pkgs []AffectedPackageHandle query := s.db.Joins("JOIN packages ON affected_package_handles.package_id = packages.id") @@ -90,8 +90,8 @@ func (s *affectedPackageStore) getNonDistroPackageByName(packageName string, con query = query.Where("operating_system_id IS NULL") } - query = handlePacakge(query, packageName, config) - query = handlePreload(query, config) + query = s.handlePacakge(query, packageName, config) + query = s.handlePreload(query, config) err := query.Find(&pkgs).Error @@ -111,14 +111,14 @@ func (s *affectedPackageStore) getNonDistroPackageByName(packageName string, con return pkgs, nil } -func (s *affectedPackageStore) getPackageByNameAndDistro(packageName string, config GetAffectedOptions) ([]AffectedPackageHandle, error) { +func (s *affectedPackageStore) getPackageByNameAndDistro(packageName string, config GetAffectedPackageOptions) ([]AffectedPackageHandle, error) { var pkgs []AffectedPackageHandle query := s.db.Joins("JOIN packages ON affected_package_handles.package_id = packages.id"). Joins("JOIN operating_systems ON affected_package_handles.operating_system_id = operating_systems.id") - query = handlePacakge(query, packageName, config) - query = handleDistro(query, config.Distro) - query = handlePreload(query, config) + query = s.handlePacakge(query, packageName, config) + query = s.handleDistro(query, config.Distro) + query = s.handlePreload(query, config) err := query.Find(&pkgs).Error if err != nil { @@ -137,7 +137,7 @@ func (s *affectedPackageStore) getPackageByNameAndDistro(packageName string, con return pkgs, nil } -func handlePacakge(query *gorm.DB, packageName string, config GetAffectedOptions) *gorm.DB { +func (s *affectedPackageStore) handlePacakge(query *gorm.DB, packageName string, config GetAffectedPackageOptions) *gorm.DB { query = query.Where("packages.name = ?", packageName) if config.PackageType != "" { @@ -146,7 +146,7 @@ func handlePacakge(query *gorm.DB, packageName string, config GetAffectedOptions return query } -func handleDistro(query *gorm.DB, d *DistroSpecifier) *gorm.DB { +func (s *affectedPackageStore) handleDistro(query *gorm.DB, d *DistroSpecifier) *gorm.DB { if d == AnyDistroSpecified { return query } @@ -169,7 +169,7 @@ func handleDistro(query *gorm.DB, d *DistroSpecifier) *gorm.DB { return query } -func handlePreload(query *gorm.DB, config GetAffectedOptions) *gorm.DB { +func (s *affectedPackageStore) handlePreload(query *gorm.DB, config GetAffectedPackageOptions) *gorm.DB { if config.PreloadPackage { query = query.Preload("Package") } diff --git a/grype/db/v6/affected_package_store_test.go b/grype/db/v6/affected_package_store_test.go index b5135ee943c..e25ef9ef08b 100644 --- a/grype/db/v6/affected_package_store_test.go +++ b/grype/db/v6/affected_package_store_test.go @@ -31,7 +31,7 @@ func TestAffectedPackageStore_AddAffectedPackages(t *testing.T) { require.NoError(t, err) assert.Equal(t, pkg1.PackageID, result1.PackageID) assert.Equal(t, pkg1.BlobID, result1.BlobID) - assert.Nil(t, result1.BlobValue) // no preloading on fetch + require.Nil(t, result1.BlobValue) // no preloading on fetch var result2 AffectedPackageHandle err = db.Where("package_id = ?", pkg2.PackageID).First(&result2).Error @@ -55,13 +55,13 @@ func TestAffectedPackageStore_GetAffectedPackagesByName(t *testing.T) { tests := []struct { name string packageName string - options *GetAffectedOptions + options *GetAffectedPackageOptions expected []AffectedPackageHandle }{ { name: "specific distro", packageName: pkg2d1.Package.Name, - options: &GetAffectedOptions{ + options: &GetAffectedPackageOptions{ Distro: &DistroSpecifier{ Name: "ubuntu", MajorVersion: "20", @@ -73,7 +73,7 @@ func TestAffectedPackageStore_GetAffectedPackagesByName(t *testing.T) { { name: "distro major version", packageName: pkg2d1.Package.Name, - options: &GetAffectedOptions{ + options: &GetAffectedPackageOptions{ Distro: &DistroSpecifier{ Name: "ubuntu", MajorVersion: "20", @@ -84,7 +84,7 @@ func TestAffectedPackageStore_GetAffectedPackagesByName(t *testing.T) { { name: "distro codename", packageName: pkg2d1.Package.Name, - options: &GetAffectedOptions{ + options: &GetAffectedPackageOptions{ Distro: &DistroSpecifier{ Name: "ubuntu", Codename: "groovy", @@ -95,7 +95,7 @@ func TestAffectedPackageStore_GetAffectedPackagesByName(t *testing.T) { { name: "no distro", packageName: pkg2.Package.Name, - options: &GetAffectedOptions{ + options: &GetAffectedPackageOptions{ Distro: NoDistroSpecified, }, expected: []AffectedPackageHandle{*pkg2}, @@ -103,7 +103,7 @@ func TestAffectedPackageStore_GetAffectedPackagesByName(t *testing.T) { { name: "any distro", packageName: pkg2d1.Package.Name, - options: &GetAffectedOptions{ + options: &GetAffectedPackageOptions{ Distro: AnyDistroSpecified, }, expected: []AffectedPackageHandle{*pkg2d1, *pkg2, *pkg2d2}, @@ -111,7 +111,7 @@ func TestAffectedPackageStore_GetAffectedPackagesByName(t *testing.T) { { name: "package type", packageName: pkg2.Package.Name, - options: &GetAffectedOptions{ + options: &GetAffectedPackageOptions{ PackageType: "type2", }, expected: []AffectedPackageHandle{*pkg2}, diff --git a/grype/db/v6/db.go b/grype/db/v6/db.go index 08fa13576db..957133dfc5e 100644 --- a/grype/db/v6/db.go +++ b/grype/db/v6/db.go @@ -31,6 +31,7 @@ type Reader interface { ProviderStoreReader VulnerabilityStoreReader AffectedPackageStoreReader + AffectedCPEStoreReader } type Writer interface { @@ -38,6 +39,7 @@ type Writer interface { ProviderStoreWriter VulnerabilityStoreWriter AffectedPackageStoreWriter + AffectedCPEStoreWriter io.Closer } diff --git a/grype/db/v6/models.go b/grype/db/v6/models.go index 2bc40a84276..d13e9567e32 100644 --- a/grype/db/v6/models.go +++ b/grype/db/v6/models.go @@ -29,6 +29,10 @@ func models() []any { &AffectedPackageHandle{}, // join on package, operating system &OperatingSystem{}, &Package{}, + + // CPE related search tables + &AffectedCPEHandle{}, // join on CPE + &Cpe{}, } } @@ -91,7 +95,7 @@ type Provider struct { // VulnerabilityHandle represents the pointer to the core advisory record for a single known vulnerability from a specific provider. type VulnerabilityHandle struct { - ID int64 `gorm:"column:id;primaryKey"` + ID ID `gorm:"column:id;primaryKey"` // Name is the unique name for the vulnerability (same as the decoded VulnerabilityBlob.ID) Name string `gorm:"column:name;not null;index"` @@ -112,8 +116,8 @@ func (v *VulnerabilityHandle) setBlobID(id ID) { // AffectedPackageHandle represents a single package affected by the specified vulnerability. type AffectedPackageHandle struct { - ID int64 `gorm:"column:id;primaryKey"` - VulnerabilityID ID `gorm:"column:vulnerability_id;not null"` + ID ID `gorm:"column:id;primaryKey"` + VulnerabilityID ID `gorm:"column:vulnerability_id;not null"` // Vulnerability *VulnerabilityHandle `gorm:"foreignKey:VulnerabilityID"` OperatingSystemID *ID `gorm:"column:operating_system_id"` @@ -135,13 +139,13 @@ func (v *AffectedPackageHandle) setBlobID(id ID) { } type Package struct { - ID int64 `gorm:"column:id;primaryKey"` + ID ID `gorm:"column:id;primaryKey"` Type string `gorm:"column:type;index:idx_package,unique"` Name string `gorm:"column:name;index:idx_package,unique"` } type OperatingSystem struct { - ID int64 `gorm:"column:id;primaryKey"` + ID ID `gorm:"column:id;primaryKey"` Name string `gorm:"column:name;index:os_idx,unique"` MajorVersion string `gorm:"column:major_version;index:os_idx,unique"` @@ -159,3 +163,56 @@ func (os *OperatingSystem) BeforeCreate(tx *gorm.DB) (err error) { } return nil } + +// CPE related search tables ////////////////////////////////////////////////////// + +// AffectedCPEHandle represents a single CPE affected by the specified vulnerability +type AffectedCPEHandle struct { + ID ID `gorm:"column:id;primaryKey"` + VulnerabilityID ID `gorm:"column:vulnerability_id;not null"` + // Vulnerability *VulnerabilityHandle `gorm:"foreignKey:VulnerabilityID"` + + CpeID ID `gorm:"column:cpe_id"` + CPE *Cpe `gorm:"foreignKey:CpeID"` + + BlobID ID `gorm:"column:blob_id"` + BlobValue *AffectedPackageBlob `gorm:"-"` +} + +func (v AffectedCPEHandle) getBlobValue() any { + return v.BlobValue +} + +func (v *AffectedCPEHandle) setBlobID(id ID) { + v.BlobID = id +} + +type Cpe struct { + // TODO: what about different CPE versions? + ID ID `gorm:"primaryKey"` + + Type string `gorm:"column:type;not null;index:idx_cpe,unique"` + Vendor string `gorm:"column:vendor;index:idx_cpe,unique"` + Product string `gorm:"column:product;not null;index:idx_cpe,unique"` + Edition string `gorm:"column:edition;index:idx_cpe,unique"` + Language string `gorm:"column:language;index:idx_cpe,unique"` + SoftwareEdition string `gorm:"column:software_edition;index:idx_cpe,unique"` + TargetHardware string `gorm:"column:target_hardware;index:idx_cpe,unique"` + TargetSoftware string `gorm:"column:target_software;index:idx_cpe,unique"` + Other string `gorm:"column:other;index:idx_cpe,unique"` +} + +func (c Cpe) String() string { + return fmt.Sprintf("%s:%s:%s:%s:%s:%s:%s:%s:%s", c.Type, c.Vendor, c.Product, c.Edition, c.Language, c.SoftwareEdition, c.TargetHardware, c.TargetSoftware, c.Other) +} + +func (c *Cpe) BeforeCreate(tx *gorm.DB) (err error) { + // if the name, major version, and minor version already exist in the table then we should not insert a new record + var existing Cpe + result := tx.Where("type = ? AND vendor = ? AND product = ? AND edition = ? AND language = ? AND software_edition = ? AND target_hardware = ? AND target_software = ? AND other = ?", c.Type, c.Vendor, c.Product, c.Edition, c.Language, c.SoftwareEdition, c.TargetHardware, c.TargetSoftware, c.Other).First(&existing) + if result.Error == nil { + // if the record already exists, then we should use the existing record + *c = existing + } + return nil +} diff --git a/grype/db/v6/store.go b/grype/db/v6/store.go index 591c6cd55e9..d7066994544 100644 --- a/grype/db/v6/store.go +++ b/grype/db/v6/store.go @@ -14,6 +14,7 @@ type store struct { *providerStore *vulnerabilityStore *affectedPackageStore + *affectedCPEStore blobStore *blobStore db *gorm.DB config Config @@ -42,6 +43,7 @@ func newStore(cfg Config, write bool) (*store, error) { providerStore: newProviderStore(db), vulnerabilityStore: newVulnerabilityStore(db, bs), affectedPackageStore: newAffectedPackageStore(db, bs), + affectedCPEStore: newAffectedCPEStore(db, bs), blobStore: bs, db: db, config: cfg, diff --git a/grype/db/v6/vulnerability_store.go b/grype/db/v6/vulnerability_store.go index 36152000552..fb5638ae8dc 100644 --- a/grype/db/v6/vulnerability_store.go +++ b/grype/db/v6/vulnerability_store.go @@ -14,7 +14,7 @@ type VulnerabilityStoreWriter interface { } type VulnerabilityStoreReader interface { - GetVulnerability(id int64, config *GetVulnerabilityOptions) (*VulnerabilityHandle, error) + GetVulnerability(id ID, config *GetVulnerabilityOptions) (*VulnerabilityHandle, error) GetVulnerabilitiesByName(vulnID string, config *GetVulnerabilityOptions) ([]VulnerabilityHandle, error) } @@ -40,7 +40,7 @@ func newVulnerabilityStore(db *gorm.DB, bs *blobStore) *vulnerabilityStore { } } -func (s *vulnerabilityStore) GetVulnerability(id int64, config *GetVulnerabilityOptions) (*VulnerabilityHandle, error) { +func (s *vulnerabilityStore) GetVulnerability(id ID, config *GetVulnerabilityOptions) (*VulnerabilityHandle, error) { if config == nil { config = DefaultGetVulnerabilityOptions() }