diff --git a/grype/db/v6/affected_package_store.go b/grype/db/v6/affected_package_store.go new file mode 100644 index 00000000000..8d214edf481 --- /dev/null +++ b/grype/db/v6/affected_package_store.go @@ -0,0 +1,231 @@ +package v6 + +import ( + "encoding/json" + "fmt" + + "gorm.io/gorm" + + "github.com/anchore/grype/internal/log" +) + +var NoDistroSpecified = &DistroSpecifier{} +var AnyDistroSpecified *DistroSpecifier + +type GetAffectedOptions struct { + PreloadOS bool + PreloadPackage bool + PreloadBlob bool + Distro *DistroSpecifier +} + +type DistroSpecifier struct { + Name string + MajorVersion string + MinorVersion string + Codename string +} + +type AffectedPackageStoreWriter interface { + AddAffectedPackages(packages ...*AffectedPackageHandle) error +} + +type AffectedPackageStoreReader interface { + GetAffectedPackagesByName(packageName string, config *GetAffectedOptions) ([]AffectedPackageHandle, error) +} + +type affectedPackageStore struct { + db *gorm.DB + blobStore *blobStore +} + +func newAffectedPackageStore(db *gorm.DB, bs *blobStore) *affectedPackageStore { + return &affectedPackageStore{ + db: db, + blobStore: bs, + } +} + +func (s *affectedPackageStore) AddAffectedPackages(packages ...*AffectedPackageHandle) error { + for _, v := range packages { + if v.Package != nil { + var existingPackage Package + result := s.db.Where("name = ? AND type = ?", v.Package.Name, v.Package.Type).FirstOrCreate(&existingPackage, v.Package) + if result.Error != nil { + return fmt.Errorf("failed to create package (name=%q type=%q): %w", v.Package.Name, v.Package.Type, result.Error) + } + v.Package = &existingPackage + } + + if err := s.blobStore.addBlobable(v); err != nil { + return fmt.Errorf("unable to add affected blob: %w", err) + } + if err := s.db.Create(v).Error; err != nil { + return err + } + } + return nil +} + +func (s *affectedPackageStore) GetAffectedPackagesByName(packageName string, config *GetAffectedOptions) ([]AffectedPackageHandle, error) { + if config == nil { + config = &GetAffectedOptions{} + } + + log.WithFields("name", packageName, "distro", distroDisplay(config.Distro)).Trace("fetching AffectedPackage record") + + if hasDistroSpecified(config.Distro) { + return s.getPackageByNameAndDistro(packageName, *config) + } + + return s.getNonDistroPackageByName(packageName, *config) +} + +func (s *affectedPackageStore) getNonDistroPackageByName(packageName string, config GetAffectedOptions) ([]AffectedPackageHandle, error) { + var pkgs []AffectedPackageHandle + query := s.db.Joins("JOIN packages ON affected_package_handles.package_id = packages.id"). + Where("packages.name = ?", packageName) + if config.Distro != AnyDistroSpecified { + query = query.Where("operating_system_id IS NULL") + } + + err := s.handlePreload(query, config).Find(&pkgs).Error + + if err != nil { + return nil, fmt.Errorf("unable to fetch non-distro 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 *affectedPackageStore) getPackageByNameAndDistro(packageName string, config GetAffectedOptions) ([]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"). + Where("packages.name = ?", packageName) + + err := s.handleDistroAndPreload(query, config).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 *affectedPackageStore) handleDistroAndPreload(query *gorm.DB, config GetAffectedOptions) *gorm.DB { + query = s.handleDistro(query, config.Distro) + query = s.handlePreload(query, config) + return query +} + +func (s *affectedPackageStore) handleDistro(query *gorm.DB, d *DistroSpecifier) *gorm.DB { + if d == AnyDistroSpecified { + return query + } + + if d.Name != "" { + query = query.Where("operating_systems.name = ?", d.Name) + } + + if d.Codename != "" { + query = query.Where("operating_systems.codename = ?", d.Codename) + } + + if d.MajorVersion != "" { + query = query.Where("operating_systems.major_version = ?", d.MajorVersion) + } + + if d.MinorVersion != "" { + query = query.Where("operating_systems.minor_version = ?", d.MinorVersion) + } + return query +} + +func (s *affectedPackageStore) handlePreload(query *gorm.DB, config GetAffectedOptions) *gorm.DB { + if config.PreloadPackage { + query = query.Preload("Package") + } + + if config.PreloadOS { + query = query.Preload("OperatingSystem") + } + return query +} + +func (s *affectedPackageStore) attachBlob(vh *AffectedPackageHandle) error { + var blobValue *AffectedPackageBlob + + rawValue, err := s.blobStore.getBlobValue(vh.BlobID) + if err != nil { + return fmt.Errorf("unable to fetch affected package blob value: %w", err) + } + + err = json.Unmarshal([]byte(rawValue), &blobValue) + if err != nil { + return fmt.Errorf("unable to unmarshal affected package blob value: %w", err) + } + + vh.BlobValue = blobValue + + return nil +} + +func distroDisplay(d *DistroSpecifier) string { + if d == nil { + return "any" + } + + if *d == *NoDistroSpecified { + return "none" + } + + var version string + if d.MajorVersion != "" { + version = d.MajorVersion + if d.MinorVersion != "" { + version += "." + d.MinorVersion + } + } else { + version = d.Codename + } + + distroDisplayName := d.Name + if version != "" { + distroDisplayName += "@" + version + } + if version == d.MajorVersion && d.Codename != "" { + distroDisplayName += " (" + d.Codename + ")" + } + + return distroDisplayName +} + +func hasDistroSpecified(d *DistroSpecifier) bool { + if d == AnyDistroSpecified { + return false + } + + if *d == *NoDistroSpecified { + return false + } + return true +} diff --git a/grype/db/v6/affected_package_store_test.go b/grype/db/v6/affected_package_store_test.go new file mode 100644 index 00000000000..7cf144fd25d --- /dev/null +++ b/grype/db/v6/affected_package_store_test.go @@ -0,0 +1,343 @@ +package v6 + +import ( + "fmt" + "testing" + + "github.com/google/go-cmp/cmp" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestAffectedPackageStore_AddAffectedPackages(t *testing.T) { + db := setupTestDB(t) + bs := newBlobStore(db) + s := newAffectedPackageStore(db, bs) + + pkg1 := &AffectedPackageHandle{ + Package: &Package{Name: "pkg1", Type: "type1"}, + BlobValue: &AffectedPackageBlob{ + CVEs: []string{"CVE-2023-1234"}, + }, + } + + pkg2 := testDistro1AffectedPackage2Handle() + + err := s.AddAffectedPackages(pkg1, pkg2) + require.NoError(t, err) + + var result1 AffectedPackageHandle + err = db.Where("package_id = ?", pkg1.PackageID).First(&result1).Error + 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 + + var result2 AffectedPackageHandle + err = db.Where("package_id = ?", pkg2.PackageID).First(&result2).Error + require.NoError(t, err) + assert.Equal(t, pkg2.PackageID, result2.PackageID) + assert.Equal(t, pkg2.BlobID, result2.BlobID) + assert.Nil(t, result2.BlobValue) +} + +func TestAffectedPackageStore_GetAffectedPackagesByName(t *testing.T) { + db := setupTestDB(t) + bs := newBlobStore(db) + s := newAffectedPackageStore(db, bs) + + pkg2d1 := testDistro1AffectedPackage2Handle() + pkg2d2 := testDistro2AffectedPackage2Handle() + pkg2 := testNonDistroAffectedPackage2Handle() + err := s.AddAffectedPackages(pkg2d1, pkg2, pkg2d2) + require.NoError(t, err) + + tests := []struct { + name string + packageName string + options *GetAffectedOptions + expected []AffectedPackageHandle + }{ + { + name: "specific distro", + packageName: pkg2d1.Package.Name, + options: &GetAffectedOptions{ + Distro: &DistroSpecifier{ + Name: "ubuntu", + MajorVersion: "20", + MinorVersion: "04", + }, + }, + expected: []AffectedPackageHandle{*pkg2d1}, + }, + { + name: "distro major version", + packageName: pkg2d1.Package.Name, + options: &GetAffectedOptions{ + Distro: &DistroSpecifier{ + Name: "ubuntu", + MajorVersion: "20", + }, + }, + expected: []AffectedPackageHandle{*pkg2d1, *pkg2d2}, + }, + { + name: "distro codename", + packageName: pkg2d1.Package.Name, + options: &GetAffectedOptions{ + Distro: &DistroSpecifier{ + Name: "ubuntu", + Codename: "groovy", + }, + }, + expected: []AffectedPackageHandle{*pkg2d2}, + }, + { + name: "no distro", + packageName: pkg2.Package.Name, + options: &GetAffectedOptions{ + Distro: NoDistroSpecified, + }, + expected: []AffectedPackageHandle{*pkg2}, + }, + { + name: "any distro", + packageName: pkg2d1.Package.Name, + options: &GetAffectedOptions{ + Distro: AnyDistroSpecified, + }, + expected: []AffectedPackageHandle{*pkg2d1, *pkg2, *pkg2d2}, + }, + } + + type preloadConfig struct { + name string + PreloadOS bool + PreloadPackage bool + PreloadBlob bool + prepExpectations func(*testing.T, []AffectedPackageHandle) []AffectedPackageHandle + } + + preloadCases := []preloadConfig{ + { + name: "preload-all", + PreloadOS: true, + PreloadPackage: true, + PreloadBlob: true, + prepExpectations: func(t *testing.T, in []AffectedPackageHandle) []AffectedPackageHandle { + for _, a := range in { + if a.OperatingSystemID != nil { + require.NotNil(t, a.OperatingSystem) + } + require.NotNil(t, a.Package) + require.NotNil(t, a.BlobValue) + } + return in + }, + }, + { + name: "preload-none", + prepExpectations: func(t *testing.T, in []AffectedPackageHandle) []AffectedPackageHandle { + var out []AffectedPackageHandle + for _, v := range in { + v.OperatingSystem = nil + v.Package = nil + v.BlobValue = nil + out = append(out, v) + } + return out + }, + }, + { + name: "preload-os-only", + PreloadOS: true, + prepExpectations: func(t *testing.T, in []AffectedPackageHandle) []AffectedPackageHandle { + var out []AffectedPackageHandle + for _, a := range in { + if a.OperatingSystemID != nil { + require.NotNil(t, a.OperatingSystem) + } + a.Package = nil + a.BlobValue = nil + out = append(out, a) + } + return out + }, + }, + { + name: "preload-package-only", + PreloadPackage: true, + prepExpectations: func(t *testing.T, in []AffectedPackageHandle) []AffectedPackageHandle { + var out []AffectedPackageHandle + for _, a := range in { + require.NotNil(t, a.Package) + a.OperatingSystem = nil + a.BlobValue = nil + out = append(out, a) + } + return out + }, + }, + { + name: "preload-blob-only", + PreloadBlob: true, + prepExpectations: func(t *testing.T, in []AffectedPackageHandle) []AffectedPackageHandle { + var out []AffectedPackageHandle + for _, a := range in { + a.OperatingSystem = nil + a.Package = nil + out = append(out, a) + } + return out + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + + for _, pc := range preloadCases { + t.Run(pc.name, func(t *testing.T) { + opts := tt.options + opts.PreloadOS = pc.PreloadOS + opts.PreloadPackage = pc.PreloadPackage + opts.PreloadBlob = pc.PreloadBlob + expected := tt.expected + if pc.prepExpectations != nil { + expected = pc.prepExpectations(t, expected) + } + result, err := s.GetAffectedPackagesByName(tt.packageName, opts) + require.NoError(t, err) + if d := cmp.Diff(expected, result); d != "" { + t.Errorf(fmt.Sprintf("unexpected result: %s", d)) + } + }) + } + }) + } +} + +func TestDistroDisplay(t *testing.T) { + tests := []struct { + name string + distro *DistroSpecifier + expected string + }{ + { + name: "nil distro", + distro: AnyDistroSpecified, + expected: "any", + }, + { + name: "no distro specified", + distro: NoDistroSpecified, + expected: "none", + }, + { + name: "only name specified", + distro: &DistroSpecifier{ + Name: "ubuntu", + }, + expected: "ubuntu", + }, + { + name: "name and major version specified", + distro: &DistroSpecifier{ + Name: "ubuntu", + MajorVersion: "20", + }, + expected: "ubuntu@20", + }, + { + name: "name, major, and minor version specified", + distro: &DistroSpecifier{ + Name: "ubuntu", + MajorVersion: "20", + MinorVersion: "04", + }, + expected: "ubuntu@20.04", + }, + { + name: "name, major version, and codename specified", + distro: &DistroSpecifier{ + Name: "ubuntu", + MajorVersion: "20", + Codename: "focal", + }, + expected: "ubuntu@20 (focal)", + }, + { + name: "name and codename specified", + distro: &DistroSpecifier{ + Name: "ubuntu", + Codename: "focal", + }, + expected: "ubuntu@focal", + }, + { + name: "name, major version, minor version, and codename specified", + distro: &DistroSpecifier{ + Name: "ubuntu", + MajorVersion: "20", + MinorVersion: "04", + Codename: "focal", + }, + expected: "ubuntu@20.04", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := distroDisplay(tt.distro) + require.Equal(t, tt.expected, result) + }) + } +} + +func testDistro1AffectedPackage2Handle() *AffectedPackageHandle { + return &AffectedPackageHandle{ + Package: &Package{ + Name: "pkg2", + Type: "type2d", + }, + OperatingSystem: &OperatingSystem{ + Name: "ubuntu", + MajorVersion: "20", + MinorVersion: "04", + Codename: "focal", + }, + BlobValue: &AffectedPackageBlob{ + CVEs: []string{"CVE-2023-4567"}, + }, + } +} + +func testDistro2AffectedPackage2Handle() *AffectedPackageHandle { + return &AffectedPackageHandle{ + Package: &Package{ + Name: "pkg2", + Type: "type2d", + }, + OperatingSystem: &OperatingSystem{ + Name: "ubuntu", + MajorVersion: "20", + MinorVersion: "10", + Codename: "groovy", + }, + BlobValue: &AffectedPackageBlob{ + CVEs: []string{"CVE-2023-4567"}, + }, + } +} + +func testNonDistroAffectedPackage2Handle() *AffectedPackageHandle { + return &AffectedPackageHandle{ + Package: &Package{ + Name: "pkg2", + Type: "type2", + }, + BlobValue: &AffectedPackageBlob{ + CVEs: []string{"CVE-2023-4567"}, + }, + } +} diff --git a/grype/db/v6/blobs.go b/grype/db/v6/blobs.go index aef3fb7457f..ed47a4072c7 100644 --- a/grype/db/v6/blobs.go +++ b/grype/db/v6/blobs.go @@ -2,47 +2,6 @@ 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 @@ -103,3 +62,60 @@ type Severity struct { // Rank is a free-form organizational field to convey priority over other severities Rank int `json:"rank"` } + +// AffectedPackageBlob represents a package affected by a vulnerability. +type AffectedPackageBlob struct { + // CVEs is a list of Common Vulnerabilities and Exposures (CVE) identifiers related to this vulnerability. + CVEs []string `json:"cves"` + + // RpmModularity indicates if the package follows RPM modularity for versioning. + RpmModularity string `json:"rpm_modularity,omitempty"` + + // PlatformCPEs lists Common Platform Enumeration (CPE) identifiers for affected platforms. + PlatformCPEs []string `json:"platform_cpes,omitempty"` + + // Ranges specifies the affected version ranges and fixes if available. + Ranges []AffectedRange `json:"ranges,omitempty"` +} + +// AffectedRange defines a specific range of versions affected by a vulnerability. +type AffectedRange struct { + // Version defines the version constraints for affected software. + Version AffectedVersion `json:"version"` + + // Fix provides details on the fix version and its state if available. + Fix *Fix `json:"fix,omitempty"` +} + +// Fix conveys availability of a fix for a vulnerability. +type Fix struct { + // Version is the version number of the fix. + Version string `json:"version"` + + // State represents the status of the fix (e.g., "fixed", "unaffected"). + State FixStatus `json:"state"` + + // Detail provides additional fix information, such as commit details. + Detail *FixDetail `json:"detail,omitempty"` +} + +// FixDetail is additional information about a fix, such as commit details and patch URLs. +type FixDetail struct { + // GitCommit is the identifier for the Git commit associated with the fix. + GitCommit string `json:"git_commit"` + + // Timestamp is the date and time when the fix was committed. + Timestamp *time.Time `json:"timestamp"` + + // References contains URLs or identifiers for additional resources on the fix. + References []Reference `json:"references,omitempty"` +} + +// AffectedVersion defines the versioning format and constraints. +type AffectedVersion struct { + // Type specifies the versioning system used (e.g., "semver", "rpm"). + Type string `json:"type"` + + // Constraint defines the version range constraint for affected versions. + Constraint string `json:"constraint"` +} diff --git a/grype/db/v6/db.go b/grype/db/v6/db.go index cef72701be9..08fa13576db 100644 --- a/grype/db/v6/db.go +++ b/grype/db/v6/db.go @@ -30,12 +30,14 @@ type Reader interface { DBMetadataStoreReader ProviderStoreReader VulnerabilityStoreReader + AffectedPackageStoreReader } type Writer interface { DBMetadataStoreWriter ProviderStoreWriter VulnerabilityStoreWriter + AffectedPackageStoreWriter io.Closer } diff --git a/grype/db/v6/enumerations.go b/grype/db/v6/enumerations.go new file mode 100644 index 00000000000..f735584793c --- /dev/null +++ b/grype/db/v6/enumerations.go @@ -0,0 +1,118 @@ +package v6 + +import "strings" + +// 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 ( + UnknownVulnerabilityStatus 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 ( + UnknownSeverityScheme SeverityScheme = "?" + + // 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" +) + +// FixStatus conveys if the package is affected (or not) and the current availability (or not) of a fix +type FixStatus string + +const ( + UnknownFixStatus FixStatus = "?" + + // FixedStatus affirms the package is affected and a fix is available + FixedStatus FixStatus = "fixed" + + // NotFixedStatus affirms the package is affected and a fix is not available + NotFixedStatus FixStatus = "not-fixed" + + // WontFixStatus affirms the package is affected and a fix will not be provided + WontFixStatus FixStatus = "wont-fix" + + // NotAffectedFixStatus affirms the package is not affected by the vulnerability + NotAffectedFixStatus FixStatus = "not-affected" +) + +func ParseVulnerabilityStatus(s string) VulnerabilityStatus { + switch strings.TrimSpace(strings.ToLower(s)) { + case string(VulnerabilityActive), "": + return VulnerabilityActive + case string(VulnerabilityAnalyzing): + return VulnerabilityAnalyzing + case string(VulnerabilityRejected): + return VulnerabilityRejected + case string(VulnerabilityDisputed): + return VulnerabilityDisputed + default: + return UnknownVulnerabilityStatus + } +} + +func ParseSeverityScheme(s string) SeverityScheme { + switch replaceAny(strings.TrimSpace(strings.ToLower(s)), "", "-", "_", " ") { + case strings.ToLower(string(SeveritySchemeCVSSV2)): + return SeveritySchemeCVSSV2 + case strings.ToLower(string(SeveritySchemeCVSSV3)): + return SeveritySchemeCVSSV3 + case strings.ToLower(string(SeveritySchemeCVSSV4)): + return SeveritySchemeCVSSV4 + case strings.ToLower(string(SeveritySchemeHML)): + return SeveritySchemeHML + case strings.ToLower(string(SeveritySchemeCHMLN)): + return SeveritySchemeCHMLN + default: + return UnknownSeverityScheme + } +} + +func ParseFixStatus(s string) FixStatus { + switch replaceAny(strings.TrimSpace(strings.ToLower(s)), "-", " ", "_") { + case string(FixedStatus): + return FixedStatus + case string(NotFixedStatus): + return NotFixedStatus + case string(WontFixStatus): + return WontFixStatus + case string(NotAffectedFixStatus): + return NotAffectedFixStatus + default: + return UnknownFixStatus + } +} + +func replaceAny(input string, newStr string, searchFor ...string) string { + for _, s := range searchFor { + input = strings.ReplaceAll(input, s, newStr) + } + return input +} diff --git a/grype/db/v6/enumerations_test.go b/grype/db/v6/enumerations_test.go new file mode 100644 index 00000000000..5536282efc1 --- /dev/null +++ b/grype/db/v6/enumerations_test.go @@ -0,0 +1,87 @@ +package v6 + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestParseVulnerabilityStatus(t *testing.T) { + tests := []struct { + name string + input string + expected VulnerabilityStatus + }{ + {"Active status", "active", VulnerabilityActive}, + {"Analyzing status with whitespace", " analyzing ", VulnerabilityAnalyzing}, + {"Rejected status in uppercase", "REJECTED", VulnerabilityRejected}, + {"Disputed status", "disputed", VulnerabilityDisputed}, + {"Unknown status", "unknown", UnknownVulnerabilityStatus}, + {"Empty string as active status", "", VulnerabilityActive}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + assert.Equal(t, tt.expected, ParseVulnerabilityStatus(tt.input)) + }) + } +} + +func TestParseSeverityScheme(t *testing.T) { + tests := []struct { + name string + input string + expected SeverityScheme + }{ + {"CVSSv2 scheme", "CVSS v2", SeveritySchemeCVSSV2}, + {"CVSSv3 scheme", "CVSS-V3", SeveritySchemeCVSSV3}, + {"CVSSv4 scheme", "Cvss_V4", SeveritySchemeCVSSV4}, + {"HML scheme", "H-M-l", SeveritySchemeHML}, + {"CHMLN scheme", "CHmLN", SeveritySchemeCHMLN}, + {"Unknown scheme", "unknown", UnknownSeverityScheme}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + assert.Equal(t, tt.expected, ParseSeverityScheme(tt.input)) + }) + } +} + +func TestParseFixStatus(t *testing.T) { + tests := []struct { + name string + input string + expected FixStatus + }{ + {"Fixed status", "fixed", FixedStatus}, + {"Not fixed status with hyphen", "not-fixed", NotFixedStatus}, + {"Wont fix status in uppercase with underscore", "WONT_FIX", WontFixStatus}, + {"Not affected status with whitespace", " not affected ", NotAffectedFixStatus}, + {"Unknown status", "unknown", UnknownFixStatus}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + assert.Equal(t, tt.expected, ParseFixStatus(tt.input)) + }) + } +} + +func TestReplaceAny(t *testing.T) { + tests := []struct { + name string + input string + newStr string + searchFor []string + expected string + }{ + {"go case", "really not_fixed-i'promise", "-", []string{"'", " ", "_"}, "really-not-fixed-i-promise"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + assert.Equal(t, tt.expected, replaceAny(tt.input, tt.newStr, tt.searchFor...)) + }) + } +} diff --git a/grype/db/v6/models.go b/grype/db/v6/models.go index 5d3d7c31555..bb8a06271a9 100644 --- a/grype/db/v6/models.go +++ b/grype/db/v6/models.go @@ -5,6 +5,7 @@ import ( "time" "github.com/OneOfOne/xxhash" + "gorm.io/gorm" "github.com/anchore/grype/internal/log" ) @@ -23,6 +24,11 @@ func models() []any { // vulnerability related search tables &VulnerabilityHandle{}, + + // package related search tables + &AffectedPackageHandle{}, // join on package, operating system + &OperatingSystem{}, + &Package{}, } } @@ -99,3 +105,55 @@ func (v VulnerabilityHandle) getBlobValue() any { func (v *VulnerabilityHandle) setBlobID(id int64) { v.BlobID = id } + +// package related search tables ////////////////////////////////////////////////////// + +// AffectedPackageHandle represents a single package affected by the specified vulnerability. +type AffectedPackageHandle struct { + ID int64 `gorm:"column:id;primaryKey"` + VulnerabilityID int64 `gorm:"column:vulnerability_id;not null"` + // Vulnerability *VulnerabilityHandle `gorm:"foreignKey:VulnerabilityID"` + + OperatingSystemID *int64 `gorm:"column:operating_system_id"` + OperatingSystem *OperatingSystem `gorm:"foreignKey:OperatingSystemID"` + + PackageID int64 `gorm:"column:package_id"` + Package *Package `gorm:"foreignKey:PackageID"` + + BlobID int64 `gorm:"column:blob_id"` + BlobValue *AffectedPackageBlob `gorm:"-"` +} + +func (v AffectedPackageHandle) getBlobValue() any { + return v.BlobValue +} + +func (v *AffectedPackageHandle) setBlobID(id int64) { + v.BlobID = id +} + +type Package struct { + ID int64 `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"` + + Name string `gorm:"column:name;index:os_idx,unique"` + MajorVersion string `gorm:"column:major_version;index:os_idx,unique"` + MinorVersion string `gorm:"column:minor_version;index:os_idx,unique"` + Codename string `gorm:"column:codename"` +} + +func (os *OperatingSystem) 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 OperatingSystem + result := tx.Where("name = ? AND major_version = ? AND minor_version = ?", os.Name, os.MajorVersion, os.MinorVersion).First(&existing) + if result.Error == nil { + // if the record already exists, then we should use the existing record + *os = existing + } + return nil +} diff --git a/grype/db/v6/store.go b/grype/db/v6/store.go index c27bf14b639..591c6cd55e9 100644 --- a/grype/db/v6/store.go +++ b/grype/db/v6/store.go @@ -13,6 +13,7 @@ type store struct { *dbMetadataStore *providerStore *vulnerabilityStore + *affectedPackageStore blobStore *blobStore db *gorm.DB config Config @@ -37,13 +38,14 @@ func newStore(cfg Config, write bool) (*store, error) { bs := newBlobStore(db) return &store{ - dbMetadataStore: newDBMetadataStore(db), - providerStore: newProviderStore(db), - vulnerabilityStore: newVulnerabilityStore(db, bs), - blobStore: bs, - db: db, - config: cfg, - write: write, + dbMetadataStore: newDBMetadataStore(db), + providerStore: newProviderStore(db), + vulnerabilityStore: newVulnerabilityStore(db, bs), + affectedPackageStore: newAffectedPackageStore(db, bs), + blobStore: bs, + db: db, + config: cfg, + write: write, }, nil } diff --git a/grype/db/v6/vulnerability_store.go b/grype/db/v6/vulnerability_store.go index c9ae73fbe98..36152000552 100644 --- a/grype/db/v6/vulnerability_store.go +++ b/grype/db/v6/vulnerability_store.go @@ -74,10 +74,10 @@ func (s *vulnerabilityStore) GetVulnerabilitiesByName(name string, config *GetVu var allModels []VulnerabilityHandle - result := s.db.Where("name = ?", name).Find(&allModels) + err := s.db.Where("name = ?", name).Find(&allModels).Error - if result.Error != nil { - return nil, fmt.Errorf("unable to fetch vulnerability record: %w", result.Error) + if err != nil { + return nil, fmt.Errorf("unable to fetch vulnerability record: %w", err) } if config.Preload {