diff --git a/cmd/grype/cli/commands/db_search.go b/cmd/grype/cli/commands/db_search.go index 1e1af4492af..944745e2d56 100644 --- a/cmd/grype/cli/commands/db_search.go +++ b/cmd/grype/cli/commands/db_search.go @@ -2,6 +2,7 @@ package commands import ( "encoding/json" + "errors" "fmt" "io" "strings" @@ -11,21 +12,18 @@ import ( "github.com/anchore/clio" "github.com/anchore/grype/grype" - 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/grype/vulnerability" "github.com/anchore/grype/internal/bus" "github.com/anchore/grype/internal/log" ) +var _ clio.FlagAdder = (*dbQueryOptions)(nil) + type dbQueryOptions struct { Output string `yaml:"output" json:"output" mapstructure:"output"` DBOptions `yaml:",inline" mapstructure:",squash"` } -var _ clio.FlagAdder = (*dbQueryOptions)(nil) - func (c *dbQueryOptions) AddFlags(flags clio.FlagSet) { flags.StringVarP(&c.Output, "output", "o", "format to display results (available=[table, json])") } @@ -36,59 +34,41 @@ func DBSearch(app clio.Application) *cobra.Command { DBOptions: *dbOptionsDefault(app.ID()), } - return app.SetupCommand(&cobra.Command{ - Use: "search [vulnerability_id]", - Short: "get information on a vulnerability from the db", - Args: cobra.ExactArgs(1), - RunE: func(_ *cobra.Command, args []string) (err error) { - id := args[0] - return runDBSearch(*opts, id) + cmd := &cobra.Command{ + // this is here to support v5 functionality today but will be removed when v6 is the default DB version + Use: "search ID...", + Short: "get information on vulnerabilities from the db", + //Use: "search", + //Short: "search the DB for vulnerabilities or affected packages", + PreRunE: disableUI(app), + RunE: func(cmd *cobra.Command, args []string) (err error) { + if opts.Experimental.DBv6 { + if len(args) > 0 { + // looks like the user attempted to use the search command as if it's v5 -- let them know about the new commands instead + return errors.New("this command is only supported for schema DB v5, please use `grype db search pkg` or `grype db search vuln` for schema DB v6+") + } + // running without args should only show help, not as a runtime error + return cmd.Usage() + } + + // this is v5, do arg handling here. Why not do this earlier in the struct Args field? When v6 functionality is + // enabled we want this command to show usage and exit, so we need to do this check later in processing (here). + if err := cobra.MinimumNArgs(1)(cmd, args); err != nil { + return err + } + return legacyDBSearchPackages(*opts, args) }, - }, opts) -} - -func runDBSearch(opts dbQueryOptions, vulnerabilityID string) error { - if opts.Experimental.DBv6 { - return newDBSearch(opts, vulnerabilityID) - } - return legacyDBSearch(opts, vulnerabilityID) -} - -func newDBSearch(opts dbQueryOptions, vulnerabilityID string) error { - client, err := distribution.NewClient(opts.DB.ToClientConfig()) - if err != nil { - return fmt.Errorf("unable to create distribution client: %w", err) - } - - c, err := installation.NewCurator(opts.DB.ToCuratorConfig(), client) - if err != nil { - return fmt.Errorf("unable to create curator: %w", err) - } - - reader, err := c.Reader() - if err != nil { - return fmt.Errorf("unable to get providers: %w", err) - } - - vh, err := reader.GetVulnerabilities(&v6.VulnerabilitySpecifier{Name: vulnerabilityID}, &v6.GetVulnerabilityOptions{ - Preload: true, - }) - if err != nil { - return fmt.Errorf("unable to get vulnerability: %w", err) } - if len(vh) == 0 { - return fmt.Errorf("vulnerability doesn't exist in the DB: %s", vulnerabilityID) - } + cmd.AddCommand( + DBSearchPackages(app), + DBSearchVulnerabilities(app), + ) - // TODO: we need to implement the functions that inflate models to the grype vulnerability.Vulnerability struct - panic("not implemented") + return app.SetupCommand(cmd, opts) } -/////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// -// all legacy processing below //////////////////////////////////////////////////////////////////////////////////////// - -func legacyDBSearch(opts dbQueryOptions, vulnerabilityID string) error { +func legacyDBSearchPackages(opts dbQueryOptions, vulnerabilityIDs []string) error { log.Debug("loading DB") str, status, err := grype.LoadVulnerabilityDB(opts.DB.ToLegacyCuratorConfig(), opts.DB.AutoUpdate) err = validateDBLoad(err, status) @@ -97,23 +77,27 @@ func legacyDBSearch(opts dbQueryOptions, vulnerabilityID string) error { } defer log.CloseAndLogError(str, status.Location) - vulnerabilities, err := str.Get(vulnerabilityID, "") - if err != nil { - return err + var vulnerabilities []vulnerability.Vulnerability + for _, vulnerabilityID := range vulnerabilityIDs { + vulns, err := str.Get(vulnerabilityID, "") + if err != nil { + return fmt.Errorf("unable to get vulnerability %q: %w", vulnerabilityID, err) + } + vulnerabilities = append(vulnerabilities, vulns...) } if len(vulnerabilities) == 0 { - return fmt.Errorf("vulnerability doesn't exist in the DB: %s", vulnerabilityID) + return errors.New("no affected packages found") } sb := &strings.Builder{} - err = presentLegacy(opts.Output, vulnerabilities, sb) + err = presentLegacyDBSearchPackages(opts.Output, vulnerabilities, sb) bus.Report(sb.String()) return err } -func presentLegacy(outputFormat string, vulnerabilities []vulnerability.Vulnerability, output io.Writer) error { +func presentLegacyDBSearchPackages(outputFormat string, vulnerabilities []vulnerability.Vulnerability, output io.Writer) error { if vulnerabilities == nil { return nil } @@ -126,22 +110,9 @@ func presentLegacy(outputFormat string, vulnerabilities []vulnerability.Vulnerab } table := tablewriter.NewWriter(output) - columns := []string{"ID", "Package Name", "Namespace", "Version Constraint"} - - table.SetHeader(columns) - table.SetAutoWrapText(false) - table.SetHeaderAlignment(tablewriter.ALIGN_LEFT) - table.SetAlignment(tablewriter.ALIGN_LEFT) - - table.SetHeaderLine(false) - table.SetBorder(false) - table.SetAutoFormatHeaders(true) - table.SetCenterSeparator("") - table.SetColumnSeparator("") - table.SetRowSeparator("") - table.SetTablePadding(" ") - table.SetNoWhiteSpace(true) + commonTableWriterOptions(table) + table.SetHeader([]string{"ID", "Package Name", "Namespace", "Version Constraint"}) table.AppendBulk(rows) table.Render() case jsonOutputFormat: diff --git a/cmd/grype/cli/commands/db_search_pkg.go b/cmd/grype/cli/commands/db_search_pkg.go new file mode 100644 index 00000000000..0f1c6b18b6a --- /dev/null +++ b/cmd/grype/cli/commands/db_search_pkg.go @@ -0,0 +1,156 @@ +package commands + +import ( + "encoding/json" + "errors" + "fmt" + "io" + "strings" + + "github.com/olekukonko/tablewriter" + "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" + "github.com/anchore/grype/grype/db/v6/distribution" + "github.com/anchore/grype/grype/db/v6/installation" + "github.com/anchore/grype/internal/bus" +) + +type dbSearchPackageOptions struct { + Format options.DBSearchFormat `yaml:",inline" mapstructure:",squash"` + Vulnerability options.DBSearchVulnerabilities `yaml:",inline" mapstructure:",squash"` + Package options.DBSearchPackages `yaml:",inline" mapstructure:",squash"` + OS options.DBSearchOSs `yaml:",inline" mapstructure:",squash"` + + DBOptions `yaml:",inline" mapstructure:",squash"` +} + +func DBSearchPackages(app clio.Application) *cobra.Command { + opts := &dbSearchPackageOptions{ + Format: options.DBSearchFormat{ + Output: tableOutputFormat, + Allowable: []string{ + tableOutputFormat, + jsonOutputFormat, + }, + }, + Vulnerability: options.DBSearchVulnerabilities{ + UseVulnIDFlag: true, + }, + DBOptions: *dbOptionsDefault(app.ID()), + } + + return app.SetupCommand(&cobra.Command{ + Use: "pkg PURL|CPE|NAME...", + Aliases: []string{"package", "packages", "pkgs"}, + Short: "Search for packages affected by vulnerabilities within the db (supports DB schema v6+ only)", + Args: func(_ *cobra.Command, args []string) error { + opts.Package.Names = args + return nil + }, + RunE: func(_ *cobra.Command, _ []string) (err error) { + if !opts.Experimental.DBv6 { + return errors.New("this command only supports the v6+ database schemas") + } + return runDBSearchPackages(*opts) + }, + }, opts) +} + +func runDBSearchPackages(opts dbSearchPackageOptions) error { + client, err := distribution.NewClient(opts.DB.ToClientConfig()) + if err != nil { + return fmt.Errorf("unable to create distribution client: %w", err) + } + + curator, err := installation.NewCurator(opts.DB.ToCuratorConfig(), client) + if err != nil { + return fmt.Errorf("unable to create curator: %w", err) + } + + reader, err := curator.Reader() + if err != nil { + 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, + CPE: opts.Package.CPESpecs, + OS: opts.OS.Specs, + }) + if err != nil { + return err + } + + if len(rows) == 0 { + return errors.New("no affected packages found") + } + + sb := &strings.Builder{} + err = presentDBSearchPackages(opts.Format.Output, rows, sb) + bus.Report(sb.String()) + return err +} + +func presentDBSearchPackages(outputFormat string, structuredRows []dbsearch.AffectedPackageTableRow, output io.Writer) error { + if len(structuredRows) == 0 { + // TODO: show a message that no results were found? + return nil + } + + switch outputFormat { + case tableOutputFormat: + rows := renderDBSearchPackagesTableRows(structuredRows) + + table := tablewriter.NewWriter(output) + commonTableWriterOptions(table) + + table.SetHeader([]string{"ID", "Package", "Ecosystem", "Namespace", "Version Constraint"}) + table.AppendBulk(rows) + table.Render() + case jsonOutputFormat: + enc := json.NewEncoder(output) + enc.SetEscapeHTML(false) + enc.SetIndent("", " ") + if err := enc.Encode(structuredRows); err != nil { + return fmt.Errorf("failed to encode diff information: %+v", err) + } + default: + return fmt.Errorf("unsupported output format: %s", outputFormat) + } + return nil +} + +func renderDBSearchPackagesTableRows(structuredRows []dbsearch.AffectedPackageTableRow) [][]string { + var rows [][]string + for _, rr := range structuredRows { + var pkgOrCPE, ecosystem string + if rr.Package != nil { + pkgOrCPE = rr.Package.Name + ecosystem = rr.Package.Ecosystem + } else if rr.CPE != nil { + pkgOrCPE = rr.CPE.String() + ecosystem = rr.CPE.TargetSoftware + } + + namespace := rr.Vulnerability.Provider + if rr.OS != nil { + namespace = fmt.Sprintf("%s:%s", rr.OS.Family, rr.OS.Version) + } + + var ranges []string + for _, ra := range rr.Detail.Ranges { + ranges = append(ranges, ra.Version.Constraint) + } + rangeStr := strings.Join(ranges, " || ") + rows = append(rows, []string{rr.Vulnerability.ID, pkgOrCPE, ecosystem, namespace, rangeStr}) + } + return rows +} diff --git a/cmd/grype/cli/commands/db_search_vuln.go b/cmd/grype/cli/commands/db_search_vuln.go new file mode 100644 index 00000000000..f6c2d9e8a5a --- /dev/null +++ b/cmd/grype/cli/commands/db_search_vuln.go @@ -0,0 +1,221 @@ +package commands + +import ( + "encoding/json" + "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" +) + +type dbSearchVulnerabilityOptions struct { + Format options.DBSearchFormat `yaml:",inline" mapstructure:",squash"` + Vulnerability options.DBSearchVulnerabilities `yaml:",inline" mapstructure:",squash"` + + DBOptions `yaml:",inline" mapstructure:",squash"` +} + +func DBSearchVulnerabilities(app clio.Application) *cobra.Command { + opts := &dbSearchVulnerabilityOptions{ + Format: options.DBSearchFormat{ + Output: tableOutputFormat, + Allowable: []string{ + tableOutputFormat, + jsonOutputFormat, + }, + }, + Vulnerability: options.DBSearchVulnerabilities{ + UseVulnIDFlag: false, // we input this through the args + }, + DBOptions: *dbOptionsDefault(app.ID()), + } + + return app.SetupCommand(&cobra.Command{ + Use: "vuln ID...", + Aliases: []string{"vulnerability", "vulnerabilities", "vulns"}, + Short: "Search for vulnerabilities within the DB (supports DB schema v6+ only)", + Args: func(_ *cobra.Command, args []string) error { + if len(args) == 0 { + return fmt.Errorf("must specify at least one vulnerability ID") + } + opts.Vulnerability.VulnerabilityIDs = args + return nil + }, + RunE: func(_ *cobra.Command, _ []string) (err error) { + if !opts.Experimental.DBv6 { + return errors.New("this command only supports the v6+ database schemas") + } + return runDBSearchVulnerabilities(*opts) + }, + }, opts) +} + +func runDBSearchVulnerabilities(opts dbSearchVulnerabilityOptions) error { + client, err := distribution.NewClient(opts.DB.ToClientConfig()) + if err != nil { + return fmt.Errorf("unable to create distribution client: %w", err) + } + + c, err := installation.NewCurator(opts.DB.ToCuratorConfig(), client) + if err != nil { + return fmt.Errorf("unable to create curator: %w", err) + } + + reader, err := c.Reader() + if err != nil { + 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 + } + + if len(rows) == 0 { + return errors.New("no vulnerabilities found") + } + + sb := &strings.Builder{} + err = presentDBSearchVulnerabilities(opts.Format.Output, rows, sb) + bus.Report(sb.String()) + 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? + return nil + } + + switch outputFormat { + case tableOutputFormat: + rows := renderDBSearchVulnerabilitiesTableRows(structuredRows) + + table := tablewriter.NewWriter(output) + commonTableWriterOptions(table) + + table.SetHeader([]string{"ID", "Provider", "Published", "Severity", "Reference"}) + table.AppendBulk(rows) + table.Render() + case jsonOutputFormat: + enc := json.NewEncoder(output) + enc.SetEscapeHTML(false) + enc.SetIndent("", " ") + if err := enc.Encode(structuredRows); err != nil { + return fmt.Errorf("failed to encode diff information: %+v", err) + } + default: + return fmt.Errorf("unsupported output format: %s", outputFormat) + } + return nil +} + +func renderDBSearchVulnerabilitiesTableRows(structuredRows []dbsearch.VulnerabilityRow) [][]string { + type row struct { + Vuln string + ProviderWithoutVersions string + PublishedDate string + Severity string + Reference string + } + + versionsByRow := make(map[row][]string) + for _, rr := range structuredRows { + // get the first severity value (which is ranked highest) + var sev string + if len(rr.Severities) > 0 { + sev = fmt.Sprintf("%s", rr.Severities[0].Value) + } + + prov := rr.Provider + var versions []string + for _, os := range rr.OperatingSystems { + versions = append(versions, os.Version) + } + + 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 + } + + r := row{ + Vuln: rr.ID, + ProviderWithoutVersions: prov, + PublishedDate: published, + Severity: sev, + Reference: ref, + } + versionsByRow[r] = append(versionsByRow[r], versions...) + } + + var rows [][]string + for r, versions := range versionsByRow { + prov := r.ProviderWithoutVersions + if len(versions) > 0 { + sort.Strings(versions) + prov = fmt.Sprintf("%s (%s)", r.ProviderWithoutVersions, strings.Join(versions, ", ")) + } + rows = append(rows, []string{r.Vuln, prov, r.PublishedDate, r.Severity, r.Reference}) + } + + // sort rows by each column + sort.Slice(rows, func(i, j int) bool { + for k := range rows[i] { + if rows[i][k] != rows[j][k] { + return rows[i][k] < rows[j][k] + } + } + return false + }) + + return rows +} diff --git a/cmd/grype/cli/commands/internal/dbsearch/affected_packages.go b/cmd/grype/cli/commands/internal/dbsearch/affected_packages.go new file mode 100644 index 00000000000..78a091f63a7 --- /dev/null +++ b/cmd/grype/cli/commands/internal/dbsearch/affected_packages.go @@ -0,0 +1,186 @@ +package dbsearch + +import ( + "encoding/json" + "fmt" + + v6 "github.com/anchore/grype/grype/db/v6" + "github.com/anchore/grype/internal/log" + "github.com/anchore/syft/syft/cpe" +) + +type AffectedPackageTableRow struct { + Vulnerability VulnerabilityInfo `json:"vulnerability"` + OS *OS `json:"os,omitempty"` + Package *Package `json:"package,omitempty"` + CPE *v6.Cpe `json:"cpe,omitempty"` + Detail v6.AffectedPackageBlob `json:"detail"` +} + +func (r AffectedPackageTableRow) MarshalJSON() ([]byte, error) { + var c string + if r.CPE != nil { + c = r.CPE.String() + } + return json.Marshal(&struct { + Vulnerability VulnerabilityInfo `json:"vulnerability"` + Package *Package `json:"package,omitempty"` + CPE string `json:"cpe,omitempty"` + Detail v6.AffectedPackageBlob `json:"detail"` + }{ + Vulnerability: r.Vulnerability, + Package: r.Package, + CPE: c, + Detail: r.Detail, + }) +} + +type Package struct { + Name string `json:"name"` + Ecosystem string `json:"ecosystem"` +} + +type OS struct { + Family string `json:"family"` + Version string `json:"version"` +} + +func newAffectedPackageRows(affectedPkgs []v6.AffectedPackageHandle, affectedCPEs []v6.AffectedCPEHandle) (rows []AffectedPackageTableRow) { + for _, pkg := range affectedPkgs { + var detail v6.AffectedPackageBlob + if pkg.BlobValue != nil { + detail = *pkg.BlobValue + } + if pkg.Vulnerability == nil { + // TODO: handle better + log.Errorf("affected package record missing vulnerability: %+v", pkg) + continue + } + + rows = append(rows, AffectedPackageTableRow{ + Vulnerability: newVulnerabilityInfo(*pkg.Vulnerability), + OS: toOS(pkg.OperatingSystem), + Package: toPackage(pkg.Package), + Detail: detail, + }) + } + + for _, ac := range affectedCPEs { + var detail v6.AffectedPackageBlob + if ac.BlobValue != nil { + detail = *ac.BlobValue + } + if ac.Vulnerability == nil { + // TODO: handle better + log.Errorf("affected CPE record missing vulnerability: %+v", ac) + continue + } + + rows = append(rows, AffectedPackageTableRow{ + Vulnerability: newVulnerabilityInfo(*ac.Vulnerability), + CPE: ac.CPE, + Detail: detail, + }) + } + return rows +} + +func toPackage(pkg *v6.Package) *Package { + if pkg == nil { + return nil + } + return &Package{ + Name: pkg.Name, + Ecosystem: pkg.Type, + } +} + +func toOS(os *v6.OperatingSystem) *OS { + if os == nil { + return nil + } + version := os.VersionNumber() + if version == "" { + version = os.Version() + } + + return &OS{ + Family: os.Name, + Version: version, + } +} + +type AffectedPackagesOptions struct { + Vulnerability v6.VulnerabilitySpecifiers + Package v6.PackageSpecifiers + CPE v6.PackageSpecifiers + OS v6.OSSpecifiers +} + +func AffectedPackages(reader interface { + v6.AffectedPackageStoreReader + v6.AffectedCPEStoreReader +}, criteria AffectedPackagesOptions) ([]AffectedPackageTableRow, error) { + var allAffectedPkgs []v6.AffectedPackageHandle + var allAffectedCPEs []v6.AffectedCPEHandle + + pkgSpecs := criteria.Package + cpeSpecs := criteria.CPE + osSpecs := criteria.OS + vulnSpecs := criteria.Vulnerability + + if len(pkgSpecs) == 0 { + pkgSpecs = []*v6.PackageSpecifier{nil} + } + + if len(cpeSpecs) == 0 { + cpeSpecs = []*v6.PackageSpecifier{nil} + } + + for i := range pkgSpecs { + pkgSpec := pkgSpecs[i] + + log.WithFields("vuln", vulnSpecs, "pkg", pkgSpec, "os", osSpecs).Debug("searching for affected packages") + + affectedPkgs, err := reader.GetAffectedPackages(pkgSpec, &v6.GetAffectedPackageOptions{ + PreloadOS: true, + PreloadPackage: true, + PreloadPackageCPEs: false, + PreloadVulnerability: true, + PreloadBlob: true, + OSs: osSpecs, + Vulnerabilities: vulnSpecs, + }) + if err != nil { + return nil, fmt.Errorf("unable to get affected packages for %s: %w", vulnSpecs, err) + } + + allAffectedPkgs = append(allAffectedPkgs, affectedPkgs...) + } + + if osSpecs.IsAny() { + for i := range cpeSpecs { + cpeSpec := cpeSpecs[i] + var searchCPE *cpe.Attributes + if cpeSpec != nil { + searchCPE = cpeSpec.CPE + } + + log.WithFields("vuln", vulnSpecs, "cpe", cpeSpec).Debug("searching for affected packages") + + affectedCPEs, err := reader.GetAffectedCPEs(searchCPE, &v6.GetAffectedCPEOptions{ + PreloadCPE: true, + PreloadVulnerability: true, + PreloadBlob: true, + Vulnerabilities: vulnSpecs, + }) + if err != nil { + return nil, fmt.Errorf("unable to get affected cpes for %s: %w", vulnSpecs, err) + } + + allAffectedCPEs = append(allAffectedCPEs, affectedCPEs...) + } + } + + return newAffectedPackageRows(allAffectedPkgs, allAffectedCPEs), nil +} 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 new file mode 100644 index 00000000000..eb5e719792c --- /dev/null +++ b/cmd/grype/cli/commands/internal/dbsearch/vulnerabilities.go @@ -0,0 +1,139 @@ +package dbsearch + +import ( + "fmt" + "sort" + "time" + + v6 "github.com/anchore/grype/grype/db/v6" + "github.com/anchore/grype/internal/log" +) + +type VulnerabilityInfo struct { + v6.VulnerabilityBlob `json:",inline"` + Provider string `json:"provider"` + Status string `json:"status"` + 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"` +} + +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, 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 VulnerabilityInfo{ + VulnerabilityBlob: blob, + Provider: vuln.Provider.ID, + Status: vuln.Status, + PublishedDate: vuln.PublishedDate, + ModifiedDate: vuln.ModifiedDate, + WithdrawnDate: vuln.WithdrawnDate, + } +} + +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 { + vulnSpec := vulnSpecs[i] + vs, err := reader.GetVulnerabilities(&vulnSpec, &v6.GetVulnerabilityOptions{ + Preload: true, + }) + if err != nil { + return nil, fmt.Errorf("unable to get vulnerabilities: %w", err) + } + + vulns = append(vulns, vs...) + } + + 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/commands/util.go b/cmd/grype/cli/commands/util.go index 5ac90ca7114..5a672b98d7e 100644 --- a/cmd/grype/cli/commands/util.go +++ b/cmd/grype/cli/commands/util.go @@ -7,6 +7,7 @@ import ( "sync" "github.com/hashicorp/go-multierror" + "github.com/olekukonko/tablewriter" "github.com/spf13/cobra" "golang.org/x/exp/maps" @@ -83,3 +84,18 @@ func appendErrors(errs error, err ...error) error { } return multierror.Append(errs, err...) } + +func commonTableWriterOptions(table *tablewriter.Table) { + table.SetAutoWrapText(false) + table.SetHeaderAlignment(tablewriter.ALIGN_LEFT) + table.SetAlignment(tablewriter.ALIGN_LEFT) + + table.SetHeaderLine(false) + table.SetBorder(false) + table.SetAutoFormatHeaders(true) + table.SetCenterSeparator("") + table.SetColumnSeparator("") + table.SetRowSeparator("") + table.SetTablePadding(" ") + table.SetNoWhiteSpace(true) +} diff --git a/cmd/grype/cli/options/database_search_format.go b/cmd/grype/cli/options/database_search_format.go new file mode 100644 index 00000000000..fd9921f20b3 --- /dev/null +++ b/cmd/grype/cli/options/database_search_format.go @@ -0,0 +1,29 @@ +package options + +import ( + "fmt" + "strings" + + "github.com/scylladb/go-set/strset" + + "github.com/anchore/clio" +) + +type DBSearchFormat struct { + Output string `yaml:"output" json:"output" mapstructure:"output"` + Allowable []string `yaml:"-" json:"-" mapstructure:"-"` +} + +func (c *DBSearchFormat) AddFlags(flags clio.FlagSet) { + available := strings.Join(c.Allowable, ", ") + flags.StringVarP(&c.Output, "output", "o", fmt.Sprintf("format to display results (available=[%s])", available)) +} + +func (c *DBSearchFormat) PostLoad() error { + if len(c.Allowable) > 0 { + if !strset.New(c.Allowable...).Has(c.Output) { + return fmt.Errorf("invalid output format: %s (expected one of: %s)", c.Output, strings.Join(c.Allowable, ", ")) + } + } + return nil +} diff --git a/cmd/grype/cli/options/database_search_os.go b/cmd/grype/cli/options/database_search_os.go new file mode 100644 index 00000000000..d1dc18e6bea --- /dev/null +++ b/cmd/grype/cli/options/database_search_os.go @@ -0,0 +1,99 @@ +package options + +import ( + "errors" + "fmt" + "strings" + + "github.com/anchore/clio" + v6 "github.com/anchore/grype/grype/db/v6" +) + +type DBSearchOSs struct { + OSs []string `yaml:"distro" json:"distro" mapstructure:"distro"` + Specs v6.OSSpecifiers `yaml:"-" json:"-" mapstructure:"-"` +} + +func (o *DBSearchOSs) AddFlags(flags clio.FlagSet) { + // consistent with grype --distro flag today + flags.StringArrayVarP(&o.OSs, "distro", "", "refine to results with the given operating system (format: 'name', 'name@version', 'name@maj.min', 'name@codename')") +} + +func (o *DBSearchOSs) PostLoad() error { + if len(o.OSs) == 0 { + o.Specs = []*v6.OSSpecifier{v6.AnyOSSpecified} + return nil + } + + var specs []*v6.OSSpecifier + for _, osValue := range o.OSs { + spec, err := parseOSString(osValue) + if err != nil { + return err + } + if spec != nil { + spec.AllowMultiple = true + } + specs = append(specs, spec) + } + o.Specs = specs + + return nil +} + +func parseOSString(osValue string) (*v6.OSSpecifier, error) { + // parse name@version from the distro string + // version could be a codename, major version, major.minor version, or major.minior.patch version + switch strings.Count(osValue, ":") { + case 0: + // fallthrough + case 1: + // be nice to folks that are close... + osValue = strings.ReplaceAll(osValue, ":", "@") + default: + // this is pretty unexpected + return nil, fmt.Errorf("invalid distro input provided: %q", osValue) + } + + parts := strings.Split(osValue, "@") + switch len(parts) { + case 1: + name := strings.TrimSpace(parts[0]) + return &v6.OSSpecifier{Name: name}, nil + case 2: + version := strings.TrimSpace(parts[1]) + name := strings.TrimSpace(parts[0]) + if len(version) == 0 { + return nil, errors.New("invalid distro version provided") + } + + // parse the version (major.minor.patch, major.minor, major, codename) + + // if starts with a number, then it is a version + startVersion := version[0] + if startVersion >= '0' && startVersion <= '9' { + versionParts := strings.Split(parts[1], ".") + var major, minor string + switch len(versionParts) { + case 1: + major = versionParts[0] + case 2: + major = versionParts[0] + minor = versionParts[1] + case 3: + return nil, fmt.Errorf("invalid distro version provided: patch version ignored: %q", version) + default: + return nil, fmt.Errorf("invalid distro version provided: %q", version) + } + + return &v6.OSSpecifier{Name: name, MajorVersion: major, MinorVersion: minor}, nil + } + + // is codename / label + // TODO: pick one! not both + return &v6.OSSpecifier{Name: name, Codename: version, LabelVersion: version}, nil + + default: + return nil, fmt.Errorf("invalid distro name@version: %q", osValue) + } +} diff --git a/cmd/grype/cli/options/database_search_packages.go b/cmd/grype/cli/options/database_search_packages.go new file mode 100644 index 00000000000..84d52487669 --- /dev/null +++ b/cmd/grype/cli/options/database_search_packages.go @@ -0,0 +1,59 @@ +package options + +import ( + "errors" + "fmt" + "strings" + + "github.com/anchore/clio" + v6 "github.com/anchore/grype/grype/db/v6" + "github.com/anchore/packageurl-go" + "github.com/anchore/syft/syft/cpe" +) + +type DBSearchPackages struct { + Names []string `yaml:"names" json:"names" mapstructure:"names"` + Ecosystem string `yaml:"ecosystem" json:"ecosystem" mapstructure:"ecosystem"` + PkgSpecs v6.PackageSpecifiers `yaml:"-" json:"-" mapstructure:"-"` + CPESpecs v6.PackageSpecifiers `yaml:"-" json:"-" mapstructure:"-"` +} + +func (o *DBSearchPackages) AddFlags(flags clio.FlagSet) { + flags.StringVarP(&o.Ecosystem, "ecosystem", "", "ecosystem of the package to search within") +} + +func (o *DBSearchPackages) PostLoad() error { + if len(o.Names) == 0 { + return nil + } + for _, p := range o.Names { + switch { + case strings.HasPrefix(p, "cpe:"): + c, err := cpe.NewAttributes(p) + if err != nil { + return fmt.Errorf("invalid CPE from %q: %w", o.Names, err) + } + s := &v6.PackageSpecifier{CPE: &c} + o.CPESpecs = append(o.CPESpecs, s) + o.PkgSpecs = append(o.PkgSpecs, s) + case strings.HasPrefix(p, "pkg:"): + if o.Ecosystem != "" { + return errors.New("cannot specify both package URL and ecosystem") + } + + purl, err := packageurl.FromString(p) + if err != nil { + return fmt.Errorf("invalid package URL from %q: %w", o.Names, err) + } + + o.PkgSpecs = append(o.PkgSpecs, &v6.PackageSpecifier{Name: purl.Name, Type: purl.Type}) // TODO: map this to correct DB types + + default: + o.PkgSpecs = append(o.PkgSpecs, &v6.PackageSpecifier{Name: p, Type: o.Ecosystem}) + o.CPESpecs = append(o.CPESpecs, &v6.PackageSpecifier{ + CPE: &cpe.Attributes{Part: "a", Product: p}, + }) + } + } + return nil +} diff --git a/cmd/grype/cli/options/database_search_vulnerabilities.go b/cmd/grype/cli/options/database_search_vulnerabilities.go new file mode 100644 index 00000000000..c097f91ecd5 --- /dev/null +++ b/cmd/grype/cli/options/database_search_vulnerabilities.go @@ -0,0 +1,77 @@ +package options + +import ( + "fmt" + "time" + + "github.com/araddon/dateparse" + + "github.com/anchore/clio" + v6 "github.com/anchore/grype/grype/db/v6" +) + +type DBSearchVulnerabilities struct { + VulnerabilityIDs []string `yaml:"vulnerability-ids" json:"vulnerability-ids" mapstructure:"vulnerability-ids"` + IncludeAliases bool `yaml:"include-aliases" json:"include-aliases" mapstructure:"include-aliases"` + UseVulnIDFlag bool `yaml:"-" json:"-" mapstructure:"-"` + + 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:"-"` +} + +func (c *DBSearchVulnerabilities) AddFlags(flags clio.FlagSet) { + if c.UseVulnIDFlag { + flags.StringArrayVarP(&c.VulnerabilityIDs, "vuln", "", "only show results for the given vulnerability ID") + } + 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)") + flags.StringVarP(&c.ModifiedAfter, "modified-after", "", "only show vulnerabilities originally published or modified since the given date (format: YYYY-MM-DD)") + flags.StringArrayVarP(&c.Providers, "provider", "", "only show vulnerabilities from the given provider") +} + +func (c *DBSearchVulnerabilities) PostLoad() error { + handleTimeOption := func(val string, flag string) (*time.Time, error) { + if val == "" { + return nil, nil + } + parsed, err := dateparse.ParseIn(val, time.UTC) + if err != nil { + return nil, fmt.Errorf("invalid date format for %s=%q: %w", flag, val, err) + } + return &parsed, nil + } + + if c.PublishedAfter != "" && c.ModifiedAfter != "" { + return fmt.Errorf("only one of --published-after or --modified-after can be set") + } + + var publishedAfter, modifiedAfter *time.Time + var err error + publishedAfter, err = handleTimeOption(c.PublishedAfter, "published-after") + if err != nil { + return fmt.Errorf("invalid date format for published-after field: %w", err) + } + modifiedAfter, err = handleTimeOption(c.ModifiedAfter, "modified-after") + if err != nil { + return fmt.Errorf("invalid date format for modified-after field: %w", err) + } + + var specs []v6.VulnerabilitySpecifier + for _, vulnID := range c.VulnerabilityIDs { + specs = append(specs, v6.VulnerabilitySpecifier{ + Name: vulnID, + PublishedAfter: publishedAfter, + ModifiedAfter: modifiedAfter, + IncludeAliases: c.IncludeAliases, + Providers: c.Providers, + }) + } + + c.Specs = specs + + return nil +} diff --git a/go.mod b/go.mod index db9bf9239bc..1567788b7b2 100644 --- a/go.mod +++ b/go.mod @@ -19,6 +19,7 @@ require ( github.com/anchore/stereoscope v0.0.11 github.com/anchore/syft v1.18.1 github.com/aquasecurity/go-pep440-version v0.0.0-20210121094942-22b2f8951d46 + github.com/araddon/dateparse v0.0.0-20210429162001-6b43995a97de github.com/bmatcuk/doublestar/v2 v2.0.4 github.com/charmbracelet/bubbletea v1.2.4 github.com/charmbracelet/lipgloss v1.0.0 diff --git a/go.sum b/go.sum index 5e5fe416260..3499d03c933 100644 --- a/go.sum +++ b/go.sum @@ -272,6 +272,8 @@ github.com/aquasecurity/go-pep440-version v0.0.0-20210121094942-22b2f8951d46 h1: github.com/aquasecurity/go-pep440-version v0.0.0-20210121094942-22b2f8951d46/go.mod h1:olhPNdiiAAMiSujemd1O/sc6GcyePr23f/6uGKtthNg= github.com/aquasecurity/go-version v0.0.0-20210121072130-637058cfe492 h1:rcEG5HI490FF0a7zuvxOxen52ddygCfNVjP0XOCMl+M= github.com/aquasecurity/go-version v0.0.0-20210121072130-637058cfe492/go.mod h1:9Beu8XsUNNfzml7WBf3QmyPToP1wm1Gj/Vc5UJKqTzU= +github.com/araddon/dateparse v0.0.0-20210429162001-6b43995a97de h1:FxWPpzIjnTlhPwqqXc4/vE0f7GvRjuAsbW+HOIe8KnA= +github.com/araddon/dateparse v0.0.0-20210429162001-6b43995a97de/go.mod h1:DCaWoUhZrYW9p1lxo/cm8EmUOOzAPSEZNGF2DK1dJgw= github.com/armon/circbuf v0.0.0-20150827004946-bbbad097214e/go.mod h1:3U/XgcO3hCbHZ8TKRvWD2dDTCfh9M9ya+I9JpbB7O8o= github.com/armon/go-metrics v0.0.0-20180917152333-f0300d1749da/go.mod h1:Q73ZrmVTwzkszR9V5SSuryQ31EELlFMUz1kKyl939pY= github.com/armon/go-metrics v0.3.10/go.mod h1:4O98XIr/9W0sxpJ8UaYkvjk10Iff7SnFrb4QAOwNTFc= @@ -743,6 +745,7 @@ github.com/mattn/go-localereader v0.0.2-0.20220822084749-2491eb6c1c75 h1:P8UmIzZ github.com/mattn/go-localereader v0.0.2-0.20220822084749-2491eb6c1c75/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88= github.com/mattn/go-runewidth v0.0.4/go.mod h1:LwmH8dsx7+W8Uxz3IHJYH5QSwggIsqBzpuz5H//U1FU= github.com/mattn/go-runewidth v0.0.9/go.mod h1:H031xJmbD/WCDINGzjvQ9THkh0rPKHF+m2gUSrubnMI= +github.com/mattn/go-runewidth v0.0.10/go.mod h1:RAqKPSqVFrSLVXbA8x7dzmKdmGzieGRCM46jaSJTDAk= github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc= github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= github.com/matttproud/golang_protobuf_extensions v1.0.1/go.mod h1:D8He9yQNgCq6Z5Ld7szi9bcBfOoFv/3dc6xSMkL2PC0= @@ -864,6 +867,7 @@ github.com/prometheus/procfs v0.8.0 h1:ODq8ZFEaYeCaZOJlZZdJA2AbQR98dSHSM1KW/You5 github.com/prometheus/procfs v0.8.0/go.mod h1:z7EfXMXOkbkqb9IINtpCn86r/to3BnA0uaxHdg830/4= github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= +github.com/rivo/uniseg v0.1.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= @@ -892,6 +896,7 @@ github.com/sassoftware/go-rpmutils v0.4.0 h1:ojND82NYBxgwrV+mX1CWsd5QJvvEZTKddtC github.com/sassoftware/go-rpmutils v0.4.0/go.mod h1:3goNWi7PGAT3/dlql2lv3+MSN5jNYPjT5mVcQcIsYzI= github.com/scylladb/go-set v1.0.3-0.20200225121959-cc7b2070d91e h1:7q6NSFZDeGfvvtIRwBrU/aegEYJYmvev0cHAwo17zZQ= github.com/scylladb/go-set v1.0.3-0.20200225121959-cc7b2070d91e/go.mod h1:DkpGd78rljTxKAnTDPFqXSGxvETQnJyuSOQwsHycqfs= +github.com/scylladb/termtables v0.0.0-20191203121021-c4c0b6d42ff4/go.mod h1:C1a7PQSMz9NShzorzCiG2fk9+xuCgLkPeCvMHYR2OWg= github.com/sean-/seed v0.0.0-20170313163322-e2103e2c3529/go.mod h1:DxrIzT+xaE7yg65j358z/aeFdxmN0P9QXhEzd20vsDc= github.com/sebdah/goldie/v2 v2.5.5 h1:rx1mwF95RxZ3/83sdS4Yp7t2C5TCokvWP4TBRbAyEWY= github.com/sebdah/goldie/v2 v2.5.5/go.mod h1:oZ9fp0+se1eapSRjfYbsV/0Hqhbuu3bJVvKI/NNtssI= diff --git a/grype/db/v6/blobs.go b/grype/db/v6/blobs.go index dbd9eecd168..744bf2a4fab 100644 --- a/grype/db/v6/blobs.go +++ b/grype/db/v6/blobs.go @@ -138,7 +138,7 @@ type AffectedRange struct { // Fix conveys availability of a fix for a vulnerability. type Fix struct { // Version is the version number of the fix. - Version string `json:"version"` + Version string `json:"version,omitempty"` // State represents the status of the fix (e.g., "fixed", "unaffected"). State FixStatus `json:"state"` @@ -162,7 +162,7 @@ type FixDetail struct { // AffectedVersion defines the versioning format and constraints. type AffectedVersion struct { // Type specifies the versioning system used (e.g., "semver", "rpm"). - Type string `json:"type"` + Type string `json:"type,omitempty"` // Constraint defines the version range constraint for affected versions. Constraint string `json:"constraint"` diff --git a/grype/db/v6/models.go b/grype/db/v6/models.go index f427fea9a77..001644824bd 100644 --- a/grype/db/v6/models.go +++ b/grype/db/v6/models.go @@ -3,6 +3,7 @@ package v6 import ( "encoding/json" "fmt" + "strings" "time" "github.com/OneOfOne/xxhash" @@ -285,6 +286,32 @@ type OperatingSystem struct { Codename string `gorm:"column:codename"` // TODO: should this be removed and use label-version instead? } +func (os *OperatingSystem) VersionNumber() string { + if os.MinorVersion != "" { + return fmt.Sprintf("%s.%s", os.MajorVersion, os.MinorVersion) + } + return os.MajorVersion +} + +func (os *OperatingSystem) Version() string { + if os == nil { + return "" + } + + if os.LabelVersion != "" { + return os.LabelVersion + } + + if os.MajorVersion != "" { + if os.MinorVersion != "" { + return fmt.Sprintf("%s.%s", os.MajorVersion, os.MinorVersion) + } + return os.MajorVersion + } + + return os.Codename +} + func (os *OperatingSystem) BeforeCreate(tx *gorm.DB) (err error) { if (os.MajorVersion != "" || os.MinorVersion != "") && os.LabelVersion != "" { return fmt.Errorf("cannot have both label_version and major_version/minor_version set") @@ -410,7 +437,13 @@ type Cpe struct { } func (c Cpe) String() string { - return fmt.Sprintf("%s:%s:%s:::%s:%s:%s:%s:%s:%s", c.Part, c.Vendor, c.Product, c.Edition, c.Language, c.SoftwareEdition, c.TargetHardware, c.TargetSoftware, c.Other) + parts := []string{"cpe:2.3", c.Part, c.Vendor, c.Product, c.Edition, c.Language, c.SoftwareEdition, c.TargetHardware, c.TargetSoftware, c.Other} + for i, part := range parts { + if part == "" { + parts[i] = "*" + } + } + return strings.Join(parts, ":") } func (c *Cpe) BeforeCreate(tx *gorm.DB) (err error) {