diff --git a/cmd/grype/cli/commands/db_search_pkg.go b/cmd/grype/cli/commands/db_search_pkg.go index fc2f5c9314a..56805c1aa01 100644 --- a/cmd/grype/cli/commands/db_search_pkg.go +++ b/cmd/grype/cli/commands/db_search_pkg.go @@ -54,10 +54,6 @@ func DBSearchPackages(app clio.Application) *cobra.Command { return nil }, RunE: func(_ *cobra.Command, _ []string) (err error) { - if len(opts.Vulnerability.VulnerabilityIDs) == 0 { - // TODO: we can relax this over time, but for now make it required - return fmt.Errorf("must specify at least one vulnerability ID") - } return runDBSearchPackages(*opts) }, }, opts) @@ -86,6 +82,10 @@ func newDBSearchPackages(opts dbSearchPackageOptions) error { return fmt.Errorf("unable to get providers: %w", err) } + if err := validateProvidersFilter(reader, opts.Vulnerability.Providers); err != nil { + return err + } + rows, err := dbsearch.AffectedPackages(reader, dbsearch.AffectedPackagesOptions{ Vulnerability: opts.Vulnerability.Specs, Package: opts.Package.PkgSpecs, diff --git a/cmd/grype/cli/commands/db_search_vuln.go b/cmd/grype/cli/commands/db_search_vuln.go index c0f6bf22a1c..ec1da07174f 100644 --- a/cmd/grype/cli/commands/db_search_vuln.go +++ b/cmd/grype/cli/commands/db_search_vuln.go @@ -5,14 +5,18 @@ import ( "errors" "fmt" "io" + "sort" "strings" + "github.com/hashicorp/go-multierror" "github.com/olekukonko/tablewriter" + "github.com/scylladb/go-set/strset" "github.com/spf13/cobra" "github.com/anchore/clio" "github.com/anchore/grype/cmd/grype/cli/commands/internal/dbsearch" "github.com/anchore/grype/cmd/grype/cli/options" + v6 "github.com/anchore/grype/grype/db/v6" "github.com/anchore/grype/grype/db/v6/distribution" "github.com/anchore/grype/grype/db/v6/installation" "github.com/anchore/grype/internal/bus" @@ -80,6 +84,10 @@ func runNewDBSearchVulnerabilities(opts dbSearchVulnerabilityOptions) error { return fmt.Errorf("unable to get providers: %w", err) } + if err := validateProvidersFilter(reader, opts.Vulnerability.Providers); err != nil { + return err + } + rows, err := dbsearch.Vulnerabilities(reader, opts.Vulnerability.Specs) if err != nil { return err @@ -95,6 +103,32 @@ func runNewDBSearchVulnerabilities(opts dbSearchVulnerabilityOptions) error { return err } +func validateProvidersFilter(reader v6.Reader, providers []string) error { + if len(providers) == 0 { + return nil + } + availableProviders, err := reader.AllProviders() + if err != nil { + return fmt.Errorf("unable to get providers: %w", err) + } + activeProviders := strset.New() + for _, p := range availableProviders { + activeProviders.Add(p.ID) + } + + provSet := strset.New(providers...) + + diff := strset.Difference(provSet, activeProviders) + diffList := diff.List() + sort.Strings(diffList) + var errs error + for _, p := range diffList { + errs = multierror.Append(errs, fmt.Errorf("provider not found: %q", p)) + } + + return errs +} + func presentDBSearchVulnerabilities(outputFormat string, structuredRows []dbsearch.VulnerabilityRow, output io.Writer) error { if len(structuredRows) == 0 { // TODO: show a message that no results were found? @@ -108,7 +142,7 @@ func presentDBSearchVulnerabilities(outputFormat string, structuredRows []dbsear table := tablewriter.NewWriter(output) commonTableWriterOptions(table) - table.SetHeader([]string{"ID", "Provider", "Severity"}) + table.SetHeader([]string{"ID", "Provider", "Published", "Severity", "Reference"}) table.AppendBulk(rows) table.Render() case jsonOutputFormat: @@ -130,15 +164,29 @@ func renderDBSearchVulnerabilitiesTableRows(structuredRows []dbsearch.Vulnerabil // get the first severity value (which is ranked highest) var sev string if len(rr.Severities) > 0 { - s := rr.Severities[0] - var source string - if s.Source != "" { - source = fmt.Sprintf(" from %s", s.Source) + sev = fmt.Sprintf("%s", rr.Severities[0].Value) + } + + prov := rr.Provider + if len(rr.OperatingSystems) > 0 { + var versions []string + for _, os := range rr.OperatingSystems { + versions = append(versions, os.Version) } - sev = fmt.Sprintf("%s%s", s.Value, source) + prov = fmt.Sprintf("%s (%s)", rr.Provider, strings.Join(versions, ", ")) + } + + var published string + if rr.PublishedDate != nil && !rr.PublishedDate.IsZero() { + published = rr.PublishedDate.Format("2006-01-02") + } + + var ref string + if len(rr.References) > 0 { + ref = rr.References[0].URL } - rows = append(rows, []string{rr.ID, rr.Provider, sev}) + rows = append(rows, []string{rr.ID, prov, published, sev, ref}) } return rows } diff --git a/cmd/grype/cli/commands/internal/dbsearch/affected_packages.go b/cmd/grype/cli/commands/internal/dbsearch/affected_packages.go index 1b229001606..78a091f63a7 100644 --- a/cmd/grype/cli/commands/internal/dbsearch/affected_packages.go +++ b/cmd/grype/cli/commands/internal/dbsearch/affected_packages.go @@ -10,7 +10,7 @@ import ( ) type AffectedPackageTableRow struct { - Vulnerability VulnerabilityRow `json:"vulnerability"` + Vulnerability VulnerabilityInfo `json:"vulnerability"` OS *OS `json:"os,omitempty"` Package *Package `json:"package,omitempty"` CPE *v6.Cpe `json:"cpe,omitempty"` @@ -23,14 +23,12 @@ func (r AffectedPackageTableRow) MarshalJSON() ([]byte, error) { c = r.CPE.String() } return json.Marshal(&struct { - Vulnerability VulnerabilityRow `json:"vulnerability"` - OS *OS `json:"os,omitempty"` + Vulnerability VulnerabilityInfo `json:"vulnerability"` Package *Package `json:"package,omitempty"` CPE string `json:"cpe,omitempty"` Detail v6.AffectedPackageBlob `json:"detail"` }{ Vulnerability: r.Vulnerability, - OS: r.OS, Package: r.Package, CPE: c, Detail: r.Detail, @@ -58,8 +56,9 @@ func newAffectedPackageRows(affectedPkgs []v6.AffectedPackageHandle, affectedCPE log.Errorf("affected package record missing vulnerability: %+v", pkg) continue } + rows = append(rows, AffectedPackageTableRow{ - Vulnerability: newVulnerabilityRow(*pkg.Vulnerability), + Vulnerability: newVulnerabilityInfo(*pkg.Vulnerability), OS: toOS(pkg.OperatingSystem), Package: toPackage(pkg.Package), Detail: detail, @@ -78,7 +77,7 @@ func newAffectedPackageRows(affectedPkgs []v6.AffectedPackageHandle, affectedCPE } rows = append(rows, AffectedPackageTableRow{ - Vulnerability: newVulnerabilityRow(*ac.Vulnerability), + Vulnerability: newVulnerabilityInfo(*ac.Vulnerability), CPE: ac.CPE, Detail: detail, }) @@ -118,7 +117,10 @@ type AffectedPackagesOptions struct { OS v6.OSSpecifiers } -func AffectedPackages(reader v6.Reader, criteria AffectedPackagesOptions) ([]AffectedPackageTableRow, error) { +func AffectedPackages(reader interface { + v6.AffectedPackageStoreReader + v6.AffectedCPEStoreReader +}, criteria AffectedPackagesOptions) ([]AffectedPackageTableRow, error) { var allAffectedPkgs []v6.AffectedPackageHandle var allAffectedCPEs []v6.AffectedCPEHandle diff --git a/cmd/grype/cli/commands/internal/dbsearch/affected_packages_test.go b/cmd/grype/cli/commands/internal/dbsearch/affected_packages_test.go new file mode 100644 index 00000000000..b82242543ee --- /dev/null +++ b/cmd/grype/cli/commands/internal/dbsearch/affected_packages_test.go @@ -0,0 +1,353 @@ +package dbsearch + +import ( + "testing" + "time" + + "github.com/google/go-cmp/cmp" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/require" + + v6 "github.com/anchore/grype/grype/db/v6" + "github.com/anchore/syft/syft/cpe" +) + +func TestAffectedPackageTableRowMarshalJSON(t *testing.T) { + row := AffectedPackageTableRow{ + Vulnerability: VulnerabilityInfo{ + VulnerabilityBlob: v6.VulnerabilityBlob{ + ID: "CVE-1234-5678", + Description: "Test vulnerability", + }, + Provider: "provider1", + Status: "active", + PublishedDate: ptrTime(time.Date(2023, 1, 1, 0, 0, 0, 0, time.UTC)), + ModifiedDate: ptrTime(time.Date(2023, 2, 1, 0, 0, 0, 0, time.UTC)), + }, + Package: &Package{Name: "pkg1", Ecosystem: "ecosystem1"}, + CPE: &v6.Cpe{Part: "a", Vendor: "vendor1", Product: "product1"}, + Detail: v6.AffectedPackageBlob{ + CVEs: []string{"CVE-1234-5678"}, + Qualifiers: &v6.AffectedPackageQualifiers{ + RpmModularity: "modularity", + PlatformCPEs: []string{"platform-cpe-1"}, + }, + Ranges: []v6.AffectedRange{ + { + Version: v6.AffectedVersion{ + Type: "semver", + Constraint: ">=1.0.0, <2.0.0", + }, + Fix: &v6.Fix{ + Version: "1.2.0", + State: "fixed", + }, + }, + }, + }, + } + + data, err := row.MarshalJSON() + require.NoError(t, err) + + expectedJSON := `{ + "vulnerability":{ + "id":"CVE-1234-5678", + "description":"Test vulnerability", + "provider":"provider1", + "status":"active", + "published_date":"2023-01-01T00:00:00Z", + "modified_date":"2023-02-01T00:00:00Z" + }, + "package":{"name":"pkg1","ecosystem":"ecosystem1"}, + "cpe":"cpe:2.3:a:vendor1:product1:*:*:*:*:*:*", + "detail":{ + "cves":["CVE-1234-5678"], + "qualifiers":{ + "rpm_modularity":"modularity", + "platform_cpes":["platform-cpe-1"] + }, + "ranges":[{ + "version":{ + "type":"semver", + "constraint":">=1.0.0, <2.0.0" + }, + "fix":{ + "version":"1.2.0", + "state":"fixed" + } + }] + } + }` + + assert.JSONEq(t, expectedJSON, string(data)) +} + +func TestNewAffectedPackageRows(t *testing.T) { + affectedPkgs := []v6.AffectedPackageHandle{ + { + Package: &v6.Package{Name: "pkg1", Type: "ecosystem1"}, + OperatingSystem: &v6.OperatingSystem{ + Name: "Linux", + MajorVersion: "5", + MinorVersion: "10", + }, + Vulnerability: &v6.VulnerabilityHandle{ + Name: "CVE-1234-5678", + Provider: &v6.Provider{ID: "provider1"}, + Status: "active", + PublishedDate: ptrTime(time.Date(2023, 1, 1, 0, 0, 0, 0, time.UTC)), + ModifiedDate: ptrTime(time.Date(2023, 2, 1, 0, 0, 0, 0, time.UTC)), + BlobValue: &v6.VulnerabilityBlob{Description: "Test vulnerability"}, + }, + BlobValue: &v6.AffectedPackageBlob{ + CVEs: []string{"CVE-1234-5678"}, + Qualifiers: &v6.AffectedPackageQualifiers{ + RpmModularity: "modularity", + PlatformCPEs: []string{"platform-cpe-1"}, + }, + Ranges: []v6.AffectedRange{ + { + Version: v6.AffectedVersion{ + Type: "semver", + Constraint: ">=1.0.0, <2.0.0", + }, + Fix: &v6.Fix{ + Version: "1.2.0", + State: "fixed", + }, + }, + }, + }, + }, + } + + affectedCPEs := []v6.AffectedCPEHandle{ + { + CPE: &v6.Cpe{Part: "a", Vendor: "vendor1", Product: "product1"}, + Vulnerability: &v6.VulnerabilityHandle{ + Name: "CVE-9876-5432", + Provider: &v6.Provider{ID: "provider2"}, + BlobValue: &v6.VulnerabilityBlob{Description: "CPE vulnerability description"}, + }, + BlobValue: &v6.AffectedPackageBlob{ + CVEs: []string{"CVE-9876-5432"}, + Ranges: []v6.AffectedRange{ + { + Version: v6.AffectedVersion{ + Type: "rpm", + Constraint: ">=2.0.0, <3.0.0", + }, + Fix: &v6.Fix{ + Version: "2.5.0", + State: "fixed", + }, + }, + }, + }, + }, + } + + rows := newAffectedPackageRows(affectedPkgs, affectedCPEs) + expected := []AffectedPackageTableRow{ + { + Vulnerability: VulnerabilityInfo{ + VulnerabilityBlob: v6.VulnerabilityBlob{Description: "Test vulnerability"}, + Provider: "provider1", + Status: "active", + PublishedDate: ptrTime(time.Date(2023, 1, 1, 0, 0, 0, 0, time.UTC)), + ModifiedDate: ptrTime(time.Date(2023, 2, 1, 0, 0, 0, 0, time.UTC)), + }, + OS: &OS{Family: "Linux", Version: "5.10"}, + Package: &Package{Name: "pkg1", Ecosystem: "ecosystem1"}, + Detail: v6.AffectedPackageBlob{ + CVEs: []string{"CVE-1234-5678"}, + Qualifiers: &v6.AffectedPackageQualifiers{ + RpmModularity: "modularity", + PlatformCPEs: []string{"platform-cpe-1"}, + }, + Ranges: []v6.AffectedRange{ + { + Version: v6.AffectedVersion{ + Type: "semver", + Constraint: ">=1.0.0, <2.0.0", + }, + Fix: &v6.Fix{ + Version: "1.2.0", + State: "fixed", + }, + }, + }, + }, + }, + { + Vulnerability: VulnerabilityInfo{ + VulnerabilityBlob: v6.VulnerabilityBlob{Description: "CPE vulnerability description"}, + Provider: "provider2", + }, + CPE: &v6.Cpe{Part: "a", Vendor: "vendor1", Product: "product1"}, + Detail: v6.AffectedPackageBlob{ + CVEs: []string{"CVE-9876-5432"}, + Ranges: []v6.AffectedRange{ + { + Version: v6.AffectedVersion{ + Type: "rpm", + Constraint: ">=2.0.0, <3.0.0", + }, + Fix: &v6.Fix{ + Version: "2.5.0", + State: "fixed", + }, + }, + }, + }, + }, + } + + if diff := cmp.Diff(expected, rows); diff != "" { + t.Errorf("unexpected rows (-want +got):\n%s", diff) + } +} + +func TestAffectedPackages(t *testing.T) { + mockReader := new(affectedMockReader) + + mockReader.On("GetAffectedPackages", mock.Anything, mock.Anything).Return([]v6.AffectedPackageHandle{ + { + Package: &v6.Package{Name: "pkg1", Type: "ecosystem1"}, + OperatingSystem: &v6.OperatingSystem{ + Name: "Linux", + MajorVersion: "5", + MinorVersion: "10", + }, + Vulnerability: &v6.VulnerabilityHandle{ + Name: "CVE-1234-5678", + Provider: &v6.Provider{ID: "provider1"}, + Status: "active", + PublishedDate: ptrTime(time.Date(2023, 1, 1, 0, 0, 0, 0, time.UTC)), + ModifiedDate: ptrTime(time.Date(2023, 2, 1, 0, 0, 0, 0, time.UTC)), + BlobValue: &v6.VulnerabilityBlob{Description: "Test vulnerability"}, + }, + BlobValue: &v6.AffectedPackageBlob{ + CVEs: []string{"CVE-1234-5678"}, + Ranges: []v6.AffectedRange{ + { + Version: v6.AffectedVersion{ + Type: "semver", + Constraint: ">=1.0.0, <2.0.0", + }, + Fix: &v6.Fix{ + Version: "1.2.0", + State: "fixed", + }, + }, + }, + }, + }, + }, nil) + + mockReader.On("GetAffectedCPEs", mock.Anything, mock.Anything).Return([]v6.AffectedCPEHandle{ + { + CPE: &v6.Cpe{Part: "a", Vendor: "vendor1", Product: "product1"}, + Vulnerability: &v6.VulnerabilityHandle{ + Name: "CVE-9876-5432", + Provider: &v6.Provider{ID: "provider2"}, + BlobValue: &v6.VulnerabilityBlob{Description: "CPE vulnerability description"}, + }, + BlobValue: &v6.AffectedPackageBlob{ + CVEs: []string{"CVE-9876-5432"}, + Ranges: []v6.AffectedRange{ + { + Version: v6.AffectedVersion{ + Type: "rpm", + Constraint: ">=2.0.0, <3.0.0", + }, + Fix: &v6.Fix{ + Version: "2.5.0", + State: "fixed", + }, + }, + }, + }, + }, + }, nil) + + criteria := AffectedPackagesOptions{ + Vulnerability: v6.VulnerabilitySpecifiers{ + {Name: "CVE-1234-5678"}, + }, + } + + results, err := AffectedPackages(mockReader, criteria) + require.NoError(t, err) + + expected := []AffectedPackageTableRow{ + { + Vulnerability: VulnerabilityInfo{ + VulnerabilityBlob: v6.VulnerabilityBlob{Description: "Test vulnerability"}, + Provider: "provider1", + Status: "active", + PublishedDate: ptrTime(time.Date(2023, 1, 1, 0, 0, 0, 0, time.UTC)), + ModifiedDate: ptrTime(time.Date(2023, 2, 1, 0, 0, 0, 0, time.UTC)), + }, + OS: &OS{Family: "Linux", Version: "5.10"}, + Package: &Package{Name: "pkg1", Ecosystem: "ecosystem1"}, + Detail: v6.AffectedPackageBlob{ + CVEs: []string{"CVE-1234-5678"}, + Ranges: []v6.AffectedRange{ + { + Version: v6.AffectedVersion{ + Type: "semver", + Constraint: ">=1.0.0, <2.0.0", + }, + Fix: &v6.Fix{ + Version: "1.2.0", + State: "fixed", + }, + }, + }, + }, + }, + { + Vulnerability: VulnerabilityInfo{ + VulnerabilityBlob: v6.VulnerabilityBlob{Description: "CPE vulnerability description"}, + Provider: "provider2", + }, + CPE: &v6.Cpe{Part: "a", Vendor: "vendor1", Product: "product1"}, + Detail: v6.AffectedPackageBlob{ + CVEs: []string{"CVE-9876-5432"}, + Ranges: []v6.AffectedRange{ + { + Version: v6.AffectedVersion{ + Type: "rpm", + Constraint: ">=2.0.0, <3.0.0", + }, + Fix: &v6.Fix{ + Version: "2.5.0", + State: "fixed", + }, + }, + }, + }, + }, + } + + if diff := cmp.Diff(expected, results); diff != "" { + t.Errorf("unexpected results (-want +got):\n%s", diff) + } +} + +type affectedMockReader struct { + mock.Mock +} + +func (m *affectedMockReader) GetAffectedPackages(pkgSpec *v6.PackageSpecifier, options *v6.GetAffectedPackageOptions) ([]v6.AffectedPackageHandle, error) { + args := m.Called(pkgSpec, options) + return args.Get(0).([]v6.AffectedPackageHandle), args.Error(1) +} + +func (m *affectedMockReader) GetAffectedCPEs(cpeSpec *cpe.Attributes, options *v6.GetAffectedCPEOptions) ([]v6.AffectedCPEHandle, error) { + args := m.Called(cpeSpec, options) + return args.Get(0).([]v6.AffectedCPEHandle), args.Error(1) +} diff --git a/cmd/grype/cli/commands/internal/dbsearch/vulnerabilities.go b/cmd/grype/cli/commands/internal/dbsearch/vulnerabilities.go index 061515f2793..eb5e719792c 100644 --- a/cmd/grype/cli/commands/internal/dbsearch/vulnerabilities.go +++ b/cmd/grype/cli/commands/internal/dbsearch/vulnerabilities.go @@ -2,33 +2,59 @@ package dbsearch import ( "fmt" + "sort" "time" v6 "github.com/anchore/grype/grype/db/v6" + "github.com/anchore/grype/internal/log" ) -type VulnerabilityRow struct { +type VulnerabilityInfo struct { v6.VulnerabilityBlob `json:",inline"` Provider string `json:"provider"` Status string `json:"status"` - PublishedDate *time.Time `json:"published_date"` - ModifiedDate *time.Time `json:"modified_date"` - WithdrawnDate *time.Time `json:"withdrawn_date"` + PublishedDate *time.Time `json:"published_date,omitempty"` + ModifiedDate *time.Time `json:"modified_date,omitempty"` + WithdrawnDate *time.Time `json:"withdrawn_date,omitempty"` +} +type VulnerabilityRow struct { + VulnerabilityInfo `json:",inline"` + OperatingSystems []OperatingSystem `json:"operating_systems"` + AffectedPackages int `json:"affected_packages"` } -func newVulnerabilityRows(vulns ...v6.VulnerabilityHandle) (rows []VulnerabilityRow) { - for _, vuln := range vulns { - rows = append(rows, newVulnerabilityRow(vuln)) +type OperatingSystem struct { + Name string `json:"name"` + Version string `json:"version"` +} + +type vulnerabilityAffectedPackageJoin struct { + Vulnerability v6.VulnerabilityHandle + OperatingSystems []v6.OperatingSystem + AffectedPackages int +} + +func newVulnerabilityRows(vaps ...vulnerabilityAffectedPackageJoin) (rows []VulnerabilityRow) { + for _, vap := range vaps { + rows = append(rows, newVulnerabilityRow(vap.Vulnerability, vap.AffectedPackages, vap.OperatingSystems)) } return rows } -func newVulnerabilityRow(vuln v6.VulnerabilityHandle) VulnerabilityRow { +func newVulnerabilityRow(vuln v6.VulnerabilityHandle, apCount int, operatingSystems []v6.OperatingSystem) VulnerabilityRow { + return VulnerabilityRow{ + VulnerabilityInfo: newVulnerabilityInfo(vuln), + OperatingSystems: newOperatingSystems(operatingSystems), + AffectedPackages: apCount, + } +} + +func newVulnerabilityInfo(vuln v6.VulnerabilityHandle) VulnerabilityInfo { var blob v6.VulnerabilityBlob if vuln.BlobValue != nil { blob = *vuln.BlobValue } - return VulnerabilityRow{ + return VulnerabilityInfo{ VulnerabilityBlob: blob, Provider: vuln.Provider.ID, Status: vuln.Status, @@ -38,8 +64,21 @@ func newVulnerabilityRow(vuln v6.VulnerabilityHandle) VulnerabilityRow { } } -func Vulnerabilities(reader v6.Reader, vulnSpecs v6.VulnerabilitySpecifiers) ([]VulnerabilityRow, error) { - // TODO: maybe refactor this in terms of search function pattern described in #2132 (in other words, the store should not be directly accessed here) +func newOperatingSystems(oss []v6.OperatingSystem) (os []OperatingSystem) { + for _, o := range oss { + os = append(os, OperatingSystem{ + Name: o.Name, + Version: o.Version(), + }) + } + return os +} + +func Vulnerabilities(reader interface { + v6.VulnerabilityStoreReader + v6.AffectedPackageStoreReader +}, vulnSpecs v6.VulnerabilitySpecifiers) ([]VulnerabilityRow, error) { + log.WithFields("vulnSpecs", len(vulnSpecs)).Debug("fetching vulnerabilities") var vulns []v6.VulnerabilityHandle for i := range vulnSpecs { @@ -54,5 +93,47 @@ func Vulnerabilities(reader v6.Reader, vulnSpecs v6.VulnerabilitySpecifiers) ([] vulns = append(vulns, vs...) } - return newVulnerabilityRows(vulns...), nil + log.WithFields("vulns", len(vulns)).Debug("fetching affected packages") + + // find all affected packages for this vulnerability, so we can gather os information + var pairs []vulnerabilityAffectedPackageJoin + for _, vuln := range vulns { + affected, err := reader.GetAffectedPackages(nil, &v6.GetAffectedPackageOptions{ + PreloadOS: true, + Vulnerabilities: []v6.VulnerabilitySpecifier{ + { + ID: vuln.ID, + }, + }, + }) + if err != nil { + return nil, fmt.Errorf("unable to get affected packages: %w", err) + } + + distros := make(map[v6.ID]v6.OperatingSystem) + for _, a := range affected { + if a.OperatingSystem != nil { + if _, ok := distros[a.OperatingSystem.ID]; !ok { + distros[a.OperatingSystem.ID] = *a.OperatingSystem + } + } + } + + var distrosSlice []v6.OperatingSystem + for _, d := range distros { + distrosSlice = append(distrosSlice, d) + } + + sort.Slice(distrosSlice, func(i, j int) bool { + return distrosSlice[i].ID < distrosSlice[j].ID + }) + + pairs = append(pairs, vulnerabilityAffectedPackageJoin{ + Vulnerability: vuln, + OperatingSystems: distrosSlice, + AffectedPackages: len(affected), + }) + } + + return newVulnerabilityRows(pairs...), nil } diff --git a/cmd/grype/cli/commands/internal/dbsearch/vulnerabilities_test.go b/cmd/grype/cli/commands/internal/dbsearch/vulnerabilities_test.go new file mode 100644 index 00000000000..38a9519f9cd --- /dev/null +++ b/cmd/grype/cli/commands/internal/dbsearch/vulnerabilities_test.go @@ -0,0 +1,120 @@ +package dbsearch + +import ( + "testing" + "time" + + "github.com/google/go-cmp/cmp" + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/require" + + v6 "github.com/anchore/grype/grype/db/v6" +) + +func TestNewVulnerabilityRows(t *testing.T) { + vap := vulnerabilityAffectedPackageJoin{ + Vulnerability: v6.VulnerabilityHandle{ + ID: 1, + Name: "CVE-1234-5678", + Status: "active", + PublishedDate: ptrTime(time.Date(2023, 1, 1, 0, 0, 0, 0, time.UTC)), + ModifiedDate: ptrTime(time.Date(2023, 2, 1, 0, 0, 0, 0, time.UTC)), + WithdrawnDate: nil, + Provider: &v6.Provider{ID: "provider1"}, + BlobValue: &v6.VulnerabilityBlob{Description: "Test description"}, + }, + OperatingSystems: []v6.OperatingSystem{ + {Name: "Linux", MajorVersion: "5", MinorVersion: "10"}, + }, + AffectedPackages: 5, + } + + rows := newVulnerabilityRows(vap) + expected := []VulnerabilityRow{ + { + VulnerabilityInfo: VulnerabilityInfo{ + VulnerabilityBlob: v6.VulnerabilityBlob{Description: "Test description"}, + Provider: "provider1", + Status: "active", + PublishedDate: ptrTime(time.Date(2023, 1, 1, 0, 0, 0, 0, time.UTC)), + ModifiedDate: ptrTime(time.Date(2023, 2, 1, 0, 0, 0, 0, time.UTC)), + WithdrawnDate: nil, + }, + OperatingSystems: []OperatingSystem{ + {Name: "Linux", Version: "5.10"}, + }, + AffectedPackages: 5, + }, + } + + if diff := cmp.Diff(expected, rows); diff != "" { + t.Errorf("unexpected rows (-want +got):\n%s", diff) + } +} + +func TestVulnerabilities(t *testing.T) { + mockReader := new(mockVulnReader) + vulnSpecs := v6.VulnerabilitySpecifiers{ + {Name: "CVE-1234-5678"}, + } + + mockReader.On("GetVulnerabilities", mock.Anything, mock.Anything).Return([]v6.VulnerabilityHandle{ + { + ID: 1, + Name: "CVE-1234-5678", + Status: "active", + PublishedDate: ptrTime(time.Date(2023, 1, 1, 0, 0, 0, 0, time.UTC)), + ModifiedDate: ptrTime(time.Date(2023, 2, 1, 0, 0, 0, 0, time.UTC)), + Provider: &v6.Provider{ID: "provider1"}, + BlobValue: &v6.VulnerabilityBlob{Description: "Test description"}, + }, + }, nil) + + mockReader.On("GetAffectedPackages", mock.Anything, mock.Anything).Return([]v6.AffectedPackageHandle{ + { + OperatingSystem: &v6.OperatingSystem{Name: "Linux", MajorVersion: "5", MinorVersion: "10"}, + }, + }, nil) + + results, err := Vulnerabilities(mockReader, vulnSpecs) + require.NoError(t, err) + + expected := []VulnerabilityRow{ + { + VulnerabilityInfo: VulnerabilityInfo{ + VulnerabilityBlob: v6.VulnerabilityBlob{Description: "Test description"}, + Provider: "provider1", + Status: "active", + PublishedDate: ptrTime(time.Date(2023, 1, 1, 0, 0, 0, 0, time.UTC)), + ModifiedDate: ptrTime(time.Date(2023, 2, 1, 0, 0, 0, 0, time.UTC)), + WithdrawnDate: nil, + }, + OperatingSystems: []OperatingSystem{ + {Name: "Linux", Version: "5.10"}, + }, + AffectedPackages: 1, + }, + } + + if diff := cmp.Diff(expected, results); diff != "" { + t.Errorf("unexpected results (-want +got):\n%s", diff) + } +} + +type mockVulnReader struct { + mock.Mock +} + +func (m *mockVulnReader) GetVulnerabilities(vuln *v6.VulnerabilitySpecifier, config *v6.GetVulnerabilityOptions) ([]v6.VulnerabilityHandle, error) { + args := m.Called(vuln, config) + return args.Get(0).([]v6.VulnerabilityHandle), args.Error(1) +} + +func (m *mockVulnReader) GetAffectedPackages(pkg *v6.PackageSpecifier, config *v6.GetAffectedPackageOptions) ([]v6.AffectedPackageHandle, error) { + args := m.Called(pkg, config) + return args.Get(0).([]v6.AffectedPackageHandle), args.Error(1) +} + +func ptrTime(t time.Time) *time.Time { + return &t +} diff --git a/cmd/grype/cli/options/database_search_vulnerabilities.go b/cmd/grype/cli/options/database_search_vulnerabilities.go index 1ea21a5eca3..f280caec80e 100644 --- a/cmd/grype/cli/options/database_search_vulnerabilities.go +++ b/cmd/grype/cli/options/database_search_vulnerabilities.go @@ -18,6 +18,8 @@ type DBSearchVulnerabilities struct { PublishedAfter string `yaml:"published-after" json:"published-after" mapstructure:"published-after"` ModifiedAfter string `yaml:"modified-after" json:"modified-after" mapstructure:"modified-after"` + Providers []string `yaml:"providers" json:"providers" mapstructure:"providers"` + Specs v6.VulnerabilitySpecifiers `yaml:"-" json:"-" mapstructure:"-"` } @@ -28,6 +30,7 @@ func (c *DBSearchVulnerabilities) AddFlags(flags clio.FlagSet) { flags.BoolVarP(&c.IncludeAliases, "include-aliases", "", "search for vulnerability aliases (for v6+ schemas only)") flags.StringVarP(&c.PublishedAfter, "published-after", "", "only show vulnerabilities originally published after the given date (format: YYYY-MM-DD) (for v6+ schemas only)") flags.StringVarP(&c.ModifiedAfter, "modified-after", "", "only show vulnerabilities originally published or modified since the given date (format: YYYY-MM-DD) (for v6+ schemas only)") + flags.StringArrayVarP(&c.Providers, "provider", "", "only show vulnerabilities from the given provider (for v6+ schemas only)") } func (c *DBSearchVulnerabilities) PostLoad() error { @@ -64,6 +67,7 @@ func (c *DBSearchVulnerabilities) PostLoad() error { PublishedAfter: publishedAfter, ModifiedAfter: modifiedAfter, IncludeAliases: c.IncludeAliases, + Providers: c.Providers, }) } diff --git a/grype/db/v6/affected_package_store.go b/grype/db/v6/affected_package_store.go index 841fec498fd..5bd1cc64794 100644 --- a/grype/db/v6/affected_package_store.go +++ b/grype/db/v6/affected_package_store.go @@ -30,8 +30,8 @@ type GetAffectedPackageOptions struct { PreloadPackageCPEs bool PreloadVulnerability bool PreloadBlob bool - OSs []*OSSpecifier - Vulnerabilities []VulnerabilitySpecifier + OSs OSSpecifiers + Vulnerabilities VulnerabilitySpecifiers } type PackageSpecifiers []*PackageSpecifier @@ -229,7 +229,7 @@ func (s *affectedPackageStore) GetAffectedPackages(pkg *PackageSpecifier, config config = &GetAffectedPackageOptions{} } - log.WithFields("pkg", pkg.String(), "distro", config.OSs).Trace("fetching AffectedPackage record") + log.WithFields("pkg", pkg.String(), "distro", config.OSs, "vulns", config.Vulnerabilities).Trace("fetching AffectedPackage record") query := s.handlePackage(s.db, pkg) @@ -359,11 +359,11 @@ func (s *affectedPackageStore) handleOSOptions(query *gorm.DB, configs []*OSSpec query = query.Joins("JOIN operating_systems ON affected_package_handles.operating_system_id = operating_systems.id") if len(resolvedDistros) > 0 { - orClauses := s.db - for _, o := range resolvedDistros { - orClauses = orClauses.Or("operating_systems.id = ?", o.ID) + ids := make([]ID, len(resolvedDistros)) + for i, d := range resolvedDistros { + ids[i] = d.ID } - query = query.Where(orClauses) + query = query.Where("operating_systems.id IN ?", ids) } return query, nil diff --git a/grype/db/v6/vulnerability_store.go b/grype/db/v6/vulnerability_store.go index 22e0b0c48d1..76f5a172930 100644 --- a/grype/db/v6/vulnerability_store.go +++ b/grype/db/v6/vulnerability_store.go @@ -45,6 +45,9 @@ type VulnerabilitySpecifier struct { // IncludeAliases for the given name or ID in results IncludeAliases bool + + // Providers + Providers []string } func (v *VulnerabilitySpecifier) String() string { @@ -69,6 +72,14 @@ func (v *VulnerabilitySpecifier) String() string { parts = append(parts, fmt.Sprintf("modifiedAfter=%s", v.ModifiedAfter.String())) } + if v.IncludeAliases { + parts = append(parts, "includeAliases=true") + } + + if len(v.Providers) > 0 { + parts = append(parts, fmt.Sprintf("providers=%s", strings.Join(v.Providers, ","))) + } + return fmt.Sprintf("vulnerability(%s)", strings.Join(parts, ", ")) } @@ -265,6 +276,11 @@ func handleVulnerabilityOptions(base, parentQuery *gorm.DB, configs ...Vulnerabi if config.Status != "" { query = query.Where("vulnerability_handles.status = ?", config.Status) } + + if len(config.Providers) > 0 { + query = query.Where("vulnerability_handles.provider_id IN ?", config.Providers) + } + orConditions = orConditions.Or(query) } diff --git a/grype/db/v6/vulnerability_store_test.go b/grype/db/v6/vulnerability_store_test.go index 73cf933bd47..c6267e0fc9f 100644 --- a/grype/db/v6/vulnerability_store_test.go +++ b/grype/db/v6/vulnerability_store_test.go @@ -321,3 +321,75 @@ func testVulnerabilityHandle() VulnerabilityHandle { }, } } + +func TestVulnerabilityStore_GetVulnerabilities_ByProviders(t *testing.T) { + db := setupTestStore(t).db + bw := newBlobStore(db) + s := newVulnerabilityStore(db, bw) + + provider1 := &Provider{ID: "provider1"} + provider2 := &Provider{ID: "provider2"} + + vuln1 := VulnerabilityHandle{Name: "CVE-1234-5678", BlobID: 1, Provider: provider1} + vuln2 := VulnerabilityHandle{Name: "CVE-2345-6789", BlobID: 2, Provider: provider2} + + err := s.AddVulnerabilities(&vuln1, &vuln2) + require.NoError(t, err) + + results, err := s.GetVulnerabilities(&VulnerabilitySpecifier{Providers: []string{"provider1"}}, nil) + require.NoError(t, err) + require.Len(t, results, 1) + assert.Equal(t, vuln1.Name, results[0].Name) + assert.Equal(t, vuln1.Provider.ID, results[0].ProviderID) + + results, err = s.GetVulnerabilities(&VulnerabilitySpecifier{Providers: []string{"provider1", "provider2"}}, nil) + require.NoError(t, err) + require.Len(t, results, 2) + assert.ElementsMatch(t, []string{vuln1.Name, vuln2.Name}, []string{results[0].Name, results[1].Name}) +} + +func TestVulnerabilityStore_GetVulnerabilities_FilterByMultipleFactors(t *testing.T) { + db := setupTestStore(t).db + bw := newBlobStore(db) + s := newVulnerabilityStore(db, bw) + + now := time.Now() + oneDayAgo := now.Add(-24 * time.Hour) + halfDayAgo := now.Add(-12 * time.Hour) + tenDaysAgo := now.Add(-240 * time.Hour) + + provider1 := &Provider{ID: "provider1"} + provider2 := &Provider{ID: "provider2"} + + vuln1 := VulnerabilityHandle{ + Name: "CVE-1234-5678", + BlobID: 1, + Provider: provider1, + PublishedDate: &halfDayAgo, + } + + vuln2 := VulnerabilityHandle{ + Name: "CVE-2345-6789", + BlobID: 2, + Provider: provider2, // filtered out due to provider + PublishedDate: &now, + } + + vuln3 := VulnerabilityHandle{ + Name: "CVE-1234-5678", + BlobID: 3, + Provider: provider1, + PublishedDate: &tenDaysAgo, // filtered out due to date + } + + err := s.AddVulnerabilities(&vuln1, &vuln2, &vuln3) + require.NoError(t, err) + + results, err := s.GetVulnerabilities(&VulnerabilitySpecifier{ + Providers: []string{"provider1"}, // filter by provider... + PublishedAfter: &oneDayAgo, // filter by date published... + }, nil) + require.NoError(t, err) + require.Len(t, results, 1) + assert.Equal(t, vuln1.Name, results[0].Name) +}