diff --git a/go.mod b/go.mod index bd013906eea..7142e253507 100644 --- a/go.mod +++ b/go.mod @@ -5,6 +5,7 @@ go 1.23.0 require ( github.com/CycloneDX/cyclonedx-go v0.9.1 github.com/Masterminds/sprig/v3 v3.3.0 + github.com/OneOfOne/xxhash v1.2.8 github.com/acarl005/stripansi v0.0.0-20180116102854-5a71ef0e047d github.com/adrg/xdg v0.5.3 github.com/anchore/bubbly v0.0.0-20231115134915-def0aba654a9 @@ -59,11 +60,10 @@ require ( github.com/wagoodman/go-progress v0.0.0-20230925121702-07e42b3cdba0 github.com/xi2/xz v0.0.0-20171230120015-48954b6210f8 golang.org/x/exp v0.0.0-20240719175910-8a7402abbf56 + gopkg.in/yaml.v3 v3.0.1 gorm.io/gorm v1.25.12 ) -require gopkg.in/yaml.v3 v3.0.1 - require ( cloud.google.com/go v0.112.1 // indirect cloud.google.com/go/compute v1.24.0 // indirect diff --git a/grype/db/internal/gormadapter/open.go b/grype/db/internal/gormadapter/open.go index 1b99f05ee15..1d68833f645 100644 --- a/grype/db/internal/gormadapter/open.go +++ b/grype/db/internal/gormadapter/open.go @@ -3,6 +3,7 @@ package gormadapter import ( "fmt" "os" + "path/filepath" "github.com/glebarez/sqlite" "gorm.io/gorm" @@ -77,10 +78,8 @@ func Open(path string, options ...Option) (*gorm.DB, error) { cfg := newConfig(path, options) if cfg.shouldTruncate() { - if _, err := os.Stat(path); err == nil { - if err := os.Remove(path); err != nil { - return nil, fmt.Errorf("unable to remove existing DB file: %w", err) - } + if err := prepareWritableDB(path); err != nil { + return nil, err } } @@ -103,3 +102,18 @@ func Open(path string, options ...Option) (*gorm.DB, error) { return dbObj, nil } + +func prepareWritableDB(path string) error { + if _, err := os.Stat(path); err == nil { + if err := os.Remove(path); err != nil { + return fmt.Errorf("unable to remove existing DB file: %w", err) + } + } + + parent := filepath.Dir(path) + if err := os.MkdirAll(parent, 0700); err != nil { + return fmt.Errorf("unable to create parent directory %q for DB file: %w", parent, err) + } + + return nil +} diff --git a/grype/db/internal/gormadapter/open_test.go b/grype/db/internal/gormadapter/open_test.go index fbdd3e34d90..1dfc8408877 100644 --- a/grype/db/internal/gormadapter/open_test.go +++ b/grype/db/internal/gormadapter/open_test.go @@ -1,6 +1,8 @@ package gormadapter import ( + "os" + "path/filepath" "testing" "github.com/stretchr/testify/require" @@ -125,3 +127,41 @@ func TestConfigConnectionString(t *testing.T) { }) } } + +func TestPrepareWritableDB(t *testing.T) { + + t.Run("creates new directory and file when path does not exist", func(t *testing.T) { + tempDir := t.TempDir() + dbPath := filepath.Join(tempDir, "newdir", "test.db") + + err := prepareWritableDB(dbPath) + require.NoError(t, err) + + _, err = os.Stat(filepath.Dir(dbPath)) + require.NoError(t, err) + }) + + t.Run("removes existing file at path", func(t *testing.T) { + tempDir := t.TempDir() + dbPath := filepath.Join(tempDir, "test.db") + + _, err := os.Create(dbPath) + require.NoError(t, err) + + _, err = os.Stat(dbPath) + require.NoError(t, err) + + err = prepareWritableDB(dbPath) + require.NoError(t, err) + + _, err = os.Stat(dbPath) + require.True(t, os.IsNotExist(err)) + }) + + t.Run("returns error if unable to create parent directory", func(t *testing.T) { + invalidDir := filepath.Join("/root", "invalidDir", "test.db") + err := prepareWritableDB(invalidDir) + require.Error(t, err) + require.Contains(t, err.Error(), "unable to create parent directory") + }) +} diff --git a/grype/db/v6/blob_store.go b/grype/db/v6/blob_store.go new file mode 100644 index 00000000000..b62d3f098d5 --- /dev/null +++ b/grype/db/v6/blob_store.go @@ -0,0 +1,112 @@ +package v6 + +import ( + "encoding/json" + "errors" + "fmt" + "strings" + + "gorm.io/gorm" + + "github.com/anchore/grype/internal/log" +) + +type blobable interface { + getBlobValue() any + setBlobID(int64) +} + +type blobStore struct { + db *gorm.DB +} + +func newBlobStore(db *gorm.DB) *blobStore { + return &blobStore{ + db: db, + } +} + +func (s *blobStore) addBlobable(bs ...blobable) error { + for i := range bs { + b := bs[i] + v := b.getBlobValue() + if v == nil { + continue + } + bl := newBlob(v) + + if err := s.addBlobs(bl); err != nil { + return err + } + + b.setBlobID(bl.ID) + } + return nil +} + +func (s *blobStore) addBlobs(blobs ...*Blob) error { + for i := range blobs { + v := blobs[i] + digest := v.computeDigest() + + var blobDigest BlobDigest + err := s.db.Where("id = ?", digest).First(&blobDigest).Error + if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) { + return fmt.Errorf("failed to get blob digest: %w", err) + } + + if blobDigest.BlobID != 0 { + v.ID = blobDigest.BlobID + continue + } + + if err := s.db.Create(v).Error; err != nil { + return fmt.Errorf("failed to create blob: %w", err) + } + + blobDigest = BlobDigest{ + ID: digest, + BlobID: v.ID, + } + if err := s.db.Create(blobDigest).Error; err != nil { + return fmt.Errorf("failed to create blob digest: %w", err) + } + } + return nil +} + +func (s *blobStore) getBlobValue(id int64) (string, error) { + var blob Blob + if err := s.db.First(&blob, id).Error; err != nil { + return "", err + } + return blob.Value, nil +} + +func (s *blobStore) Close() error { + var count int64 + if err := s.db.Model(&Blob{}).Count(&count).Error; err != nil { + return fmt.Errorf("failed to count blobs: %w", err) + } + + log.WithFields("records", count).Trace("finalizing blobs") + + if err := s.db.Exec("DROP TABLE blob_digests").Error; err != nil { + return fmt.Errorf("failed to drop blob digests: %w", err) + } + return nil +} + +func newBlob(obj any) *Blob { + sb := strings.Builder{} + enc := json.NewEncoder(&sb) + enc.SetEscapeHTML(false) + + if err := enc.Encode(obj); err != nil { + panic("could not marshal object to json") + } + + return &Blob{ + Value: sb.String(), + } +} diff --git a/grype/db/v6/blob_store_test.go b/grype/db/v6/blob_store_test.go new file mode 100644 index 00000000000..2a51075e428 --- /dev/null +++ b/grype/db/v6/blob_store_test.go @@ -0,0 +1,61 @@ +package v6 + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestBlobWriter_AddBlobs(t *testing.T) { + db := setupTestDB(t) + writer := newBlobStore(db) + + obj1 := map[string]string{"key": "value1"} + obj2 := map[string]string{"key": "value2"} + + blob1 := newBlob(obj1) + blob2 := newBlob(obj2) + blob3 := newBlob(obj1) // same as blob1 + + err := writer.addBlobs(blob1, blob2, blob3) + require.NoError(t, err) + + require.NotZero(t, blob1.ID) + require.Equal(t, blob1.ID, blob3.ID) // blob3 should have the same ID as blob1 (natural deduplication) + + var result1 Blob + require.NoError(t, db.Where("id = ?", blob1.ID).First(&result1).Error) + assert.Equal(t, blob1.Value, result1.Value) + + var result2 Blob + require.NoError(t, db.Where("id = ?", blob2.ID).First(&result2).Error) + assert.Equal(t, blob2.Value, result2.Value) +} + +func TestBlobWriter_Close(t *testing.T) { + db := setupTestDB(t) + writer := newBlobStore(db) + + obj := map[string]string{"key": "value"} + blob := newBlob(obj) + require.NoError(t, writer.addBlobs(blob)) + + // ensure the blob digest table is created + var blobDigest BlobDigest + require.NoError(t, db.First(&blobDigest).Error) + require.NotZero(t, blobDigest.ID) + + err := writer.Close() + require.NoError(t, err) + + // ensure the blob digest table is deleted + err = db.First(&blobDigest).Error + require.ErrorContains(t, err, "no such table: blob_digests") +} + +func TestBlob_computeDigest(t *testing.T) { + assert.Equal(t, "xxh64:0e6882304e9adbd5", Blob{Value: "test content"}.computeDigest()) + + assert.Equal(t, "xxh64:ea0c19ae9fbd93b3", Blob{Value: "different content"}.computeDigest()) +} diff --git a/grype/db/v6/blobs.go b/grype/db/v6/blobs.go new file mode 100644 index 00000000000..aef3fb7457f --- /dev/null +++ b/grype/db/v6/blobs.go @@ -0,0 +1,105 @@ +package v6 + +import "time" + +// VulnerabilityStatus is meant to convey the current point in the lifecycle for a vulnerability record. +// This is roughly based on CVE status, NVD status, and vendor-specific status values (see https://nvd.nist.gov/vuln/vulnerability-status) +type VulnerabilityStatus string + +const ( + // VulnerabilityNoStatus is the default status for a vulnerability record + VulnerabilityNoStatus VulnerabilityStatus = "?" + + // VulnerabilityActive means that the information from the vulnerability record is actionable + VulnerabilityActive VulnerabilityStatus = "active" // empty also means active + + // VulnerabilityAnalyzing means that the vulnerability record is being reviewed, it may or may not be actionable + VulnerabilityAnalyzing VulnerabilityStatus = "analyzing" + + // VulnerabilityRejected means that data from the vulnerability record should not be acted upon + VulnerabilityRejected VulnerabilityStatus = "rejected" + + // VulnerabilityDisputed means that the vulnerability record is in contention, it may or may not be actionable + VulnerabilityDisputed VulnerabilityStatus = "disputed" +) + +// SeverityScheme represents how to interpret the string value for a vulnerability severity +type SeverityScheme string + +const ( + // SeveritySchemeCVSSV2 is the CVSS v2 severity scheme + SeveritySchemeCVSSV2 SeverityScheme = "CVSSv2" + + // SeveritySchemeCVSSV3 is the CVSS v3 severity scheme + SeveritySchemeCVSSV3 SeverityScheme = "CVSSv3" + + // SeveritySchemeCVSSV4 is the CVSS v4 severity scheme + SeveritySchemeCVSSV4 SeverityScheme = "CVSSv4" + + // SeveritySchemeHML is a string severity scheme (High, Medium, Low) + SeveritySchemeHML SeverityScheme = "HML" + + // SeveritySchemeCHMLN is a string severity scheme (Critical, High, Medium, Low, Negligible) + SeveritySchemeCHMLN SeverityScheme = "CHMLN" +) + +// VulnerabilityBlob represents the core advisory record for a single known vulnerability from a specific provider. +type VulnerabilityBlob struct { + // ID is the lowercase unique string identifier for the vulnerability relative to the provider + ID string `json:"id"` + + // ProviderName of the Vunnel provider (or sub processor responsible for data records from a single specific source, e.g. "ubuntu") + ProviderName string `json:"provider"` + + // Assigner is a list of names, email, or organizations who submitted the vulnerability + Assigner []string `json:"assigner,omitempty"` + + // Status conveys the actionability of the current record + Status VulnerabilityStatus `json:"status"` + + // Description of the vulnerability as provided by the source + Description string `json:"description"` + + // PublishedDate is the date the vulnerability record was first published + PublishedDate *time.Time `json:"published,omitempty"` + + // ModifiedDate is the date the vulnerability record was last modified + ModifiedDate *time.Time `json:"modified,omitempty"` + + // WithdrawnDate is the date the vulnerability record was withdrawn + WithdrawnDate *time.Time `json:"withdrawn,omitempty"` + + // References are URLs to external resources that provide more information about the vulnerability + References []Reference `json:"refs,omitempty"` + + // Aliases is a list of IDs of the same vulnerability in other databases, in the form of the ID field. This allows one database to claim that its own entry describes the same vulnerability as one or more entries in other databases. + Aliases []string `json:"aliases,omitempty"` + + // Severities is a list of severity indications (quantitative or qualitative) for the vulnerability + Severities []Severity `json:"severities,omitempty"` +} + +// Reference represents a single external URL and string tags to use for organizational purposes +type Reference struct { + // URL is the external resource + URL string `json:"url"` + + // Tags is a free-form organizational field to convey additional information about the reference + Tags []string `json:"tags,omitempty"` +} + +// Severity represents a single string severity record for a vulnerability record +type Severity struct { + // Scheme describes the quantitative method used to determine the Score, such as "CVSS_V3". Alternatively this makes + // claim that Value is qualitative, for example "HML" (High, Medium, Low), CHMLN (critical-high-medium-low-negligible) + Scheme SeverityScheme `json:"scheme"` + + // Value is the severity score (e.g. "7.5", "CVSS:4.0/AV:N/AC:L/AT:N/PR:H/UI:N/VC:L/VI:L/VA:N/SC:N/SI:N/SA:N", or "high" ) + Value string `json:"value"` + + // Source is the name of the source of the severity score (e.g. "nvd@nist.gov" or "security-advisories@github.com") + Source string `json:"source"` + + // Rank is a free-form organizational field to convey priority over other severities + Rank int `json:"rank"` +} diff --git a/grype/db/v6/models.go b/grype/db/v6/models.go index 4f939be0eb1..5d3d7c31555 100644 --- a/grype/db/v6/models.go +++ b/grype/db/v6/models.go @@ -1,17 +1,53 @@ package v6 -import "time" +import ( + "fmt" + "time" + + "github.com/OneOfOne/xxhash" + + "github.com/anchore/grype/internal/log" +) func models() []any { return []any{ + // core data store + &Blob{}, + &BlobDigest{}, // only needed in write case + // non-domain info &DBMetadata{}, // data source info &Provider{}, + + // vulnerability related search tables + &VulnerabilityHandle{}, } } +// core data store ////////////////////////////////////////////////////// + +type Blob struct { + ID int64 `gorm:"column:id;primaryKey"` + Value string `gorm:"column:value;not null"` +} + +func (b Blob) computeDigest() string { + h := xxhash.New64() + if _, err := h.Write([]byte(b.Value)); err != nil { + log.Errorf("unable to hash blob: %v", err) + panic(err) + } + return fmt.Sprintf("xxh64:%x", h.Sum(nil)) +} + +type BlobDigest struct { + ID string `gorm:"column:id;primaryKey"` // this is the digest + BlobID int64 `gorm:"column:blob_id"` + Blob Blob `gorm:"foreignKey:BlobID"` +} + // non-domain info ////////////////////////////////////////////////////// type DBMetadata struct { @@ -42,3 +78,24 @@ type Provider struct { // 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"` } + +// vulnerability related search tables ////////////////////////////////////////////////////// + +// 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"` + + // Name is the unique name for the vulnerability (same as the decoded VulnerabilityBlob.ID) + Name string `gorm:"column:name;not null;index"` + + BlobID int64 `gorm:"column:blob_id;index,unique"` + BlobValue *VulnerabilityBlob `gorm:"-"` +} + +func (v VulnerabilityHandle) getBlobValue() any { + return v.BlobValue +} + +func (v *VulnerabilityHandle) setBlobID(id int64) { + v.BlobID = id +} diff --git a/grype/db/v6/store.go b/grype/db/v6/store.go index 22d26435e8c..c27bf14b639 100644 --- a/grype/db/v6/store.go +++ b/grype/db/v6/store.go @@ -12,13 +12,19 @@ import ( type store struct { *dbMetadataStore *providerStore - db *gorm.DB - config Config - write bool + *vulnerabilityStore + blobStore *blobStore + db *gorm.DB + config Config + write bool } func newStore(cfg Config, write bool) (*store, error) { - db, err := gormadapter.Open(cfg.DBFilePath(), gormadapter.WithTruncate(write)) + var path string + if cfg.DBDirPath != "" { + path = cfg.DBFilePath() + } + db, err := gormadapter.Open(path, gormadapter.WithTruncate(write)) if err != nil { return nil, err } @@ -29,12 +35,15 @@ func newStore(cfg Config, write bool) (*store, error) { } } + bs := newBlobStore(db) return &store{ - dbMetadataStore: newDBMetadataStore(db), - providerStore: newProviderStore(db), - db: db, - config: cfg, - write: write, + dbMetadataStore: newDBMetadataStore(db), + providerStore: newProviderStore(db), + vulnerabilityStore: newVulnerabilityStore(db, bs), + blobStore: bs, + db: db, + config: cfg, + write: write, }, nil } @@ -44,6 +53,10 @@ func (s *store) Close() error { return nil } + if err := s.blobStore.Close(); err != nil { + return fmt.Errorf("failed to finalize blobs: %w", err) + } + err := s.db.Exec("VACUUM").Error if err != nil { return fmt.Errorf("failed to vacuum: %w", err) diff --git a/grype/db/v6/vulnerability_store.go b/grype/db/v6/vulnerability_store.go new file mode 100644 index 00000000000..c9ae73fbe98 --- /dev/null +++ b/grype/db/v6/vulnerability_store.go @@ -0,0 +1,126 @@ +package v6 + +import ( + "encoding/json" + "fmt" + + "gorm.io/gorm" + + "github.com/anchore/grype/internal/log" +) + +type VulnerabilityStoreWriter interface { + AddVulnerabilities(vulns ...*VulnerabilityHandle) error +} + +type VulnerabilityStoreReader interface { + GetVulnerability(id int64, config *GetVulnerabilityOptions) (*VulnerabilityHandle, error) + GetVulnerabilitiesByName(vulnID string, config *GetVulnerabilityOptions) ([]VulnerabilityHandle, error) +} + +type GetVulnerabilityOptions struct { + Preload bool +} + +func DefaultGetVulnerabilityOptions() *GetVulnerabilityOptions { + return &GetVulnerabilityOptions{ + Preload: false, + } +} + +type vulnerabilityStore struct { + db *gorm.DB + blobStore *blobStore +} + +func newVulnerabilityStore(db *gorm.DB, bs *blobStore) *vulnerabilityStore { + return &vulnerabilityStore{ + db: db, + blobStore: bs, + } +} + +func (s *vulnerabilityStore) GetVulnerability(id int64, config *GetVulnerabilityOptions) (*VulnerabilityHandle, error) { + if config == nil { + config = DefaultGetVulnerabilityOptions() + } + log.WithFields("id", id, "preload", config.Preload).Trace("fetching Vulnerability record") + + if id == 0 { + return nil, fmt.Errorf("id must be > 0") + } + + var model VulnerabilityHandle + + result := s.db.Where("id = ?", id).First(&model) + + if result.Error != nil { + return nil, fmt.Errorf("unable to fetch vulnerability record: %w", result.Error) + } + + var err error + if config.Preload { + err = s.attachBlob(&model) + } + + return &model, err +} + +func (s *vulnerabilityStore) GetVulnerabilitiesByName(name string, config *GetVulnerabilityOptions) ([]VulnerabilityHandle, error) { + if config == nil { + config = DefaultGetVulnerabilityOptions() + } + log.WithFields("name", name, "preload", config.Preload).Trace("fetching Vulnerability record") + + var allModels []VulnerabilityHandle + + result := s.db.Where("name = ?", name).Find(&allModels) + + if result.Error != nil { + return nil, fmt.Errorf("unable to fetch vulnerability record: %w", result.Error) + } + + if config.Preload { + for i := range allModels { + err := s.attachBlob(&allModels[i]) + if err != nil { + return nil, fmt.Errorf("unable to attach blob %#v: %w", allModels[i], err) + } + } + } + + return allModels, nil +} + +func (s *vulnerabilityStore) attachBlob(vh *VulnerabilityHandle) error { + var blobValue *VulnerabilityBlob + + rawValue, err := s.blobStore.getBlobValue(vh.BlobID) + if err != nil { + return fmt.Errorf("unable to fetch vulnerability blob value: %w", err) + } + + err = json.Unmarshal([]byte(rawValue), &blobValue) + if err != nil { + return fmt.Errorf("unable to unmarshal vulnerability blob value: %w", err) + } + + vh.BlobValue = blobValue + + return nil +} + +func (s *vulnerabilityStore) AddVulnerabilities(vulnerabilities ...*VulnerabilityHandle) error { + for _, v := range vulnerabilities { + // this adds the blob value to the DB and sets the ID on the vulnerability handle + if err := s.blobStore.addBlobable(v); err != nil { + return fmt.Errorf("unable to add affected blob: %w", err) + } + + // write the vulnerability handle to the DB + if err := s.db.Create(v).Error; err != nil { + return err + } + } + return nil +} diff --git a/grype/db/v6/vulnerability_store_test.go b/grype/db/v6/vulnerability_store_test.go new file mode 100644 index 00000000000..fa5a59d38c8 --- /dev/null +++ b/grype/db/v6/vulnerability_store_test.go @@ -0,0 +1,136 @@ +package v6 + +import ( + "testing" + "time" + + "github.com/google/go-cmp/cmp" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestVulnerabilityStore_AddVulnerabilities(t *testing.T) { + db := setupTestDB(t) + bw := newBlobStore(db) + s := newVulnerabilityStore(db, bw) + + vuln1 := &VulnerabilityHandle{ + Name: "CVE-1234-5678", + BlobValue: &VulnerabilityBlob{ + ID: "CVE-1234-5678", + }, + } + + vuln2 := testVulnerabilityHandle() + + err := s.AddVulnerabilities(vuln1, vuln2) + require.NoError(t, err) + + var result1 VulnerabilityHandle + err = db.Where("name = ?", "CVE-1234-5678").First(&result1).Error + require.NoError(t, err) + assert.Equal(t, vuln1.Name, result1.Name) + assert.Equal(t, vuln1.ID, result1.ID) + assert.Equal(t, vuln1.BlobID, result1.BlobID) + assert.Nil(t, result1.BlobValue) // since we're not preloading any fields on the fetch + + var result2 VulnerabilityHandle + err = db.Where("name = ?", "CVE-8765-4321").First(&result2).Error + require.NoError(t, err) + assert.Equal(t, vuln2.Name, result2.Name) + assert.Equal(t, vuln2.ID, result2.ID) + assert.Equal(t, vuln2.BlobID, result2.BlobID) + assert.Nil(t, result2.BlobValue) // since we're not preloading any fields on the fetch +} + +func TestVulnerabilityStore_GetVulnerability(t *testing.T) { + db := setupTestDB(t) + bw := newBlobStore(db) + s := newVulnerabilityStore(db, bw) + + vuln := testVulnerabilityHandle() + err := s.AddVulnerabilities(vuln) + require.NoError(t, err) + + result, err := s.GetVulnerability(vuln.ID, nil) // don't preload by default + require.NoError(t, err) + assert.Equal(t, vuln.Name, result.Name) + assert.Equal(t, vuln.ID, result.ID) + assert.Equal(t, vuln.BlobID, result.BlobID) + assert.Nil(t, result.BlobValue) // since we're not preloading any fields on the fetch + + result, err = s.GetVulnerability(vuln.ID, &GetVulnerabilityOptions{Preload: true}) + + require.NoError(t, err) + require.NotNil(t, result.BlobValue) + if d := cmp.Diff(vuln, result); d != "" { + t.Errorf("unexpected result (-want +got):\n%s", d) + } +} + +func TestVulnerabilityStore_GetVulnerabilitiesByName(t *testing.T) { + db := setupTestDB(t) + bw := newBlobStore(db) + s := newVulnerabilityStore(db, bw) + + vuln1 := testVulnerabilityHandle() + name := vuln1.Name + vuln2 := &VulnerabilityHandle{Name: name, BlobID: 2} // note: no blob value + err := s.AddVulnerabilities(vuln1, vuln2) + require.NoError(t, err) + + expected := []VulnerabilityHandle{*vuln1, *vuln2} + + results, err := s.GetVulnerabilitiesByName(name, nil) // don't preload by default + require.NoError(t, err) + require.Len(t, results, 2) + for i, result := range results { + assert.Equal(t, expected[i].Name, result.Name) + assert.Equal(t, expected[i].ID, result.ID) + assert.Equal(t, expected[i].BlobID, result.BlobID) + assert.Nil(t, result.BlobValue) // since we're not preloading any fields on the fetch + } + + results, err = s.GetVulnerabilitiesByName(name, &GetVulnerabilityOptions{Preload: true}) + require.NoError(t, err) + require.Len(t, results, 2) + + for i, result := range results { + if d := cmp.Diff(expected[i], result); d != "" { + t.Errorf("unexpected result (-want +got):\n%s", d) + } + } +} + +func testVulnerabilityHandle() *VulnerabilityHandle { + now := time.Now() + + return &VulnerabilityHandle{ + Name: "CVE-8765-4321", + BlobValue: &VulnerabilityBlob{ + ID: "CVE-8765-4321", + ProviderName: "provider!", + Assigner: []string{"assigner!"}, + Status: "status!", + Description: "description!", + PublishedDate: &now, + ModifiedDate: &now, + WithdrawnDate: &now, + References: []Reference{ + { + URL: "url!", + Tags: []string{"tag!"}, + }, + }, + Aliases: []string{"alias!"}, + Severities: []Severity{ + { + Scheme: "scheme!", + Value: "value!", + Source: "source!", + Rank: 10, + }, + }, + }, + } +}