diff --git a/.github/scripts/json-schema-drift-check.sh b/.github/scripts/json-schema-drift-check.sh new file mode 100755 index 00000000000..3002236d68b --- /dev/null +++ b/.github/scripts/json-schema-drift-check.sh @@ -0,0 +1,17 @@ +#!/usr/bin/env bash +set -u + +if [ "$(git status --porcelain | wc -l)" -ne "0" ]; then + echo " 🔴 there are uncommitted changes, please commit them before running this check" + exit 1 +fi + +if ! make generate-json-schema; then + echo "Generating json schema failed" + exit 1 +fi + +if [ "$(git status --porcelain | wc -l)" -ne "0" ]; then + echo " 🔴 there are uncommitted changes, please commit them before running this check" + exit 1 +fi diff --git a/.golangci.yaml b/.golangci.yaml index 7188b9b1fd4..6521a59d3ac 100644 --- a/.golangci.yaml +++ b/.golangci.yaml @@ -1,5 +1,6 @@ issues: max-same-issues: 25 + uniq-by-line: false # TODO: enable this when we have coverage on docstring comments # # The list of ids of default excludes to include or disable. @@ -49,8 +50,6 @@ linters-settings: # If lower than 0, disable the check. # Default: 40 statements: 50 -output: - uniq-by-line: false run: timeout: 10m diff --git a/Taskfile.yaml b/Taskfile.yaml index b4221933ab4..a4cc0a39b34 100644 --- a/Taskfile.yaml +++ b/Taskfile.yaml @@ -56,6 +56,7 @@ tasks: - task: check-go-mod-tidy - task: check-licenses - task: lint + - task: check-json-schema-drift - task: validate-cyclonedx-schema # TODO: while developing v6, we need to disable this check (since v5 and v6 are imported in the same codebase) # - task: validate-grype-db-schema @@ -171,6 +172,11 @@ tasks: - cmd: .github/scripts/go-mod-tidy-check.sh && echo "go.mod and go.sum are tidy!" silent: true + check-json-schema-drift: + desc: Ensure there is no drift between the JSON schema and the code + cmds: + - .github/scripts/json-schema-drift-check.sh + validate-cyclonedx-schema: desc: Run integration tests cmds: @@ -313,9 +319,17 @@ tasks: ## Code and data generation targets ################################# generate: - desc: Run data generation tasks + desc: Run code and data generation tasks + cmds: + - task: generate-json-schema + + generate-json-schema: + desc: Generate a new JSON schema cmds: + # re-generate package metadata - "cd grype/internal && go generate" + # generate the JSON schema for the CLI output + - "cd cmd/grype/cli/commands/internal/jsonschema && go run ." ## Build-related targets ################################# diff --git a/cmd/grype/cli/commands/db_check.go b/cmd/grype/cli/commands/db_check.go index b9bf16a49e9..49e4fceb4cc 100644 --- a/cmd/grype/cli/commands/db_check.go +++ b/cmd/grype/cli/commands/db_check.go @@ -38,7 +38,7 @@ func DBCheck(app clio.Application) *cobra.Command { cmd := &cobra.Command{ Use: "check", - Short: "check to see if there is a database update available", + Short: "Check to see if there is a database update available", PreRunE: func(cmd *cobra.Command, args []string) error { // DB commands should not opt into the low-pass check filter opts.DB.MaxUpdateCheckFrequency = 0 diff --git a/cmd/grype/cli/commands/db_delete.go b/cmd/grype/cli/commands/db_delete.go index 185a90134b4..d7ad175a8b7 100644 --- a/cmd/grype/cli/commands/db_delete.go +++ b/cmd/grype/cli/commands/db_delete.go @@ -17,7 +17,7 @@ func DBDelete(app clio.Application) *cobra.Command { cmd := &cobra.Command{ Use: "delete", - Short: "delete the vulnerability database", + Short: "Delete the vulnerability database", Args: cobra.ExactArgs(0), PreRunE: disableUI(app), RunE: func(_ *cobra.Command, _ []string) error { diff --git a/cmd/grype/cli/commands/db_diff.go b/cmd/grype/cli/commands/db_diff.go index 67d00a397a5..44c0e1bd942 100644 --- a/cmd/grype/cli/commands/db_diff.go +++ b/cmd/grype/cli/commands/db_diff.go @@ -35,7 +35,7 @@ func DBDiff(app clio.Application) *cobra.Command { cmd := &cobra.Command{ Use: "diff [flags] base_db_url target_db_url", - Short: "diff two DBs and display the result", + Short: "Diff two DBs and display the result", Args: cobra.MaximumNArgs(2), RunE: func(_ *cobra.Command, args []string) (err error) { var base, target string diff --git a/cmd/grype/cli/commands/db_import.go b/cmd/grype/cli/commands/db_import.go index 7e774dc10d7..0d45700a3c1 100644 --- a/cmd/grype/cli/commands/db_import.go +++ b/cmd/grype/cli/commands/db_import.go @@ -19,7 +19,7 @@ func DBImport(app clio.Application) *cobra.Command { cmd := &cobra.Command{ Use: "import FILE", - Short: "import a vulnerability database archive", + Short: "Import a vulnerability database archive", Long: fmt.Sprintf("import a vulnerability database archive from a local FILE.\nDB archives can be obtained from %q.", internal.DBUpdateURL), Args: cobra.ExactArgs(1), RunE: func(_ *cobra.Command, args []string) error { diff --git a/cmd/grype/cli/commands/db_list.go b/cmd/grype/cli/commands/db_list.go index b64c564c2e8..8f35ed41c4b 100644 --- a/cmd/grype/cli/commands/db_list.go +++ b/cmd/grype/cli/commands/db_list.go @@ -34,7 +34,7 @@ func DBList(app clio.Application) *cobra.Command { cmd := &cobra.Command{ Use: "list", - Short: "list all DBs available according to the listing URL", + Short: "List all DBs available according to the listing URL", PreRunE: disableUI(app), Args: cobra.ExactArgs(0), RunE: func(_ *cobra.Command, _ []string) error { diff --git a/cmd/grype/cli/commands/db_providers.go b/cmd/grype/cli/commands/db_providers.go index 4d24d3d78c4..34dbe171a5c 100644 --- a/cmd/grype/cli/commands/db_providers.go +++ b/cmd/grype/cli/commands/db_providers.go @@ -39,7 +39,7 @@ func DBProviders(app clio.Application) *cobra.Command { cmd := &cobra.Command{ Use: "providers", - Short: "list vulnerability database providers", + Short: "List vulnerability providers that are in the database", Args: cobra.ExactArgs(0), RunE: func(_ *cobra.Command, _ []string) error { return runDBProviders(opts, app) diff --git a/cmd/grype/cli/commands/db_search.go b/cmd/grype/cli/commands/db_search.go index 015fb329110..0056f0a5159 100644 --- a/cmd/grype/cli/commands/db_search.go +++ b/cmd/grype/cli/commands/db_search.go @@ -2,14 +2,18 @@ package commands import ( "encoding/json" + "errors" "fmt" "io" + "regexp" + "sort" "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" v6 "github.com/anchore/grype/grype/db/v6" "github.com/anchore/grype/grype/db/v6/distribution" @@ -19,84 +23,215 @@ import ( "github.com/anchore/grype/internal/log" ) -type dbQueryOptions struct { - Output string `yaml:"output" json:"output" mapstructure:"output"` +type dbSearchMatchOptions 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"` + Bounds options.DBSearchBounds `yaml:",inline" mapstructure:",squash"` + DBOptions `yaml:",inline" mapstructure:",squash"` } -var _ clio.FlagAdder = (*dbQueryOptions)(nil) +var alasPattern = regexp.MustCompile(`^alas[\w]*-\d+-\d+$`) + +func (o *dbSearchMatchOptions) applyArgs(args []string) error { + for _, arg := range args { + lowerArg := strings.ToLower(arg) + switch { + case hasAnyPrefix(lowerArg, "cpe:", "purl:"): + // this is explicitly a package... + log.WithFields("value", arg).Trace("assuming arg is a package specifier") + o.Package.Packages = append(o.Package.Packages, arg) + case hasAnyPrefix(lowerArg, "cve-", "ghsa-", "elsa-", "rhsa-") || alasPattern.MatchString(lowerArg): + // this is a vulnerability... + log.WithFields("value", arg).Trace("assuming arg is a vulnerability ID") + o.Vulnerability.VulnerabilityIDs = append(o.Vulnerability.VulnerabilityIDs, arg) + default: + // assume this is a package name + log.WithFields("value", arg).Trace("assuming arg is a package name") + o.Package.Packages = append(o.Package.Packages, arg) + } + } + + if err := o.Vulnerability.PostLoad(); err != nil { + return err + } + + if err := o.Package.PostLoad(); err != nil { + return err + } + + return nil +} -func (c *dbQueryOptions) AddFlags(flags clio.FlagSet) { - flags.StringVarP(&c.Output, "output", "o", "format to display results (available=[table, json])") +func hasAnyPrefix(s string, prefixes ...string) bool { + for _, prefix := range prefixes { + if strings.HasPrefix(s, prefix) { + return true + } + } + return false } func DBSearch(app clio.Application) *cobra.Command { - opts := &dbQueryOptions{ - Output: tableOutputFormat, + opts := &dbSearchMatchOptions{ + Format: options.DefaultDBSearchFormat(), + Vulnerability: options.DBSearchVulnerabilities{ + UseVulnIDFlag: true, + }, + Bounds: options.DefaultDBSearchBounds(), DBOptions: *dbOptionsDefault(app.ID()), } cmd := &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) + // this is here to support v5 functionality today but will be removed when v6 is the default DB version + Use: "search VULN|PKG...", + Short: "Search the DB for vulnerabilities or affected packages", + Example: ` + Search for affected packages by vulnerability ID: + + $ grype db search ELSA-2023-12205 # same as '--vuln ELSA-2023-12205' + + Search for affected packages by package name: + + $ grype db search log4j # same as '--pkg log4j' + + Search for affected packages by package name, filtering down to a specific vulnerability: + + $ grype db search log4j CVE-2021-44228 # same as '--pkg log4j --vuln CVE-2021-44228' + + Search for affected packages by PURL (note: version is not considered): + + $ grype db search 'pkg:rpm/redhat/openssl' # same as '--ecosystem rpm --pkg openssl' + + Search for affected packages by CPE (note: version/update is not considered): + + $ grype db search 'cpe:2.3:a:jetty:jetty_http_server:*:*:*:*:*:*' + $ grype db search 'cpe:/a:jetty:jetty_http_server'`, + PreRunE: disableUI(app), + RunE: func(cmd *cobra.Command, args []string) (err error) { + if opts.Experimental.DBv6 { + if len(args) > 0 { + // try to stay backwards compatible with v5 search command (which takes args) + if err := opts.applyArgs(args); err != nil { + return err + } + } + err := runDBSearchMatches(*opts) + if err != nil { + if errors.Is(err, dbsearch.ErrNoSearchCriteria) { + _ = cmd.Usage() + } + return err + } + return nil + } + + // 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) }, } + cmd.AddCommand( + DBSearchVulnerabilities(app), + ) + // prevent from being shown in the grype config type configWrapper struct { - Hidden *dbQueryOptions `json:"-" yaml:"-" mapstructure:"-"` + Hidden *dbSearchMatchOptions `json:"-" yaml:"-" mapstructure:"-"` *DBOptions `yaml:",inline" mapstructure:",squash"` } return app.SetupCommand(cmd, &configWrapper{Hidden: opts, DBOptions: &opts.DBOptions}) } -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 { +func runDBSearchMatches(opts dbSearchMatchOptions) 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) + curator, err := installation.NewCurator(opts.DB.ToCuratorConfig(), client) if err != nil { return fmt.Errorf("unable to create curator: %w", err) } - reader, err := c.Reader() + reader, err := curator.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 := validateProvidersFilter(reader, opts.Vulnerability.Providers); err != nil { + return err + } + + rows, queryErr := dbsearch.FindMatches(reader, dbsearch.AffectedPackagesOptions{ + Vulnerability: opts.Vulnerability.Specs, + Package: opts.Package.PkgSpecs, + CPE: opts.Package.CPESpecs, + OS: opts.OS.Specs, + RecordLimit: opts.Bounds.RecordLimit, }) - if err != nil { - return fmt.Errorf("unable to get vulnerability: %w", err) + if queryErr != nil { + if !errors.Is(queryErr, v6.ErrLimitReached) { + return queryErr + } } - if len(vh) == 0 { - return fmt.Errorf("vulnerability doesn't exist in the DB: %s", vulnerabilityID) + if len(rows) != 0 { + sb := &strings.Builder{} + err = presentDBSearchMatches(opts.Format.Output, rows, sb) + bus.Report(sb.String()) + if err != nil { + return fmt.Errorf("unable to present search results: %w", err) + } + } else { + bus.Notify("No results found") } - // TODO: we need to implement the functions that inflate models to the grype vulnerability.Vulnerability struct - panic("not implemented") + return queryErr } -/////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// -// all legacy processing below //////////////////////////////////////////////////////////////////////////////////////// +func presentDBSearchMatches(outputFormat string, structuredRows dbsearch.Matches, output io.Writer) error { + switch outputFormat { + case tableOutputFormat: + rows := renderDBSearchPackagesTableRows(structuredRows.Flatten()) + + table := newTable(output) + + table.SetHeader([]string{"Vulnerability", "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 +} + +/////////////////////////////////////////////////////////////////////////////////////////////////////////////////// +// legacy search functionality + +func legacyDBSearchPackages(opts dbSearchMatchOptions, vulnerabilityIDs []string) error { + if len(opts.Package.CPESpecs) > 0 { + return errors.New("CPE search is not supported with the v5 DB schema") + } + + if len(opts.Package.PkgSpecs) > 0 { + return errors.New("package search is not supported with the v5 DB schema") + } -func legacyDBSearch(opts dbQueryOptions, vulnerabilityID string) error { log.Debug("loading DB") str, status, err := grype.LoadVulnerabilityDB(opts.DB.ToLegacyCuratorConfig(), opts.DB.AutoUpdate) err = validateDBLoad(err, status) @@ -105,23 +240,25 @@ 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) + if len(vulnerabilities) != 0 { + sb := &strings.Builder{} + err = presentLegacyDBSearchPackages(opts.Format.Output, vulnerabilities, sb) + bus.Report(sb.String()) } - sb := &strings.Builder{} - err = presentLegacy(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 } @@ -133,23 +270,9 @@ func presentLegacy(outputFormat string, vulnerabilities []vulnerability.Vulnerab rows = append(rows, []string{v.ID, v.PackageName, v.Namespace, v.Constraint.String()}) } - 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) + table := newTable(output) + table.SetHeader([]string{"ID", "Package Name", "Namespace", "Version Constraint"}) table.AppendBulk(rows) table.Render() case jsonOutputFormat: @@ -164,3 +287,104 @@ func presentLegacy(outputFormat string, vulnerabilities []vulnerability.Vulnerab } return nil } + +func renderDBSearchPackagesTableRows(structuredRows []dbsearch.AffectedPackage) [][]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 + } + + 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, v5Namespace(rr), rangeStr}) + } + + // 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 +} + +// v5Namespace returns the namespace for a given affected package based on what schema v5 did. +func v5Namespace(row dbsearch.AffectedPackage) string { + switch row.Vulnerability.Provider { + case "nvd": + return "nvd:cpe" + case "github": + language := row.Package.Ecosystem + // normalize from purl type, github ecosystem types, and vunnel mappings + switch strings.ToLower(row.Package.Ecosystem) { + case "golang", "go-module": + language = "go" + case "composer", "php-composer": + language = "php" + case "cargo", "rust-crate": + language = "rust" + case "dart-pub", "pub": + language = "dart" + case "nuget": + language = "dotnet" + case "maven": + language = "java" + case "swifturl": + language = "swift" + case "npm", "node": + language = "javascript" + case "pypi", "pip": + language = "python" + case "rubygems", "gem": + language = "ruby" + } + return fmt.Sprintf("github:language:%s", language) + } + if row.OS != nil { + // distro family fixes + family := row.OS.Name + switch row.OS.Name { + case "amazon": + family = "amazonlinux" + case "mariner": + switch row.OS.Version { + case "1.0", "2.0": + family = "mariner" + default: + family = "azurelinux" + } + case "oracle": + family = "oraclelinux" + } + + // provider fixes + pr := row.Vulnerability.Provider + if pr == "rhel" { + pr = "redhat" + } + + // version fixes + ver := row.OS.Version + switch row.Vulnerability.Provider { + case "rhel", "oracle": + // ensure we only keep the major version + ver = strings.Split(row.OS.Version, ".")[0] + } + + return fmt.Sprintf("%s:distro:%s:%s", pr, family, ver) + } + return row.Vulnerability.Provider +} diff --git a/cmd/grype/cli/commands/db_search_test.go b/cmd/grype/cli/commands/db_search_test.go new file mode 100644 index 00000000000..2550bca31e9 --- /dev/null +++ b/cmd/grype/cli/commands/db_search_test.go @@ -0,0 +1,493 @@ +package commands + +import ( + "testing" + + "github.com/google/go-cmp/cmp" + "github.com/google/go-cmp/cmp/cmpopts" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/anchore/grype/cmd/grype/cli/commands/internal/dbsearch" + "github.com/anchore/grype/cmd/grype/cli/options" +) + +func TestDBSearchMatchOptionsApplyArgs(t *testing.T) { + testCases := []struct { + name string + args []string + expectedPackages []string + expectedVulnIDs []string + expectedErrMessage string + }{ + { + name: "empty arguments", + args: []string{}, + expectedPackages: []string{}, + expectedVulnIDs: []string{}, + }, + { + name: "valid cpe", + args: []string{"cpe:2.3:a:vendor:product:1.0:*:*:*:*:*:*:*"}, + expectedPackages: []string{ + "cpe:2.3:a:vendor:product:1.0:*:*:*:*:*:*:*", + }, + expectedVulnIDs: []string{}, + }, + { + name: "valid purl", + args: []string{"pkg:npm/package-name@1.0.0"}, + expectedPackages: []string{ + "pkg:npm/package-name@1.0.0", + }, + expectedVulnIDs: []string{}, + }, + { + name: "valid vulnerability IDs", + args: []string{"CVE-2023-0001", "GHSA-1234", "ALAS-2023-1234"}, + expectedPackages: []string{}, + expectedVulnIDs: []string{ + "CVE-2023-0001", + "GHSA-1234", + "ALAS-2023-1234", + }, + }, + { + name: "mixed package and vulns", + args: []string{"cpe:2.3:a:vendor:product:1.0:*:*:*:*:*:*:*", "CVE-2023-0001"}, + expectedPackages: []string{ + "cpe:2.3:a:vendor:product:1.0:*:*:*:*:*:*:*", + }, + expectedVulnIDs: []string{ + "CVE-2023-0001", + }, + }, + { + name: "plain package name", + args: []string{"package-name"}, + expectedPackages: []string{ + "package-name", + }, + expectedVulnIDs: []string{}, + }, + { + name: "invalid PostLoad error for Package", + args: []string{"pkg:npm/package-name@1.0.0", "cpe:invalid"}, + expectedPackages: []string{ + "pkg:npm/package-name@1.0.0", + }, + expectedErrMessage: "invalid CPE", + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + opts := &dbSearchMatchOptions{ + Vulnerability: options.DBSearchVulnerabilities{}, + Package: options.DBSearchPackages{}, + } + + err := opts.applyArgs(tc.args) + + if tc.expectedErrMessage != "" { + require.Error(t, err) + require.Contains(t, err.Error(), tc.expectedErrMessage) + return + } + + require.NoError(t, err) + if d := cmp.Diff(tc.expectedPackages, opts.Package.Packages, cmpopts.EquateEmpty()); d != "" { + t.Errorf("unexpected package specifiers: %s", d) + } + if d := cmp.Diff(tc.expectedVulnIDs, opts.Vulnerability.VulnerabilityIDs, cmpopts.EquateEmpty()); d != "" { + t.Errorf("unexpected vulnerability specifiers: %s", d) + } + + }) + } +} +func TestV5Namespace(t *testing.T) { + // provider input should be derived from the Providers table: + // +------------+---------+---------------+----------------------------------+------------------------+ + // | id | version | processor | date_captured | input_digest | + // +------------+---------+---------------+----------------------------------+------------------------+ + // | nvd | 2 | vunnel@0.29.0 | 2025-01-08 01:32:55.179881+00:00 | xxh64:0a160d2b53dd0208 | + // | alpine | 1 | vunnel@0.29.0 | 2025-01-08 01:31:28.824872+00:00 | xxh64:30c5b7b8efa0c087 | + // | amazon | 1 | vunnel@0.29.0 | 2025-01-08 01:31:28.837469+00:00 | xxh64:7d90b3fa66b183bc | + // | chainguard | 1 | vunnel@0.29.0 | 2025-01-08 01:31:26.969865+00:00 | xxh64:25a82fa97ac9e077 | + // | debian | 1 | vunnel@0.29.0 | 2025-01-08 01:31:50.718966+00:00 | xxh64:4b1834b9e4e68987 | + // | github | 1 | vunnel@0.29.0 | 2025-01-08 01:31:27.450124+00:00 | xxh64:a3ee6b48d37a0124 | + // | mariner | 1 | vunnel@0.29.0 | 2025-01-08 01:32:35.005761+00:00 | xxh64:cb4f5861a1fda0af | + // | oracle | 1 | vunnel@0.29.0 | 2025-01-08 01:32:33.696274+00:00 | xxh64:72c0a15731e96ab3 | + // | rhel | 1 | vunnel@0.29.0 | 2025-01-08 01:32:32.192345+00:00 | xxh64:abf5d2fd5a26c194 | + // | sles | 1 | vunnel@0.29.0 | 2025-01-08 01:32:42.988937+00:00 | xxh64:8f558f8f28a04489 | + // | ubuntu | 3 | vunnel@0.29.0 | 2025-01-08 01:33:25.795537+00:00 | xxh64:97ef8421c0093620 | + // | wolfi | 1 | vunnel@0.29.0 | 2025-01-08 01:32:58.571417+00:00 | xxh64:f294f3474d35b1a9 | + // +------------+---------+---------------+----------------------------------+------------------------+ + + // the expected results should mimic what is found as v5 namespace values: + // +--------------------------------------+ + // | namespace | + // +--------------------------------------+ + // | nvd:cpe | + // | github:language:javascript | + // | ubuntu:distro:ubuntu:14.04 | + // | ubuntu:distro:ubuntu:16.04 | + // | ubuntu:distro:ubuntu:18.04 | + // | ubuntu:distro:ubuntu:20.04 | + // | ubuntu:distro:ubuntu:22.04 | + // | ubuntu:distro:ubuntu:22.10 | + // | ubuntu:distro:ubuntu:23.04 | + // | ubuntu:distro:ubuntu:23.10 | + // | ubuntu:distro:ubuntu:24.10 | + // | debian:distro:debian:8 | + // | debian:distro:debian:9 | + // | ubuntu:distro:ubuntu:12.04 | + // | ubuntu:distro:ubuntu:15.04 | + // | sles:distro:sles:15 | + // | sles:distro:sles:15.1 | + // | sles:distro:sles:15.2 | + // | sles:distro:sles:15.3 | + // | sles:distro:sles:15.4 | + // | sles:distro:sles:15.5 | + // | sles:distro:sles:15.6 | + // | amazon:distro:amazonlinux:2 | + // | debian:distro:debian:10 | + // | debian:distro:debian:11 | + // | debian:distro:debian:12 | + // | debian:distro:debian:unstable | + // | oracle:distro:oraclelinux:6 | + // | oracle:distro:oraclelinux:7 | + // | oracle:distro:oraclelinux:8 | + // | oracle:distro:oraclelinux:9 | + // | redhat:distro:redhat:6 | + // | redhat:distro:redhat:7 | + // | redhat:distro:redhat:8 | + // | redhat:distro:redhat:9 | + // | ubuntu:distro:ubuntu:12.10 | + // | ubuntu:distro:ubuntu:13.04 | + // | ubuntu:distro:ubuntu:14.10 | + // | ubuntu:distro:ubuntu:15.10 | + // | ubuntu:distro:ubuntu:16.10 | + // | ubuntu:distro:ubuntu:17.04 | + // | ubuntu:distro:ubuntu:17.10 | + // | ubuntu:distro:ubuntu:18.10 | + // | ubuntu:distro:ubuntu:19.04 | + // | ubuntu:distro:ubuntu:19.10 | + // | ubuntu:distro:ubuntu:20.10 | + // | ubuntu:distro:ubuntu:21.04 | + // | ubuntu:distro:ubuntu:21.10 | + // | ubuntu:distro:ubuntu:24.04 | + // | github:language:php | + // | debian:distro:debian:13 | + // | debian:distro:debian:7 | + // | redhat:distro:redhat:5 | + // | sles:distro:sles:11.1 | + // | sles:distro:sles:11.3 | + // | sles:distro:sles:11.4 | + // | sles:distro:sles:11.2 | + // | sles:distro:sles:12 | + // | sles:distro:sles:12.1 | + // | sles:distro:sles:12.2 | + // | sles:distro:sles:12.3 | + // | sles:distro:sles:12.4 | + // | sles:distro:sles:12.5 | + // | chainguard:distro:chainguard:rolling | + // | wolfi:distro:wolfi:rolling | + // | github:language:go | + // | alpine:distro:alpine:3.20 | + // | alpine:distro:alpine:3.21 | + // | alpine:distro:alpine:edge | + // | github:language:rust | + // | github:language:python | + // | sles:distro:sles:11 | + // | oracle:distro:oraclelinux:5 | + // | github:language:ruby | + // | github:language:dotnet | + // | alpine:distro:alpine:3.12 | + // | alpine:distro:alpine:3.13 | + // | alpine:distro:alpine:3.14 | + // | alpine:distro:alpine:3.15 | + // | alpine:distro:alpine:3.16 | + // | alpine:distro:alpine:3.17 | + // | alpine:distro:alpine:3.18 | + // | alpine:distro:alpine:3.19 | + // | mariner:distro:mariner:2.0 | + // | github:language:java | + // | github:language:dart | + // | amazon:distro:amazonlinux:2023 | + // | alpine:distro:alpine:3.10 | + // | alpine:distro:alpine:3.11 | + // | alpine:distro:alpine:3.4 | + // | alpine:distro:alpine:3.5 | + // | alpine:distro:alpine:3.7 | + // | alpine:distro:alpine:3.8 | + // | alpine:distro:alpine:3.9 | + // | mariner:distro:azurelinux:3.0 | + // | mariner:distro:mariner:1.0 | + // | alpine:distro:alpine:3.3 | + // | alpine:distro:alpine:3.6 | + // | amazon:distro:amazonlinux:2022 | + // | alpine:distro:alpine:3.2 | + // | github:language:swift | + // +--------------------------------------+ + + type testCase struct { + name string + provider string // from Providers.id + ecosystem string // only used when provider is "github" + osName string // only used for OS-based providers + osVersion string // only used for OS-based providers + expected string + } + + tests := []testCase{ + // NVD + { + name: "nvd provider", + provider: "nvd", + expected: "nvd:cpe", + }, + + // GitHub ecosystem tests + { + name: "github golang direct", + provider: "github", + ecosystem: "golang", + expected: "github:language:go", + }, + { + name: "github go-module ecosystem", + provider: "github", + ecosystem: "go-module", + expected: "github:language:go", + }, + { + name: "github composer ecosystem", + provider: "github", + ecosystem: "composer", + expected: "github:language:php", + }, + { + name: "github php-composer ecosystem", + provider: "github", + ecosystem: "php-composer", + expected: "github:language:php", + }, + { + name: "github cargo ecosystem", + provider: "github", + ecosystem: "cargo", + expected: "github:language:rust", + }, + { + name: "github rust-crate ecosystem", + provider: "github", + ecosystem: "rust-crate", + expected: "github:language:rust", + }, + { + name: "github pub ecosystem", + provider: "github", + ecosystem: "pub", + expected: "github:language:dart", + }, + { + name: "github dart-pub ecosystem", + provider: "github", + ecosystem: "dart-pub", + expected: "github:language:dart", + }, + { + name: "github nuget ecosystem", + provider: "github", + ecosystem: "nuget", + expected: "github:language:dotnet", + }, + { + name: "github maven ecosystem", + provider: "github", + ecosystem: "maven", + expected: "github:language:java", + }, + { + name: "github swifturl ecosystem", + provider: "github", + ecosystem: "swifturl", + expected: "github:language:swift", + }, + { + name: "github npm ecosystem", + provider: "github", + ecosystem: "npm", + expected: "github:language:javascript", + }, + { + name: "github node ecosystem", + provider: "github", + ecosystem: "node", + expected: "github:language:javascript", + }, + { + name: "github pypi ecosystem", + provider: "github", + ecosystem: "pypi", + expected: "github:language:python", + }, + { + name: "github pip ecosystem", + provider: "github", + ecosystem: "pip", + expected: "github:language:python", + }, + { + name: "github rubygems ecosystem", + provider: "github", + ecosystem: "rubygems", + expected: "github:language:ruby", + }, + { + name: "github gem ecosystem", + provider: "github", + ecosystem: "gem", + expected: "github:language:ruby", + }, + + // OS Distribution tests + { + name: "ubuntu distribution", + provider: "ubuntu", + osName: "ubuntu", + osVersion: "22.04", + expected: "ubuntu:distro:ubuntu:22.04", + }, + { + name: "redhat distribution", + provider: "rhel", + osName: "redhat", + osVersion: "8", + expected: "redhat:distro:redhat:8", + }, + { + name: "debian distribution", + provider: "debian", + osName: "debian", + osVersion: "11", + expected: "debian:distro:debian:11", + }, + { + name: "sles distribution", + provider: "sles", + osName: "sles", + osVersion: "15.5", + expected: "sles:distro:sles:15.5", + }, + { + name: "alpine distribution", + provider: "alpine", + osName: "alpine", + osVersion: "3.18", + expected: "alpine:distro:alpine:3.18", + }, + { + name: "chainguard distribution", + provider: "chainguard", + osName: "chainguard", + osVersion: "rolling", + expected: "chainguard:distro:chainguard:rolling", + }, + { + name: "wolfi distribution", + provider: "wolfi", + osName: "wolfi", + osVersion: "rolling", + expected: "wolfi:distro:wolfi:rolling", + }, + { + name: "amazon linux distribution", + provider: "amazon", + osName: "amazon", + osVersion: "2023", + expected: "amazon:distro:amazonlinux:2023", + }, + { + name: "mariner regular version", + provider: "mariner", + osName: "mariner", + osVersion: "2.0", + expected: "mariner:distro:mariner:2.0", + }, + { + name: "mariner azure version", + provider: "mariner", + osName: "mariner", + osVersion: "3.0", + expected: "mariner:distro:azurelinux:3.0", + }, + { + name: "oracle linux distribution", + provider: "oracle", + osName: "oracle", + osVersion: "8", + expected: "oracle:distro:oraclelinux:8", + }, + + // Version truncation tests + { + name: "rhel with minor version", + provider: "rhel", + osName: "redhat", + osVersion: "8.6", + expected: "redhat:distro:redhat:8", + }, + { + name: "rhel with patch version", + provider: "rhel", + osName: "redhat", + osVersion: "9.2.1", + expected: "redhat:distro:redhat:9", + }, + { + name: "oracle with minor version", + provider: "oracle", + osName: "oracle", + osVersion: "8.7", + expected: "oracle:distro:oraclelinux:8", + }, + { + name: "oracle with patch version", + provider: "oracle", + osName: "oracle", + osVersion: "9.3.1", + expected: "oracle:distro:oraclelinux:9", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + input := dbsearch.AffectedPackage{ + Vulnerability: dbsearch.VulnerabilityInfo{ + Provider: tt.provider, + }, + } + + // Add OS info for OS-based providers + if tt.osName != "" { + input.AffectedPackageInfo.OS = &dbsearch.OperatingSystem{ + Name: tt.osName, + Version: tt.osVersion, + } + } + + // Add package info for GitHub provider + if tt.provider == "github" { + input.AffectedPackageInfo.Package = &dbsearch.Package{ + Ecosystem: tt.ecosystem, + } + } + + result := v5Namespace(input) + assert.Equal(t, tt.expected, result) + }) + } +} 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..0fd3385d352 --- /dev/null +++ b/cmd/grype/cli/commands/db_search_vuln.go @@ -0,0 +1,226 @@ +package commands + +import ( + "encoding/json" + "errors" + "fmt" + "io" + "sort" + "strings" + + "github.com/hashicorp/go-multierror" + "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"` + Bounds options.DBSearchBounds `yaml:",inline" mapstructure:",squash"` + + DBOptions `yaml:",inline" mapstructure:",squash"` +} + +func DBSearchVulnerabilities(app clio.Application) *cobra.Command { + opts := &dbSearchVulnerabilityOptions{ + Format: options.DefaultDBSearchFormat(), + Vulnerability: options.DBSearchVulnerabilities{ + UseVulnIDFlag: false, // we input this through the args + }, + Bounds: options.DefaultDBSearchBounds(), + DBOptions: *dbOptionsDefault(app.ID()), + } + + cmd := &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) + }, + } + + // prevent from being shown in the grype config + type configWrapper struct { + Hidden *dbSearchVulnerabilityOptions `json:"-" yaml:"-" mapstructure:"-"` + *DBOptions `yaml:",inline" mapstructure:",squash"` + } + + return app.SetupCommand(cmd, &configWrapper{Hidden: opts, DBOptions: &opts.DBOptions}) +} + +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.FindVulnerabilities(reader, dbsearch.VulnerabilitiesOptions{ + Vulnerability: opts.Vulnerability.Specs, + RecordLimit: opts.Bounds.RecordLimit, + }) + if err != nil { + return err + } + + if len(rows) != 0 { + sb := &strings.Builder{} + err = presentDBSearchVulnerabilities(opts.Format.Output, rows, sb) + bus.Report(sb.String()) + } else { + bus.Notify("No results found") + } + + 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.Vulnerability, output io.Writer) error { + if len(structuredRows) == 0 { + return nil + } + + switch outputFormat { + case tableOutputFormat: + rows := renderDBSearchVulnerabilitiesTableRows(structuredRows) + + table := newTable(output) + + 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.Vulnerability) [][]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/db_status.go b/cmd/grype/cli/commands/db_status.go index 0b91589ec95..c826cc2c2ea 100644 --- a/cmd/grype/cli/commands/db_status.go +++ b/cmd/grype/cli/commands/db_status.go @@ -34,7 +34,7 @@ func DBStatus(app clio.Application) *cobra.Command { cmd := &cobra.Command{ Use: "status", - Short: "display database status", + Short: "Display database status and metadata", Args: cobra.ExactArgs(0), PreRunE: disableUI(app), RunE: func(_ *cobra.Command, _ []string) error { diff --git a/cmd/grype/cli/commands/db_update.go b/cmd/grype/cli/commands/db_update.go index 7a0f1bc6d6e..b095d308b19 100644 --- a/cmd/grype/cli/commands/db_update.go +++ b/cmd/grype/cli/commands/db_update.go @@ -19,7 +19,7 @@ func DBUpdate(app clio.Application) *cobra.Command { cmd := &cobra.Command{ Use: "update", - Short: "download the latest vulnerability database", + Short: "Download and install the latest vulnerability database", Args: cobra.ExactArgs(0), PreRunE: func(_ *cobra.Command, _ []string) error { // DB commands should not opt into the low-pass check filter 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..fa2cf7a3fe5 --- /dev/null +++ b/cmd/grype/cli/commands/internal/dbsearch/affected_packages.go @@ -0,0 +1,242 @@ +package dbsearch + +import ( + "errors" + "fmt" + + v6 "github.com/anchore/grype/grype/db/v6" + "github.com/anchore/grype/internal/log" + "github.com/anchore/syft/syft/cpe" +) + +var ErrNoSearchCriteria = errors.New("must provide at least one of vulnerability or package to search for") + +// AffectedPackage represents a package affected by a vulnerability +type AffectedPackage struct { + // Vulnerability is the core advisory record for a single known vulnerability from a specific provider. + Vulnerability VulnerabilityInfo `json:"vulnerability"` + + // AffectedPackageInfo is the detailed information about the affected package + AffectedPackageInfo `json:",inline"` +} + +type AffectedPackageInfo struct { + // OS identifies the operating system release that the affected package is released for + OS *OperatingSystem `json:"os,omitempty"` + + // Package identifies the name of the package in a specific ecosystem affected by the vulnerability + Package *Package `json:"package,omitempty"` + + // CPE is a Common Platform Enumeration that is affected by the vulnerability + CPE *CPE `json:"cpe,omitempty"` + + // Detail is the detailed information about the affected package + Detail v6.AffectedPackageBlob `json:"detail"` +} + +// Package represents a package name within a known ecosystem, such as "python" or "golang". +type Package struct { + + // Name is the name of the package within the ecosystem + Name string `json:"name"` + + // Ecosystem is the tooling and language ecosystem that the package is released within + Ecosystem string `json:"ecosystem"` +} + +// CPE is a Common Platform Enumeration that identifies a package +type CPE v6.Cpe + +func (c *CPE) MarshalJSON() ([]byte, error) { + return []byte(fmt.Sprintf("%q", c.String())), nil +} + +func (c *CPE) String() string { + if c == nil { + return "" + } + return v6.Cpe(*c).String() +} + +type AffectedPackagesOptions struct { + Vulnerability v6.VulnerabilitySpecifiers + Package v6.PackageSpecifiers + CPE v6.PackageSpecifiers + OS v6.OSSpecifiers + RecordLimit int +} + +func newAffectedPackageRows(affectedPkgs []v6.AffectedPackageHandle, affectedCPEs []v6.AffectedCPEHandle) (rows []AffectedPackage) { + for _, pkg := range affectedPkgs { + var detail v6.AffectedPackageBlob + if pkg.BlobValue != nil { + detail = *pkg.BlobValue + } + if pkg.Vulnerability == nil { + log.Errorf("affected package record missing vulnerability: %+v", pkg) + continue + } + + rows = append(rows, AffectedPackage{ + Vulnerability: newVulnerabilityInfo(*pkg.Vulnerability), + AffectedPackageInfo: AffectedPackageInfo{ + 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 { + log.Errorf("affected CPE record missing vulnerability: %+v", ac) + continue + } + + var c *CPE + if ac.CPE != nil { + cv := CPE(*ac.CPE) + c = &cv + } + + rows = append(rows, AffectedPackage{ + Vulnerability: newVulnerabilityInfo(*ac.Vulnerability), + AffectedPackageInfo: AffectedPackageInfo{ + CPE: c, + 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) *OperatingSystem { + if os == nil { + return nil + } + version := os.VersionNumber() + if version == "" { + version = os.Version() + } + + return &OperatingSystem{ + Name: os.Name, + Version: version, + } +} + +func FindAffectedPackages(reader interface { + v6.AffectedPackageStoreReader + v6.AffectedCPEStoreReader +}, criteria AffectedPackagesOptions) ([]AffectedPackage, error) { + allAffectedPkgs, allAffectedCPEs, err := findAffectedPackages(reader, criteria) + if err != nil { + return nil, err + } + + return newAffectedPackageRows(allAffectedPkgs, allAffectedCPEs), nil +} + +func findAffectedPackages(reader interface { //nolint:funlen + v6.AffectedPackageStoreReader + v6.AffectedCPEStoreReader +}, config AffectedPackagesOptions) ([]v6.AffectedPackageHandle, []v6.AffectedCPEHandle, error) { + var allAffectedPkgs []v6.AffectedPackageHandle + var allAffectedCPEs []v6.AffectedCPEHandle + + pkgSpecs := config.Package + cpeSpecs := config.CPE + osSpecs := config.OS + vulnSpecs := config.Vulnerability + + if config.RecordLimit == 0 { + log.Warn("no record limit set! For queries with large result sets this may result in performance issues") + } + + if len(vulnSpecs) == 0 && len(pkgSpecs) == 0 && len(cpeSpecs) == 0 { + return nil, nil, ErrNoSearchCriteria + } + + // don't allow for searching by any package AND any CPE AND any vulnerability AND any OS. Since these searches + // are oriented by primarily package, we only want to have ANY package/CPE when there is a vulnerability or OS specified. + if len(vulnSpecs) > 0 || !osSpecs.IsAny() { + if len(pkgSpecs) == 0 { + pkgSpecs = []*v6.PackageSpecifier{v6.AnyPackageSpecified} + } + + if len(cpeSpecs) == 0 { + cpeSpecs = []*v6.PackageSpecifier{v6.AnyPackageSpecified} + } + } + + 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, + Limit: config.RecordLimit, + }) + + allAffectedPkgs = append(allAffectedPkgs, affectedPkgs...) + + if err != nil { + if errors.Is(err, v6.ErrLimitReached) { + return allAffectedPkgs, allAffectedCPEs, err + } + return nil, nil, fmt.Errorf("unable to get affected packages for %s: %w", vulnSpecs, err) + } + } + + 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, + Limit: config.RecordLimit, + }) + + allAffectedCPEs = append(allAffectedCPEs, affectedCPEs...) + + if err != nil { + if errors.Is(err, v6.ErrLimitReached) { + return allAffectedPkgs, allAffectedCPEs, err + } + return nil, nil, fmt.Errorf("unable to get affected cpes for %s: %w", vulnSpecs, err) + } + } + } + + return 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..bb1317289ae --- /dev/null +++ b/cmd/grype/cli/commands/internal/dbsearch/affected_packages_test.go @@ -0,0 +1,585 @@ +package dbsearch + +import ( + "encoding/json" + "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 := AffectedPackage{ + 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)), + }, + AffectedPackageInfo: AffectedPackageInfo{ + Package: &Package{Name: "pkg1", Ecosystem: "ecosystem1"}, + CPE: &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 := json.Marshal(row) + 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 := []AffectedPackage{ + { + 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)), + }, + AffectedPackageInfo: AffectedPackageInfo{ + OS: &OperatingSystem{Name: "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", + }, + AffectedPackageInfo: AffectedPackageInfo{ + CPE: &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 := FindAffectedPackages(mockReader, criteria) + require.NoError(t, err) + + expected := []AffectedPackage{ + { + 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)), + }, + AffectedPackageInfo: AffectedPackageInfo{ + OS: &OperatingSystem{Name: "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", + }, + AffectedPackageInfo: AffectedPackageInfo{ + CPE: &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) + } +} + +func TestFindAffectedPackages(t *testing.T) { + // this test is not meant to check the correctness of the results relative to the reader but instead make certain + // that the correct calls are made to the reader based on the search criteria (we're wired up correctly). + // Additional verifications are made to check that the combinations of different specs are handled correctly. + type pkgCall struct { + pkg *v6.PackageSpecifier + options *v6.GetAffectedPackageOptions + } + + type cpeCall struct { + cpe *cpe.Attributes + options *v6.GetAffectedCPEOptions + } + + testCases := []struct { + name string + config AffectedPackagesOptions + expectedPkgCalls []pkgCall + expectedCPECalls []cpeCall + expectedErr error + }{ + { + name: "no search criteria", + config: AffectedPackagesOptions{}, + expectedErr: ErrNoSearchCriteria, + }, + { + name: "os spec alone is not enough", + config: AffectedPackagesOptions{ + OS: v6.OSSpecifiers{ + {Name: "ubuntu", MajorVersion: "20", MinorVersion: "04"}, + }, + }, + expectedErr: ErrNoSearchCriteria, + }, + { + name: "vuln spec provided", + config: AffectedPackagesOptions{ + Vulnerability: v6.VulnerabilitySpecifiers{ + {Name: "CVE-2023-0001"}, + }, + }, + expectedPkgCalls: []pkgCall{ + { + pkg: nil, + options: &v6.GetAffectedPackageOptions{ + PreloadOS: true, + PreloadPackage: true, + PreloadVulnerability: true, + PreloadBlob: true, + Vulnerabilities: v6.VulnerabilitySpecifiers{ + {Name: "CVE-2023-0001"}, + }, + Limit: 0, + }, + }, + }, + expectedCPECalls: []cpeCall{ + { + cpe: nil, + options: &v6.GetAffectedCPEOptions{ + PreloadCPE: true, + PreloadVulnerability: true, + PreloadBlob: true, + Vulnerabilities: v6.VulnerabilitySpecifiers{ + {Name: "CVE-2023-0001"}, + }, + Limit: 0, + }, + }, + }, + }, + { + name: "only cpe spec provided", + config: AffectedPackagesOptions{ + Package: v6.PackageSpecifiers{ + {CPE: &cpe.Attributes{Part: "a", Vendor: "vendor1", Product: "product1"}}, + }, + CPE: v6.PackageSpecifiers{ + {CPE: &cpe.Attributes{Part: "a", Vendor: "vendor2", Product: "product2"}}, + }, + }, + expectedPkgCalls: []pkgCall{ + { + pkg: &v6.PackageSpecifier{CPE: &cpe.Attributes{Part: "a", Vendor: "vendor1", Product: "product1"}}, + options: &v6.GetAffectedPackageOptions{ + PreloadOS: true, + PreloadPackage: true, + PreloadVulnerability: true, + PreloadBlob: true, + Vulnerabilities: nil, + Limit: 0, + }, + }, + }, + expectedCPECalls: []cpeCall{ + { + cpe: &cpe.Attributes{Part: "a", Vendor: "vendor2", Product: "product2"}, + options: &v6.GetAffectedCPEOptions{ + PreloadCPE: true, + PreloadVulnerability: true, + PreloadBlob: true, + Vulnerabilities: nil, + Limit: 0, + }, + }, + }, + expectedErr: nil, + }, + { + name: "cpe + os spec provided", + config: AffectedPackagesOptions{ + Package: v6.PackageSpecifiers{ + {CPE: &cpe.Attributes{Part: "a", Vendor: "vendor1", Product: "product1"}}, + }, + CPE: v6.PackageSpecifiers{ + {CPE: &cpe.Attributes{Part: "a", Vendor: "vendor2", Product: "product2"}}, + }, + OS: v6.OSSpecifiers{ + {Name: "debian", MajorVersion: "10"}, // this prevents an agnostic CPE search + }, + }, + expectedPkgCalls: []pkgCall{ + { + pkg: &v6.PackageSpecifier{CPE: &cpe.Attributes{Part: "a", Vendor: "vendor1", Product: "product1"}}, + options: &v6.GetAffectedPackageOptions{ + PreloadOS: true, + PreloadPackage: true, + PreloadVulnerability: true, + PreloadBlob: true, + Vulnerabilities: nil, + OSs: v6.OSSpecifiers{ + {Name: "debian", MajorVersion: "10"}, + }, + Limit: 0, + }, + }, + }, + expectedCPECalls: nil, + expectedErr: nil, + }, + { + name: "pkg spec provided", + config: AffectedPackagesOptions{ + Package: v6.PackageSpecifiers{ + {Name: "test-package", Ecosystem: "npm"}, + }, + }, + expectedPkgCalls: []pkgCall{ + { + pkg: &v6.PackageSpecifier{Name: "test-package", Ecosystem: "npm"}, + options: &v6.GetAffectedPackageOptions{ + PreloadOS: true, + PreloadPackage: true, + PreloadVulnerability: true, + PreloadBlob: true, + Vulnerabilities: nil, + Limit: 0, + }, + }, + }, + expectedCPECalls: nil, + }, + + { + name: "pkg and os specs provided", + config: AffectedPackagesOptions{ + Package: v6.PackageSpecifiers{ + {Name: "test-package", Ecosystem: "npm"}, + }, + OS: v6.OSSpecifiers{ + {Name: "debian", MajorVersion: "10"}, + }, + }, + expectedPkgCalls: []pkgCall{ + { + pkg: &v6.PackageSpecifier{Name: "test-package", Ecosystem: "npm"}, + options: &v6.GetAffectedPackageOptions{ + PreloadOS: true, + PreloadPackage: true, + PreloadVulnerability: true, + PreloadBlob: true, + OSs: v6.OSSpecifiers{ + {Name: "debian", MajorVersion: "10"}, + }, + Limit: 0, + }, + }, + }, + expectedCPECalls: nil, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + m := new(affectedMockReader) + defer m.AssertExpectations(t) + + for _, expected := range tc.expectedPkgCalls { + m.On("GetAffectedPackages", expected.pkg, mock.MatchedBy(func(actual *v6.GetAffectedPackageOptions) bool { + return cmp.Equal(actual, expected.options) + })).Return([]v6.AffectedPackageHandle{}, nil).Once() + } + + for _, expected := range tc.expectedCPECalls { + m.On("GetAffectedCPEs", expected.cpe, mock.MatchedBy(func(actual *v6.GetAffectedCPEOptions) bool { + return cmp.Equal(actual, expected.options) + })).Return([]v6.AffectedCPEHandle{}, nil).Once() + } + + _, _, err := findAffectedPackages(m, tc.config) + + if tc.expectedErr != nil { + require.ErrorIs(t, err, tc.expectedErr) + } else { + require.NoError(t, err) + } + }) + } +} + +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/matches.go b/cmd/grype/cli/commands/internal/dbsearch/matches.go new file mode 100644 index 00000000000..360a415a268 --- /dev/null +++ b/cmd/grype/cli/commands/internal/dbsearch/matches.go @@ -0,0 +1,129 @@ +package dbsearch + +import ( + "errors" + "fmt" + "sort" + + "github.com/hashicorp/go-multierror" + + v6 "github.com/anchore/grype/grype/db/v6" +) + +// Matches is the JSON document for the `db search` command +type Matches []Match + +// Match represents a pairing of a vulnerability advisory with the packages affected by the vulnerability. +type Match struct { + // Vulnerability is the core advisory record for a single known vulnerability from a specific provider. + Vulnerability VulnerabilityInfo `json:"vulnerability"` + + // AffectedPackages is the list of packages affected by the vulnerability. + AffectedPackages []AffectedPackageInfo `json:"packages"` +} + +func (m Match) Flatten() []AffectedPackage { + var rows []AffectedPackage + for _, pkg := range m.AffectedPackages { + rows = append(rows, AffectedPackage{ + Vulnerability: m.Vulnerability, + AffectedPackageInfo: pkg, + }) + } + return rows +} + +func (m Matches) Flatten() []AffectedPackage { + var rows []AffectedPackage + for _, r := range m { + rows = append(rows, r.Flatten()...) + } + return rows +} + +func newMatchesRows(affectedPkgs []v6.AffectedPackageHandle, affectedCPEs []v6.AffectedCPEHandle) (rows []Match, retErr error) { + var affectedPkgsByVuln = make(map[v6.ID][]AffectedPackageInfo) + var vulnsByID = make(map[v6.ID]v6.VulnerabilityHandle) + + for _, pkg := range affectedPkgs { + var detail v6.AffectedPackageBlob + if pkg.BlobValue != nil { + detail = *pkg.BlobValue + } + if pkg.Vulnerability == nil { + retErr = multierror.Append(retErr, fmt.Errorf("affected package record missing vulnerability: %+v", pkg)) + continue + } + if _, ok := vulnsByID[pkg.Vulnerability.ID]; !ok { + vulnsByID[pkg.Vulnerability.ID] = *pkg.Vulnerability + } + + aff := AffectedPackageInfo{ + OS: toOS(pkg.OperatingSystem), + Package: toPackage(pkg.Package), + Detail: detail, + } + + affectedPkgsByVuln[pkg.Vulnerability.ID] = append(affectedPkgsByVuln[pkg.Vulnerability.ID], aff) + } + + for _, ac := range affectedCPEs { + var detail v6.AffectedPackageBlob + if ac.BlobValue != nil { + detail = *ac.BlobValue + } + if ac.Vulnerability == nil { + retErr = multierror.Append(retErr, fmt.Errorf("affected CPE record missing vulnerability: %+v", ac)) + continue + } + + var c *CPE + if ac.CPE != nil { + cv := CPE(*ac.CPE) + c = &cv + } + + if _, ok := vulnsByID[ac.Vulnerability.ID]; !ok { + vulnsByID[ac.Vulnerability.ID] = *ac.Vulnerability + } + + aff := AffectedPackageInfo{ + CPE: c, + Detail: detail, + } + + affectedPkgsByVuln[ac.Vulnerability.ID] = append(affectedPkgsByVuln[ac.Vulnerability.ID], aff) + } + + for vulnID, vuln := range vulnsByID { + rows = append(rows, Match{ + Vulnerability: newVulnerabilityInfo(vuln), + AffectedPackages: affectedPkgsByVuln[vulnID], + }) + } + + sort.Slice(rows, func(i, j int) bool { + return rows[i].Vulnerability.ID < rows[j].Vulnerability.ID + }) + + return rows, retErr +} + +func FindMatches(reader interface { + v6.AffectedPackageStoreReader + v6.AffectedCPEStoreReader +}, criteria AffectedPackagesOptions) (Matches, error) { + allAffectedPkgs, allAffectedCPEs, fetchErr := findAffectedPackages(reader, criteria) + + if fetchErr != nil { + if !errors.Is(fetchErr, v6.ErrLimitReached) { + return nil, fetchErr + } + } + + rows, presErr := newMatchesRows(allAffectedPkgs, allAffectedCPEs) + if presErr != nil { + return nil, presErr + } + return rows, fetchErr +} diff --git a/cmd/grype/cli/commands/internal/dbsearch/versions.go b/cmd/grype/cli/commands/internal/dbsearch/versions.go new file mode 100644 index 00000000000..dc4892852ff --- /dev/null +++ b/cmd/grype/cli/commands/internal/dbsearch/versions.go @@ -0,0 +1,9 @@ +package dbsearch + +const ( + // MatchesSchemaVersion is the schema version for the `db search ` command + MatchesSchemaVersion = "1.0.0" + + // VulnerabilitiesSchemaVersion is the schema version for the `db search vuln` command + VulnerabilitiesSchemaVersion = "1.0.0" +) 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..fc9ea5cbb3c --- /dev/null +++ b/cmd/grype/cli/commands/internal/dbsearch/vulnerabilities.go @@ -0,0 +1,192 @@ +package dbsearch + +import ( + "errors" + "fmt" + "sort" + "time" + + v6 "github.com/anchore/grype/grype/db/v6" + "github.com/anchore/grype/internal/log" +) + +// Vulnerabilities is the JSON document for the `db search vuln` command +type Vulnerabilities []Vulnerability + +// Vulnerability represents the core advisory record for a single known vulnerability from a specific provider. +type Vulnerability struct { + VulnerabilityInfo `json:",inline"` + + // OperatingSystems is a list of operating systems affected by the vulnerability + OperatingSystems []OperatingSystem `json:"operating_systems"` + + // AffectedPackages is the number of packages affected by the vulnerability + AffectedPackages int `json:"affected_packages"` +} + +type VulnerabilityInfo struct { + v6.VulnerabilityBlob `json:",inline"` + + // Provider is the upstream data processor (usually Vunnel) that is responsible for vulnerability records. Each provider + // should be scoped to a specific vulnerability dataset, for instance, the "ubuntu" provider for all records from + // Canonicals' Ubuntu Security Notices (for all Ubuntu distro versions). + Provider string `json:"provider"` + + // Status conveys the actionability of the current record (one of "active", "analyzing", "rejected", "disputed") + Status string `json:"status"` + + // PublishedDate is the date the vulnerability record was first published + PublishedDate *time.Time `json:"published_date,omitempty"` + + // ModifiedDate is the date the vulnerability record was last modified + ModifiedDate *time.Time `json:"modified_date,omitempty"` + + // WithdrawnDate is the date the vulnerability record was withdrawn + WithdrawnDate *time.Time `json:"withdrawn_date,omitempty"` +} + +// OperatingSystem represents specific release of an operating system. +type OperatingSystem struct { + // Name is the operating system family name (e.g. "debian") + Name string `json:"name"` + + // Version is the semver-ish or codename for the release of the operating system + Version string `json:"version"` +} + +type vulnerabilityAffectedPackageJoin struct { + Vulnerability v6.VulnerabilityHandle + OperatingSystems []v6.OperatingSystem + AffectedPackages int +} + +type VulnerabilitiesOptions struct { + Vulnerability v6.VulnerabilitySpecifiers + RecordLimit int +} + +func newVulnerabilityRows(vaps ...vulnerabilityAffectedPackageJoin) (rows []Vulnerability) { + 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) Vulnerability { + return Vulnerability{ + 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: string(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 FindVulnerabilities(reader interface { //nolint:funlen + v6.VulnerabilityStoreReader + v6.AffectedPackageStoreReader +}, config VulnerabilitiesOptions) ([]Vulnerability, error) { + log.WithFields("vulnSpecs", len(config.Vulnerability)).Debug("fetching vulnerabilities") + + if config.RecordLimit == 0 { + log.Warn("no record limit set! For queries with large result sets this may result in performance issues") + } + + var vulns []v6.VulnerabilityHandle + var limitReached bool + for _, vulnSpec := range config.Vulnerability { + vs, err := reader.GetVulnerabilities(&vulnSpec, &v6.GetVulnerabilityOptions{ + Preload: true, + Limit: config.RecordLimit, + }) + if err != nil { + if !errors.Is(err, v6.ErrLimitReached) { + return nil, fmt.Errorf("unable to get vulnerabilities: %w", err) + } + limitReached = true + break + } + + 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, fetchErr := reader.GetAffectedPackages(nil, &v6.GetAffectedPackageOptions{ + PreloadOS: true, + Vulnerabilities: []v6.VulnerabilitySpecifier{ + { + ID: vuln.ID, + }, + }, + Limit: config.RecordLimit, + }) + if fetchErr != nil { + if !errors.Is(fetchErr, v6.ErrLimitReached) { + return nil, fmt.Errorf("unable to get affected packages: %w", fetchErr) + } + limitReached = true + } + + 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), + }) + + if errors.Is(fetchErr, v6.ErrLimitReached) { + break + } + } + + var err error + if limitReached { + err = v6.ErrLimitReached + } + + return newVulnerabilityRows(pairs...), err +} 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..4eca1219ddc --- /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 := []Vulnerability{ + { + 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 := FindVulnerabilities(mockReader, VulnerabilitiesOptions{Vulnerability: vulnSpecs}) + require.NoError(t, err) + + expected := []Vulnerability{ + { + 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/internal/jsonschema/main.go b/cmd/grype/cli/commands/internal/jsonschema/main.go new file mode 100644 index 00000000000..c8467536c73 --- /dev/null +++ b/cmd/grype/cli/commands/internal/jsonschema/main.go @@ -0,0 +1,263 @@ +package main + +import ( + "bytes" + "encoding/json" + "fmt" + "go/ast" + "io" + "os" + "os/exec" + "path/filepath" + "reflect" + "strings" + + "github.com/invopop/jsonschema" + "golang.org/x/tools/go/packages" + + "github.com/anchore/grype/cmd/grype/cli/commands/internal/dbsearch" +) + +func main() { + pkgPatterns := []string{"../dbsearch", "../../../../../../grype/db/v6"} + + comments := parseCommentsFromPackages(pkgPatterns) + fmt.Printf("Extracted field comments from %d structs\n", len(comments)) + + compose(dbsearch.Matches{}, "db-search", dbsearch.MatchesSchemaVersion, comments) + compose(dbsearch.Vulnerabilities{}, "db-search-vuln", dbsearch.VulnerabilitiesSchemaVersion, comments) +} + +func compose(document any, component, version string, comments map[string]map[string]string) { + write(encode(build(document, component, version, comments)), component, version) +} + +func write(schema []byte, component, version string) { + parent := filepath.Join(repoRoot(), "schema", "grype", component, "json") + schemaPath := filepath.Join(parent, fmt.Sprintf("schema-%s.json", version)) + latestSchemaPath := filepath.Join(parent, "schema-latest.json") + + if _, err := os.Stat(schemaPath); !os.IsNotExist(err) { + // check if the schema is the same... + existingFh, err := os.Open(schemaPath) + if err != nil { + panic(err) + } + + existingSchemaBytes, err := io.ReadAll(existingFh) + if err != nil { + panic(err) + } + + if bytes.Equal(existingSchemaBytes, schema) { + // the generated schema is the same, bail with no error :) + fmt.Printf("No change to the existing %q schema!\n", component) + return + } + + // the generated schema is different, bail with error :( + fmt.Printf("Cowardly refusing to overwrite existing %q schema (%s)!\nSee the README.md for how to increment\n", component, schemaPath) + os.Exit(1) + } + + fh, err := os.Create(schemaPath) + if err != nil { + panic(err) + } + defer fh.Close() + + _, err = fh.Write(schema) + if err != nil { + panic(err) + } + + latestFile, err := os.Create(latestSchemaPath) + if err != nil { + panic(err) + } + defer latestFile.Close() + + _, err = latestFile.Write(schema) + if err != nil { + panic(err) + } + + fmt.Printf("Wrote new %q schema to %q\n", component, schemaPath) +} + +func encode(schema *jsonschema.Schema) []byte { + newSchemaBuffer := new(bytes.Buffer) + enc := json.NewEncoder(newSchemaBuffer) + // prevent > and < from being escaped in the payload + enc.SetEscapeHTML(false) + enc.SetIndent("", " ") + err := enc.Encode(&schema) + if err != nil { + panic(err) + } + + return newSchemaBuffer.Bytes() +} + +func build(document any, component, version string, comments map[string]map[string]string) *jsonschema.Schema { + reflector := &jsonschema.Reflector{ + BaseSchemaID: schemaID(component, version), + AllowAdditionalProperties: true, + Namer: func(r reflect.Type) string { + return strings.TrimPrefix(r.Name(), "JSON") + }, + } + + documentSchema := reflector.ReflectFromType(reflect.TypeOf(document)) + + for structName, fields := range comments { + if structSchema, exists := documentSchema.Definitions[structName]; exists { + if structSchema.Definitions == nil { + structSchema.Definitions = make(map[string]*jsonschema.Schema) + } + for fieldName, comment := range fields { + if fieldName == "" { + // struct-level comment + structSchema.Description = comment + continue + } + // field level comment + if comment == "" { + continue + } + if _, exists := structSchema.Properties.Get(fieldName); exists { + fieldSchema, exists := structSchema.Definitions[fieldName] + if exists { + fieldSchema.Description = comment + } else { + fieldSchema = &jsonschema.Schema{ + Description: comment, + } + } + structSchema.Definitions[fieldName] = fieldSchema + } + } + documentSchema.Definitions[structName] = structSchema + } + } + + return documentSchema +} + +// parseCommentsFromPackages scans multiple packages and collects field comments for structs. +func parseCommentsFromPackages(pkgPatterns []string) map[string]map[string]string { + commentMap := make(map[string]map[string]string) + + cfg := &packages.Config{ + Mode: packages.NeedFiles | packages.NeedSyntax | packages.NeedDeps | packages.NeedImports, + } + pkgs, err := packages.Load(cfg, pkgPatterns...) + if err != nil { + panic(fmt.Errorf("failed to load packages: %w", err)) + } + + for _, pkg := range pkgs { + for _, file := range pkg.Syntax { + fileComments := parseFileComments(file) + for structName, fields := range fileComments { + if _, exists := commentMap[structName]; !exists { + commentMap[structName] = fields + } + } + } + } + return commentMap +} + +// parseFileComments extracts comments for structs and their fields in a single file. +func parseFileComments(node *ast.File) map[string]map[string]string { + commentMap := make(map[string]map[string]string) + + ast.Inspect(node, func(n ast.Node) bool { + ts, ok := n.(*ast.TypeSpec) + if !ok { + return true + } + st, ok := ts.Type.(*ast.StructType) + if !ok { + return true + } + + structName := ts.Name.Name + fieldComments := make(map[string]string) + + // extract struct-level comment + if ts.Doc != nil { + structComment := strings.TrimSpace(ts.Doc.Text()) + if !strings.Contains(structComment, "TODO:") { + fieldComments[""] = cleanComment(structComment) + } + } + + // extract field-level comments + for _, field := range st.Fields.List { + if len(field.Names) == 0 { + continue + } + fieldName := field.Names[0].Name + jsonTag := getJSONTag(field) + + if field.Doc != nil { + comment := strings.TrimSpace(field.Doc.Text()) + if strings.Contains(comment, "TODO:") { + continue + } + if jsonTag != "" { + fieldComments[jsonTag] = cleanComment(comment) + } else { + fieldComments[fieldName] = cleanComment(comment) + } + } + } + + if len(fieldComments) > 0 { + commentMap[structName] = fieldComments + } + return true + }) + + return commentMap +} + +func cleanComment(comment string) string { + // remove the first word, since that is the field name (if following go-doc patterns) + split := strings.SplitN(comment, " ", 2) + if len(split) > 1 { + comment = split[1] + } + + return strings.TrimSpace(strings.ReplaceAll(comment, "\"", "'")) +} + +func getJSONTag(field *ast.Field) string { + if field.Tag != nil { + tagValue := strings.Trim(field.Tag.Value, "`") + structTag := reflect.StructTag(tagValue) + if jsonTag, ok := structTag.Lookup("json"); ok { + jsonParts := strings.Split(jsonTag, ",") + return strings.TrimSpace(jsonParts[0]) + } + } + return "" +} + +func schemaID(component, version string) jsonschema.ID { + return jsonschema.ID(fmt.Sprintf("anchore.io/schema/grype/%s/json/%s", component, version)) +} + +func repoRoot() string { + root, err := exec.Command("git", "rev-parse", "--show-toplevel").Output() + if err != nil { + panic(fmt.Errorf("unable to find repo root dir: %+v", err)) + } + absRepoRoot, err := filepath.Abs(strings.TrimSpace(string(root))) + if err != nil { + panic(fmt.Errorf("unable to get abs path to repo root: %w", err)) + } + return absRepoRoot +} diff --git a/cmd/grype/cli/commands/util.go b/cmd/grype/cli/commands/util.go index 5ac90ca7114..0a60dec73aa 100644 --- a/cmd/grype/cli/commands/util.go +++ b/cmd/grype/cli/commands/util.go @@ -1,12 +1,15 @@ package commands import ( + "bytes" "fmt" + "io" "os" "strings" "sync" "github.com/hashicorp/go-multierror" + "github.com/olekukonko/tablewriter" "github.com/spf13/cobra" "golang.org/x/exp/maps" @@ -83,3 +86,66 @@ func appendErrors(errs error, err ...error) error { } return multierror.Append(errs, err...) } + +func newTable(output io.Writer) *tablewriter.Table { + // we use a trimming writer to ensure that the table is not padded with spaces when there is a single long row + // and several short rows. AFAICT there is no table setting to control this behavior. Why do it as a writer? So + // we don't need to buffer the entire table in memory before writing it out. + table := tablewriter.NewWriter(newTrimmingWriter(output)) + 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) + return table +} + +// trimmingWriter is a writer that trims whitespace from the end of each line. It is assumed that whole lines are +// passed to Write() calls (no partial lines). +type trimmingWriter struct { + output io.Writer + buffer bytes.Buffer +} + +func newTrimmingWriter(w io.Writer) *trimmingWriter { + return &trimmingWriter{output: w} +} + +func (tw *trimmingWriter) Write(p []byte) (int, error) { + for _, b := range p { + switch b { + case '\n': + // write a newline and discard any buffered spaces + _, err := tw.output.Write([]byte{'\n'}) + if err != nil { + return 0, err + } + tw.buffer.Reset() + case ' ', '\t': + // buffer spaces and tabs + tw.buffer.WriteByte(b) + default: + // write any buffered spaces, then the non-whitespace character + if tw.buffer.Len() > 0 { + _, err := tw.output.Write(tw.buffer.Bytes()) + if err != nil { + return 0, err + } + tw.buffer.Reset() + } + _, err := tw.output.Write([]byte{b}) + if err != nil { + return 0, err + } + } + } + + return len(p), nil +} diff --git a/cmd/grype/cli/commands/util_test.go b/cmd/grype/cli/commands/util_test.go index fb04777c0db..8f868708701 100644 --- a/cmd/grype/cli/commands/util_test.go +++ b/cmd/grype/cli/commands/util_test.go @@ -1,12 +1,14 @@ package commands import ( + "bytes" "fmt" "sync" "sync/atomic" "testing" "github.com/hashicorp/go-multierror" + "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) @@ -163,3 +165,59 @@ func Test_parallelMapped(t *testing.T) { }) } } + +func TestTrimmingWriter(t *testing.T) { + tests := []struct { + name string + input string + expected string + }{ + { + name: "removes trailing spaces", + input: "line with trailing spaces \nline with no trailing spaces\n", + expected: "line with trailing spaces\nline with no trailing spaces\n", + }, + { + name: "handles multiple spaces and tabs", + input: "line with tabs\t\t\t\nline with spaces \t \t\t\n", + expected: "line with tabs\nline with spaces\n", + }, + { + name: "handles embedded whitespace", + input: "line one with spaces and tabs\t\t\nnext line\t\n", + expected: "line one with spaces and tabs\nnext line\n", + }, + { + name: "handles empty input", + input: "", + expected: "", + }, + { + name: "handles only spaces and tabs", + input: " \t\t\n \t \t\n", + expected: "\n\n", + }, + { + name: "handles single character input", + input: "a", + expected: "a", + }, + { + name: "handles input ending without newline", + input: "line without newline ", + expected: "line without newline", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var output bytes.Buffer + writer := newTrimmingWriter(&output) + + n, err := writer.Write([]byte(tt.input)) + assert.NoError(t, err) + assert.Equal(t, len(tt.input), n) + assert.Equal(t, tt.expected, output.String()) + }) + } +} diff --git a/cmd/grype/cli/options/database_search_bounds.go b/cmd/grype/cli/options/database_search_bounds.go new file mode 100644 index 00000000000..500ee069032 --- /dev/null +++ b/cmd/grype/cli/options/database_search_bounds.go @@ -0,0 +1,29 @@ +package options + +import ( + "fmt" + + "github.com/anchore/clio" +) + +type DBSearchBounds struct { + RecordLimit int `yaml:"limit" json:"limit" mapstructure:"limit"` +} + +func DefaultDBSearchBounds() DBSearchBounds { + return DBSearchBounds{ + RecordLimit: 5000, + } +} + +func (o *DBSearchBounds) AddFlags(flags clio.FlagSet) { + flags.IntVarP(&o.RecordLimit, "limit", "", "limit the number of results returned, use 0 for no limit (supports DB schema v6+ only)") +} + +func (o *DBSearchBounds) PostLoad() error { + if o.RecordLimit < 0 { + return fmt.Errorf("limit must be a positive integer") + } + + return nil +} 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..0ea068f5e78 --- /dev/null +++ b/cmd/grype/cli/options/database_search_format.go @@ -0,0 +1,36 @@ +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 DefaultDBSearchFormat() DBSearchFormat { + return DBSearchFormat{ + Output: "table", + Allowable: []string{"table", "json"}, + } +} + +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..2f1942b214f --- /dev/null +++ b/cmd/grype/cli/options/database_search_os.go @@ -0,0 +1,98 @@ +package options + +import ( + "errors" + "fmt" + "strings" + "unicode" + + "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') (supports DB schema v6+ only)") +} + +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: + // no-op + 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 + if unicode.IsDigit(rune(version[0])) { + 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 + return &v6.OSSpecifier{Name: name, LabelVersion: version}, nil + + default: + return nil, fmt.Errorf("invalid distro name@version: %q", osValue) + } +} diff --git a/cmd/grype/cli/options/database_search_os_test.go b/cmd/grype/cli/options/database_search_os_test.go new file mode 100644 index 00000000000..f4ae9af4b78 --- /dev/null +++ b/cmd/grype/cli/options/database_search_os_test.go @@ -0,0 +1,108 @@ +package options + +import ( + "testing" + + "github.com/google/go-cmp/cmp" + "github.com/stretchr/testify/require" + + v6 "github.com/anchore/grype/grype/db/v6" +) + +func TestDBSearchOSsPostLoad(t *testing.T) { + testCases := []struct { + name string + input DBSearchOSs + expectedSpecs v6.OSSpecifiers + expectedErrMsg string + }{ + { + name: "no OS input (any OS)", + input: DBSearchOSs{}, + expectedSpecs: []*v6.OSSpecifier{v6.AnyOSSpecified}, + }, + { + name: "valid OS name only", + input: DBSearchOSs{ + OSs: []string{"ubuntu"}, + }, + expectedSpecs: []*v6.OSSpecifier{ + {Name: "ubuntu", AllowMultiple: true}, + }, + }, + { + name: "valid OS with major version", + input: DBSearchOSs{ + OSs: []string{"ubuntu@20"}, + }, + expectedSpecs: []*v6.OSSpecifier{ + {Name: "ubuntu", MajorVersion: "20", AllowMultiple: true}, + }, + }, + { + name: "valid OS with major and minor version", + input: DBSearchOSs{ + OSs: []string{"ubuntu@20.04"}, + }, + expectedSpecs: []*v6.OSSpecifier{ + {Name: "ubuntu", MajorVersion: "20", MinorVersion: "04", AllowMultiple: true}, + }, + }, + { + name: "valid OS with codename", + input: DBSearchOSs{ + OSs: []string{"ubuntu@focal"}, + }, + expectedSpecs: []*v6.OSSpecifier{ + {Name: "ubuntu", LabelVersion: "focal", AllowMultiple: true}, + }, + }, + { + name: "invalid OS version (too many parts)", + input: DBSearchOSs{ + OSs: []string{"ubuntu@20.04.1"}, + }, + expectedErrMsg: "invalid distro version provided: patch version ignored", + }, + { + name: "invalid OS format with colon", + input: DBSearchOSs{ + OSs: []string{"ubuntu:20"}, + }, + expectedSpecs: []*v6.OSSpecifier{ + {Name: "ubuntu", MajorVersion: "20", AllowMultiple: true}, + }, + }, + { + name: "invalid OS with empty version", + input: DBSearchOSs{ + OSs: []string{"ubuntu@"}, + }, + expectedErrMsg: "invalid distro version provided", + }, + { + name: "invalid OS name@version format", + input: DBSearchOSs{ + OSs: []string{"ubuntu@20@04"}, + }, + expectedErrMsg: "invalid distro name@version", + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + err := tc.input.PostLoad() + + if tc.expectedErrMsg != "" { + require.Error(t, err) + require.ErrorContains(t, err, tc.expectedErrMsg) + return + } + + require.NoError(t, err) + if d := cmp.Diff(tc.expectedSpecs, tc.input.Specs); d != "" { + t.Errorf("unexpected OS specifiers (-want +got):\n%s", d) + } + }) + } +} 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..d265288e835 --- /dev/null +++ b/cmd/grype/cli/options/database_search_packages.go @@ -0,0 +1,82 @@ +package options + +import ( + "errors" + "fmt" + "strings" + + "github.com/anchore/clio" + v6 "github.com/anchore/grype/grype/db/v6" + "github.com/anchore/grype/internal/log" + "github.com/anchore/packageurl-go" + "github.com/anchore/syft/syft/cpe" +) + +type DBSearchPackages struct { + Packages []string `yaml:"packages" json:"packages" mapstructure:"packages"` + 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.StringArrayVarP(&o.Packages, "pkg", "", "package name/CPE/PURL to search for (supports DB schema v6+ only)") + flags.StringVarP(&o.Ecosystem, "ecosystem", "", "ecosystem of the package to search within (supports DB schema v6+ only)") +} + +func (o *DBSearchPackages) PostLoad() error { + // note: this may be called multiple times, so we need to reset the specs each time + o.PkgSpecs = nil + o.CPESpecs = nil + + for _, p := range o.Packages { + switch { + case strings.HasPrefix(p, "cpe:"): + c, err := cpe.NewAttributes(p) + if err != nil { + return fmt.Errorf("invalid CPE from %q: %w", o.Packages, err) + } + + if c.Version != "" || c.Update != "" { + log.Warnf("ignoring version and update values for %q", p) + c.Version = "" + c.Update = "" + } + + 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.Packages, err) + } + + if purl.Version != "" || len(purl.Qualifiers) > 0 { + log.Warnf("ignoring version and qualifiers for package URL %q", purl) + } + + o.PkgSpecs = append(o.PkgSpecs, &v6.PackageSpecifier{Name: purl.Name, Ecosystem: purl.Type}) + o.CPESpecs = append(o.CPESpecs, &v6.PackageSpecifier{CPE: &cpe.Attributes{Part: "a", Product: purl.Name, TargetSW: purl.Type}}) + + default: + o.PkgSpecs = append(o.PkgSpecs, &v6.PackageSpecifier{Name: p, Ecosystem: o.Ecosystem}) + o.CPESpecs = append(o.CPESpecs, &v6.PackageSpecifier{ + CPE: &cpe.Attributes{Part: "a", Product: p}, + }) + } + } + + if len(o.Packages) == 0 { + if o.Ecosystem != "" { + o.PkgSpecs = append(o.PkgSpecs, &v6.PackageSpecifier{Ecosystem: o.Ecosystem}) + o.CPESpecs = append(o.CPESpecs, &v6.PackageSpecifier{CPE: &cpe.Attributes{TargetSW: o.Ecosystem}}) + } + } + + return nil +} diff --git a/cmd/grype/cli/options/database_search_packages_test.go b/cmd/grype/cli/options/database_search_packages_test.go new file mode 100644 index 00000000000..a806dc4213c --- /dev/null +++ b/cmd/grype/cli/options/database_search_packages_test.go @@ -0,0 +1,112 @@ +package options + +import ( + "testing" + + "github.com/google/go-cmp/cmp" + "github.com/stretchr/testify/require" + + v6 "github.com/anchore/grype/grype/db/v6" + "github.com/anchore/syft/syft/cpe" +) + +func TestDBSearchPackagesPostLoad(t *testing.T) { + testCases := []struct { + name string + input DBSearchPackages + expectedPkg v6.PackageSpecifiers + expectedCPE v6.PackageSpecifiers + expectedErrMsg string + }{ + { + name: "valid CPE", + input: DBSearchPackages{ + Packages: []string{"cpe:2.3:a:vendor:product:1.0:*:*:*:*:*:*:*"}, + }, + expectedPkg: v6.PackageSpecifiers{ + {CPE: &cpe.Attributes{Part: "a", Vendor: "vendor", Product: "product"}}, + }, + expectedCPE: v6.PackageSpecifiers{ + {CPE: &cpe.Attributes{Part: "a", Vendor: "vendor", Product: "product"}}, + }, + }, + { + name: "valid PURL", + input: DBSearchPackages{ + Packages: []string{"pkg:npm/package-name@1.0.0"}, + }, + expectedPkg: v6.PackageSpecifiers{ + {Name: "package-name", Ecosystem: "npm"}, + }, + expectedCPE: v6.PackageSpecifiers{ + {CPE: &cpe.Attributes{Part: "a", Product: "package-name", TargetSW: "npm"}}, + }, + }, + { + name: "plain package name", + input: DBSearchPackages{ + Packages: []string{"package-name"}, + }, + expectedPkg: v6.PackageSpecifiers{ + {Name: "package-name"}, + }, + expectedCPE: v6.PackageSpecifiers{ + {CPE: &cpe.Attributes{Part: "a", Product: "package-name"}}, + }, + }, + { + name: "ecosystem without packages", + input: DBSearchPackages{ + Ecosystem: "npm", + }, + expectedPkg: v6.PackageSpecifiers{ + {Ecosystem: "npm"}, + }, + expectedCPE: v6.PackageSpecifiers{ + {CPE: &cpe.Attributes{TargetSW: "npm"}}, + }, + }, + { + name: "conflicting PURL and ecosystem", + input: DBSearchPackages{ + Packages: []string{"pkg:npm/package-name@1.0.0"}, + Ecosystem: "npm", + }, + expectedErrMsg: "cannot specify both package URL and ecosystem", + }, + { + name: "invalid CPE", + input: DBSearchPackages{ + Packages: []string{"cpe:2.3:a:$%&^*%"}, + }, + expectedErrMsg: "invalid CPE", + }, + { + name: "invalid PURL", + input: DBSearchPackages{ + Packages: []string{"pkg:invalid"}, + }, + expectedErrMsg: "invalid package URL", + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + err := tc.input.PostLoad() + + if tc.expectedErrMsg != "" { + require.Error(t, err) + require.ErrorContains(t, err, tc.expectedErrMsg) + return + } + require.NoError(t, err) + if d := cmp.Diff(tc.expectedPkg, tc.input.PkgSpecs); d != "" { + t.Errorf("unexpected package specifiers (-want +got):\n%s", d) + } + if d := cmp.Diff(tc.expectedCPE, tc.input.CPESpecs); d != "" { + t.Errorf("unexpected CPE specifiers (-want +got):\n%s", d) + } + + }) + } +} 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..53c63eaff1e --- /dev/null +++ b/cmd/grype/cli/options/database_search_vulnerabilities.go @@ -0,0 +1,87 @@ +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"` + 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 (supports DB schema v6+ only)") + } + flags.StringVarP(&c.PublishedAfter, "published-after", "", "only show vulnerabilities originally published after the given date (format: YYYY-MM-DD) (supports DB schema v6+ only)") + flags.StringVarP(&c.ModifiedAfter, "modified-after", "", "only show vulnerabilities originally published or modified since the given date (format: YYYY-MM-DD) (supports DB schema v6+ only)") + flags.StringArrayVarP(&c.Providers, "provider", "", "only show vulnerabilities from the given provider (supports DB schema v6+ only)") +} + +func (c *DBSearchVulnerabilities) PostLoad() error { + // note: this may be called multiple times, so we need to reset the specs each time + c.Specs = nil + + 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, + Providers: c.Providers, + }) + } + + if len(specs) == 0 { + if c.PublishedAfter != "" || c.ModifiedAfter != "" || len(c.Providers) > 0 { + specs = append(specs, v6.VulnerabilitySpecifier{ + PublishedAfter: publishedAfter, + ModifiedAfter: modifiedAfter, + Providers: c.Providers, + }) + } + } + + c.Specs = specs + + return nil +} diff --git a/cmd/grype/cli/options/database_search_vulnerabilities_test.go b/cmd/grype/cli/options/database_search_vulnerabilities_test.go new file mode 100644 index 00000000000..d63677748dc --- /dev/null +++ b/cmd/grype/cli/options/database_search_vulnerabilities_test.go @@ -0,0 +1,120 @@ +package options + +import ( + "testing" + "time" + + "github.com/google/go-cmp/cmp" + "github.com/stretchr/testify/require" + + v6 "github.com/anchore/grype/grype/db/v6" +) + +func TestDBSearchVulnerabilitiesPostLoad(t *testing.T) { + testCases := []struct { + name string + input DBSearchVulnerabilities + expectedSpecs v6.VulnerabilitySpecifiers + expectedErrMsg string + }{ + { + name: "single vulnerability ID", + input: DBSearchVulnerabilities{ + VulnerabilityIDs: []string{"CVE-2023-0001"}, + }, + expectedSpecs: v6.VulnerabilitySpecifiers{ + {Name: "CVE-2023-0001"}, + }, + }, + { + name: "multiple vulnerability IDs", + input: DBSearchVulnerabilities{ + VulnerabilityIDs: []string{"CVE-2023-0001", "GHSA-1234"}, + }, + expectedSpecs: v6.VulnerabilitySpecifiers{ + {Name: "CVE-2023-0001"}, + {Name: "GHSA-1234"}, + }, + }, + { + name: "published-after set", + input: DBSearchVulnerabilities{ + PublishedAfter: "2023-01-01", + }, + expectedSpecs: v6.VulnerabilitySpecifiers{ + {PublishedAfter: parseTime("2023-01-01")}, + }, + }, + { + name: "modified-after set", + input: DBSearchVulnerabilities{ + ModifiedAfter: "2023-02-01", + }, + expectedSpecs: v6.VulnerabilitySpecifiers{ + {ModifiedAfter: parseTime("2023-02-01")}, + }, + }, + { + name: "both published-after and modified-after set", + input: DBSearchVulnerabilities{ + PublishedAfter: "2023-01-01", + ModifiedAfter: "2023-02-01", + }, + expectedErrMsg: "only one of --published-after or --modified-after can be set", + }, + { + name: "invalid date for published-after", + input: DBSearchVulnerabilities{ + PublishedAfter: "invalid-date", + }, + expectedErrMsg: "invalid date format for published-after", + }, + { + name: "invalid date for modified-after", + input: DBSearchVulnerabilities{ + ModifiedAfter: "invalid-date", + }, + expectedErrMsg: "invalid date format for modified-after", + }, + { + name: "vulnerability ID with providers", + input: DBSearchVulnerabilities{ + VulnerabilityIDs: []string{"CVE-2023-0001"}, + Providers: []string{"provider1"}, + }, + expectedSpecs: v6.VulnerabilitySpecifiers{ + {Name: "CVE-2023-0001", Providers: []string{"provider1"}}, + }, + }, + { + name: "providers without vulnerability IDs", + input: DBSearchVulnerabilities{ + Providers: []string{"provider1", "provider2"}, + }, + expectedSpecs: v6.VulnerabilitySpecifiers{ + {Providers: []string{"provider1", "provider2"}}, + }, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + err := tc.input.PostLoad() + + if tc.expectedErrMsg != "" { + require.Error(t, err) + require.ErrorContains(t, err, tc.expectedErrMsg) + return + } + require.NoError(t, err) + if d := cmp.Diff(tc.expectedSpecs, tc.input.Specs); d != "" { + t.Errorf("unexpected vulnerability specifiers (-want +got):\n%s", d) + } + }) + } +} + +func parseTime(value string) *time.Time { + t, _ := time.Parse("2006-01-02", value) + return &t +} diff --git a/go.mod b/go.mod index 957fee8c81a..12ee6e87f78 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 @@ -64,6 +65,11 @@ require ( gorm.io/gorm v1.25.12 ) +require ( + github.com/invopop/jsonschema v0.7.0 + golang.org/x/tools v0.23.0 +) + require ( cloud.google.com/go v0.112.1 // indirect cloud.google.com/go/compute v1.24.0 // indirect @@ -151,6 +157,7 @@ require ( github.com/hashicorp/go-safetemp v1.0.0 // indirect github.com/hashicorp/hcl v1.0.0 // indirect github.com/huandu/xstrings v1.5.0 // indirect + github.com/iancoleman/orderedmap v0.0.0-20190318233801-ac98e3ecb4b0 // indirect github.com/iancoleman/strcase v0.3.0 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 // indirect diff --git a/go.sum b/go.sum index fea4ac0653d..b9f8d0eec1f 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= @@ -653,6 +655,8 @@ github.com/hashicorp/serf v0.9.5/go.mod h1:UWDWwZeL5cuWDJdl0C6wrvrUwEqtQ4ZKBKKEN github.com/hashicorp/serf v0.9.6/go.mod h1:TXZNMjZQijwlDvp+r0b63xZ45H7JmCmgg4gpTwn9UV4= github.com/huandu/xstrings v1.5.0 h1:2ag3IFq9ZDANvthTwTiqSSZLjDc+BedvHPAp5tJy2TI= github.com/huandu/xstrings v1.5.0/go.mod h1:y5/lhBue+AyNmUVz9RLU9xbLR0o4KIIExikq4ovT0aE= +github.com/iancoleman/orderedmap v0.0.0-20190318233801-ac98e3ecb4b0 h1:i462o439ZjprVSFSZLZxcsoAe592sZB1rci2Z8j4wdk= +github.com/iancoleman/orderedmap v0.0.0-20190318233801-ac98e3ecb4b0/go.mod h1:N0Wam8K1arqPXNWjMo21EXnBPOPp36vB07FNRdD2geA= github.com/iancoleman/strcase v0.2.0/go.mod h1:iwCmte+B7n89clKwxIoIXy/HfoL7AsD47ZCWhYzw7ho= github.com/iancoleman/strcase v0.3.0 h1:nTXanmYxhfFAMjZL34Ov6gkzEsSJZ5DbhxWjvSASxEI= github.com/iancoleman/strcase v0.3.0/go.mod h1:iwCmte+B7n89clKwxIoIXy/HfoL7AsD47ZCWhYzw7ho= @@ -662,6 +666,8 @@ github.com/ianlancetaylor/demangle v0.0.0-20210905161508-09a460cdf81d/go.mod h1: github.com/inconshreveable/mousetrap v1.0.0/go.mod h1:PxqpIevigyE2G7u3NXJIT2ANytuPF1OarO4DADm73n8= github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= +github.com/invopop/jsonschema v0.7.0 h1:2vgQcBz1n256N+FpX3Jq7Y17AjYt46Ig3zIWyy770So= +github.com/invopop/jsonschema v0.7.0/go.mod h1:O9uiLokuu0+MGFlyiaqtWxwqJm41/+8Nj0lD7A36YH0= github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 h1:BQSFePA1RWJOlocH6Fxy8MmwDt+yVQYULKfN0RoTN8A= github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99/go.mod h1:1lJo3i6rXxKeerYnT8Nvf0QmHCRC1n8sfWVwXF2Frvo= github.com/jinzhu/copier v0.4.0 h1:w3ciUoD19shMCRargcpm0cm91ytaBhDvuRpz1ODO/U8= @@ -745,6 +751,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= @@ -866,6 +873,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= @@ -894,6 +902,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= @@ -944,6 +953,7 @@ github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY= github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.3.1-0.20190311161405-34c6fa2dc709/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA= github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= diff --git a/grype/db/v6/affected_cpe_store_test.go b/grype/db/v6/affected_cpe_store_test.go index 15a36d3c6c0..f8ba0cc669e 100644 --- a/grype/db/v6/affected_cpe_store_test.go +++ b/grype/db/v6/affected_cpe_store_test.go @@ -87,6 +87,26 @@ func TestAffectedCPEStore_GetCPEs(t *testing.T) { } } +func TestAffectedCPEStore_GetExact(t *testing.T) { + db := setupTestStore(t).db + bw := newBlobStore(db) + s := newAffectedCPEStore(db, bw) + + c := testAffectedCPEHandle() + err := s.AddAffectedCPEs(c) + require.NoError(t, err) + + // we want to search by all fields to ensure that all are accounted for in the query (since there are string fields referenced in the where clauses) + results, err := s.GetAffectedCPEs(toCPE(c.CPE), nil) + require.NoError(t, err) + + expected := []AffectedCPEHandle{*c} + require.Len(t, results, len(expected)) + result := results[0] + assert.Equal(t, c.CpeID, result.CpeID) + +} + func TestAffectedCPEStore_PreventDuplicateCPEs(t *testing.T) { db := setupTestStore(t).db bw := newBlobStore(db) @@ -163,6 +183,20 @@ func cpeFromProduct(product string) *cpe.Attributes { } } +func toCPE(c *Cpe) *cpe.Attributes { + return &cpe.Attributes{ + Part: c.Part, + Vendor: c.Vendor, + Product: c.Product, + Edition: c.Edition, + Language: c.Language, + SWEdition: c.SoftwareEdition, + TargetSW: c.TargetSoftware, + TargetHW: c.TargetHardware, + Other: c.Other, + } +} + func testAffectedCPEHandle() *AffectedCPEHandle { return &AffectedCPEHandle{ Vulnerability: &VulnerabilityHandle{ diff --git a/grype/db/v6/affected_package_store.go b/grype/db/v6/affected_package_store.go index c15c6d09a69..79c29481c21 100644 --- a/grype/db/v6/affected_package_store.go +++ b/grype/db/v6/affected_package_store.go @@ -26,6 +26,7 @@ const ( var NoOSSpecified = &OSSpecifier{} var AnyOSSpecified *OSSpecifier +var AnyPackageSpecified *PackageSpecifier var ErrMissingDistroIdentification = errors.New("missing os name or codename") var ErrDistroNotPresent = errors.New("distro not present") var ErrMultipleOSMatches = errors.New("multiple OS matches found but not allowed") @@ -45,9 +46,9 @@ type GetAffectedPackageOptions struct { type PackageSpecifiers []*PackageSpecifier type PackageSpecifier struct { - Name string - Type string - CPE *cpe.Attributes + Name string + Ecosystem string + CPE *cpe.Attributes } func (p *PackageSpecifier) String() string { @@ -60,8 +61,8 @@ func (p *PackageSpecifier) String() string { args = append(args, fmt.Sprintf("name=%s", p.Name)) } - if p.Type != "" { - args = append(args, fmt.Sprintf("type=%s", p.Type)) + if p.Ecosystem != "" { + args = append(args, fmt.Sprintf("ecosystem=%s", p.Ecosystem)) } if p.CPE != nil { @@ -301,8 +302,8 @@ func (s *affectedPackageStore) handlePackage(query *gorm.DB, config *PackageSpec if config.Name != "" { query = query.Where("packages.name = ?", config.Name) } - if config.Type != "" { - query = query.Where("packages.type = ?", config.Type) + if config.Ecosystem != "" { + query = query.Where("packages.type = ?", config.Ecosystem) } if config.CPE != nil { @@ -578,15 +579,15 @@ func handleCPEOptions(query *gorm.DB, c *cpe.Attributes) *gorm.DB { } if c.SWEdition != cpe.Any { - query = query.Where("cpes.sw_edition = ?", c.SWEdition) + query = query.Where("cpes.software_edition = ?", c.SWEdition) } if c.TargetSW != cpe.Any { - query = query.Where("cpes.target_sw = ?", c.TargetSW) + query = query.Where("cpes.target_software = ?", c.TargetSW) } if c.TargetHW != cpe.Any { - query = query.Where("cpes.target_hw = ?", c.TargetHW) + query = query.Where("cpes.target_hardware = ?", c.TargetHW) } if c.Other != cpe.Any { diff --git a/grype/db/v6/affected_package_store_test.go b/grype/db/v6/affected_package_store_test.go index 6acb0960d66..5d92d85d7fa 100644 --- a/grype/db/v6/affected_package_store_test.go +++ b/grype/db/v6/affected_package_store_test.go @@ -657,7 +657,7 @@ func TestAffectedPackageStore_GetAffectedPackages(t *testing.T) { }, { name: "package type", - pkg: &PackageSpecifier{Name: pkg2.Package.Name, Type: "type2"}, + pkg: &PackageSpecifier{Name: pkg2.Package.Name, Ecosystem: "type2"}, expected: []AffectedPackageHandle{*pkg2}, }, { 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 1a9d6d5f1d7..09fb236c80c 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" @@ -129,7 +130,7 @@ type VulnerabilityHandle struct { // Name is the unique name for the vulnerability (same as the decoded VulnerabilityBlob.ID) Name string `gorm:"column:name;not null;index"` - // Status conveys the actionability of the current record + // Status conveys the actionability of the current record (one of "active", "analyzing", "rejected", "disputed") Status VulnerabilityStatus `gorm:"column:status;not null;index"` // PublishedDate is the date the vulnerability record was first published @@ -222,11 +223,17 @@ func (v *AffectedPackageHandle) setBlob(rawBlobValue []byte) error { return nil } +// Package represents a package name within a known ecosystem, such as "python" or "golang". type Package struct { - ID ID `gorm:"column:id;primaryKey"` + ID ID `gorm:"column:id;primaryKey"` + + // Type is the tooling and language ecosystem that the package is released within Type string `gorm:"column:type;index:idx_package,unique"` + + // Name is the name of the package within the ecosystem Name string `gorm:"column:name;index:idx_package,unique;index:idx_package_name"` + // CPEs is the list of Common Platform Enumeration (CPE) identifiers that represent this package CPEs []Cpe `gorm:"foreignKey:PackageID;constraint:OnDelete:CASCADE;"` } @@ -275,15 +282,56 @@ func (p *Package) BeforeCreate(tx *gorm.DB) (err error) { return nil } +// OperatingSystem represents specific release of an operating system. The resolution of the version is +// relative to the available data by the vulnerability data provider, so though there may be major.minor.patch OS +// releases, there may only be data available for major.minor. type OperatingSystem struct { ID ID `gorm:"column:id;primaryKey"` - Name string `gorm:"column:name;index:os_idx,unique;index"` - ReleaseID string `gorm:"column:release_id;index:os_idx,unique;index"` + // Name is the operating system family name (e.g. "debian") + Name string `gorm:"column:name;index:os_idx,unique;index"` + ReleaseID string `gorm:"column:release_id;index:os_idx,unique;index"` + + // MajorVersion is the major version of a specific release (e.g. "10" for debian 10) MajorVersion string `gorm:"column:major_version;index:os_idx,unique;index"` + + // MinorVersion is the minor version of a specific release (e.g. "1" for debian 10.1) MinorVersion string `gorm:"column:minor_version;index:os_idx,unique;index"` + + // LabelVersion is an optional non-codename string representation of the version (e.g. "unstable" or for debian:sid) LabelVersion string `gorm:"column:label_version;index:os_idx,unique;index"` - Codename string `gorm:"column:codename;index"` + + // Codename is the codename of a specific release (e.g. "buster" for debian 10) + Codename string `gorm:"column:codename;index"` +} + +func (os *OperatingSystem) VersionNumber() string { + if os == nil { + return "" + } + 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) { @@ -424,7 +472,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) { diff --git a/grype/db/v6/models_test.go b/grype/db/v6/models_test.go index d6a1b445035..b20736045e1 100644 --- a/grype/db/v6/models_test.go +++ b/grype/db/v6/models_test.go @@ -56,3 +56,73 @@ func TestOperatingSystemAlias_VersionMutualExclusivity(t *testing.T) { }) } } + +func TestOperatingSystem_VersionNumber(t *testing.T) { + tests := []struct { + name string + os *OperatingSystem + expectedResult string + }{ + { + name: "nil OS", + os: nil, + expectedResult: "", + }, + { + name: "major and minor versions", + os: &OperatingSystem{MajorVersion: "10", MinorVersion: "1"}, + expectedResult: "10.1", + }, + { + name: "major version only", + os: &OperatingSystem{MajorVersion: "10"}, + expectedResult: "10", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + assert.Equal(t, tt.expectedResult, tt.os.VersionNumber()) + }) + } +} + +func TestOperatingSystem_Version(t *testing.T) { + tests := []struct { + name string + os *OperatingSystem + expectedResult string + }{ + { + name: "nil OS", + os: nil, + expectedResult: "", + }, + { + name: "label version", + os: &OperatingSystem{LabelVersion: "unstable"}, + expectedResult: "unstable", + }, + { + name: "major and minor versions", + os: &OperatingSystem{MajorVersion: "10", MinorVersion: "1"}, + expectedResult: "10.1", + }, + { + name: "major version only", + os: &OperatingSystem{MajorVersion: "10"}, + expectedResult: "10", + }, + { + name: "codename", + os: &OperatingSystem{Codename: "buster"}, + expectedResult: "buster", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + assert.Equal(t, tt.expectedResult, tt.os.Version()) + }) + } +} diff --git a/schema/grype/db-search-vuln/json/README.md b/schema/grype/db-search-vuln/json/README.md new file mode 100644 index 00000000000..844ff09fa4c --- /dev/null +++ b/schema/grype/db-search-vuln/json/README.md @@ -0,0 +1,28 @@ +# `db-search vuln` JSON Schema + +This is the JSON schema for output from the `grype db search vuln` command. The required inputs for defining the JSON schema are as follows: + +- the value of `cmd/grype/cli/commands/internal/dbsearch.VulnerabilitiesSchemaVersion` that governs the schema version +- the `Vulnerabilities` type definition within `github.com/anchore/grype/cmd/grype/cli/commands/internal/dbsearch/vulnerabilities.go` that governs the overall document shape + +## Versioning + +Versioning the JSON schema must be done manually by changing the `VulnerabilitiesSchemaVersion` constant within `cmd/grype/cli/commands/internal/dbsearch/versions.go`. + +This schema is being versioned based off of the "SchemaVer" guidelines, which slightly diverges from Semantic Versioning to tailor for the purposes of data models. + +Given a version number format `MODEL.REVISION.ADDITION`: + +- `MODEL`: increment when you make a breaking schema change which will prevent interaction with any historical data +- `REVISION`: increment when you make a schema change which may prevent interaction with some historical data +- `ADDITION`: increment when you make a schema change that is compatible with all historical data + +## Generating a New Schema + +Create the new schema by running `make generate-json-schema` from the root of the repo: + +- If there is **not** an existing schema for the given version, then the new schema file will be written to `schema/grype/db-search-vuln/json/schema-$VERSION.json` +- If there is an existing schema for the given version and the new schema matches the existing schema, no action is taken +- If there is an existing schema for the given version and the new schema **does not** match the existing schema, an error is shown indicating to increment the version appropriately (see the "Versioning" section) + +***Note: never delete a JSON schema and never change an existing JSON schema once it has been published in a release!*** Only add new schemas with a newly incremented version. diff --git a/schema/grype/db-search-vuln/json/schema-1.0.0.json b/schema/grype/db-search-vuln/json/schema-1.0.0.json new file mode 100644 index 00000000000..589230789dc --- /dev/null +++ b/schema/grype/db-search-vuln/json/schema-1.0.0.json @@ -0,0 +1,164 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "anchore.io/schema/grype/db-search-vuln/json/1.0.0/vulnerabilities", + "$ref": "#/$defs/Vulnerabilities", + "$defs": { + "OperatingSystem": { + "properties": { + "name": { + "type": "string" + }, + "version": { + "type": "string" + } + }, + "type": "object", + "required": [ + "name", + "version" + ] + }, + "Reference": { + "$defs": { + "tags": { + "description": "is a free-form organizational field to convey additional information about the reference" + }, + "url": { + "description": "is the external resource" + } + }, + "properties": { + "url": { + "type": "string" + }, + "tags": { + "items": { + "type": "string" + }, + "type": "array" + } + }, + "type": "object", + "required": [ + "url" + ] + }, + "Severity": { + "$defs": { + "rank": { + "description": "is a free-form organizational field to convey priority over other severities" + }, + "scheme": { + "description": "describes the quantitative method used to determine the Score, such as 'CVSS_V3'. Alternatively this makes\nclaim that Value is qualitative, for example 'HML' (High, Medium, Low), CHMLN (critical-high-medium-low-negligible)" + }, + "source": { + "description": "is the name of the source of the severity score (e.g. 'nvd@nist.gov' or 'security-advisories@github.com')" + }, + "value": { + "description": "is the severity score (e.g. '7.5', 'CVSS:4.0/AV:N/AC:L/AT:N/PR:H/UI:N/VC:L/VI:L/VA:N/SC:N/SI:N/SA:N', or 'high' )" + } + }, + "properties": { + "scheme": { + "type": "string" + }, + "value": true, + "source": { + "type": "string" + }, + "rank": { + "type": "integer" + } + }, + "type": "object", + "required": [ + "scheme", + "value", + "rank" + ] + }, + "Vulnerabilities": { + "items": { + "$ref": "#/$defs/Vulnerability" + }, + "type": "array" + }, + "Vulnerability": { + "$defs": { + "affected_packages": { + "description": "is the number of packages affected by the vulnerability" + }, + "operating_systems": { + "description": "is a list of operating systems affected by the vulnerability" + } + }, + "properties": { + "id": { + "type": "string" + }, + "assigner": { + "items": { + "type": "string" + }, + "type": "array" + }, + "description": { + "type": "string" + }, + "refs": { + "items": { + "$ref": "#/$defs/Reference" + }, + "type": "array" + }, + "aliases": { + "items": { + "type": "string" + }, + "type": "array" + }, + "severities": { + "items": { + "$ref": "#/$defs/Severity" + }, + "type": "array" + }, + "provider": { + "type": "string" + }, + "status": { + "type": "string" + }, + "published_date": { + "type": "string", + "format": "date-time" + }, + "modified_date": { + "type": "string", + "format": "date-time" + }, + "withdrawn_date": { + "type": "string", + "format": "date-time" + }, + "operating_systems": { + "items": { + "$ref": "#/$defs/OperatingSystem" + }, + "type": "array" + }, + "affected_packages": { + "type": "integer" + } + }, + "type": "object", + "required": [ + "id", + "provider", + "status", + "operating_systems", + "affected_packages" + ] + } + } +} diff --git a/schema/grype/db-search-vuln/json/schema-latest.json b/schema/grype/db-search-vuln/json/schema-latest.json new file mode 100644 index 00000000000..589230789dc --- /dev/null +++ b/schema/grype/db-search-vuln/json/schema-latest.json @@ -0,0 +1,164 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "anchore.io/schema/grype/db-search-vuln/json/1.0.0/vulnerabilities", + "$ref": "#/$defs/Vulnerabilities", + "$defs": { + "OperatingSystem": { + "properties": { + "name": { + "type": "string" + }, + "version": { + "type": "string" + } + }, + "type": "object", + "required": [ + "name", + "version" + ] + }, + "Reference": { + "$defs": { + "tags": { + "description": "is a free-form organizational field to convey additional information about the reference" + }, + "url": { + "description": "is the external resource" + } + }, + "properties": { + "url": { + "type": "string" + }, + "tags": { + "items": { + "type": "string" + }, + "type": "array" + } + }, + "type": "object", + "required": [ + "url" + ] + }, + "Severity": { + "$defs": { + "rank": { + "description": "is a free-form organizational field to convey priority over other severities" + }, + "scheme": { + "description": "describes the quantitative method used to determine the Score, such as 'CVSS_V3'. Alternatively this makes\nclaim that Value is qualitative, for example 'HML' (High, Medium, Low), CHMLN (critical-high-medium-low-negligible)" + }, + "source": { + "description": "is the name of the source of the severity score (e.g. 'nvd@nist.gov' or 'security-advisories@github.com')" + }, + "value": { + "description": "is the severity score (e.g. '7.5', 'CVSS:4.0/AV:N/AC:L/AT:N/PR:H/UI:N/VC:L/VI:L/VA:N/SC:N/SI:N/SA:N', or 'high' )" + } + }, + "properties": { + "scheme": { + "type": "string" + }, + "value": true, + "source": { + "type": "string" + }, + "rank": { + "type": "integer" + } + }, + "type": "object", + "required": [ + "scheme", + "value", + "rank" + ] + }, + "Vulnerabilities": { + "items": { + "$ref": "#/$defs/Vulnerability" + }, + "type": "array" + }, + "Vulnerability": { + "$defs": { + "affected_packages": { + "description": "is the number of packages affected by the vulnerability" + }, + "operating_systems": { + "description": "is a list of operating systems affected by the vulnerability" + } + }, + "properties": { + "id": { + "type": "string" + }, + "assigner": { + "items": { + "type": "string" + }, + "type": "array" + }, + "description": { + "type": "string" + }, + "refs": { + "items": { + "$ref": "#/$defs/Reference" + }, + "type": "array" + }, + "aliases": { + "items": { + "type": "string" + }, + "type": "array" + }, + "severities": { + "items": { + "$ref": "#/$defs/Severity" + }, + "type": "array" + }, + "provider": { + "type": "string" + }, + "status": { + "type": "string" + }, + "published_date": { + "type": "string", + "format": "date-time" + }, + "modified_date": { + "type": "string", + "format": "date-time" + }, + "withdrawn_date": { + "type": "string", + "format": "date-time" + }, + "operating_systems": { + "items": { + "$ref": "#/$defs/OperatingSystem" + }, + "type": "array" + }, + "affected_packages": { + "type": "integer" + } + }, + "type": "object", + "required": [ + "id", + "provider", + "status", + "operating_systems", + "affected_packages" + ] + } + } +} diff --git a/schema/grype/db-search/json/README.md b/schema/grype/db-search/json/README.md new file mode 100644 index 00000000000..6ee954c4790 --- /dev/null +++ b/schema/grype/db-search/json/README.md @@ -0,0 +1,28 @@ +# `db-search` JSON Schema + +This is the JSON schema for output from the `grype db search` command. The required inputs for defining the JSON schema are as follows: + +- the value of `cmd/grype/cli/commands/internal/dbsearch.MatchesSchemaVersion` that governs the schema version +- the `Matches` type definition within `github.com/anchore/grype/cmd/grype/cli/commands/internal/dbsearch/matches.go` that governs the overall document shape + +## Versioning + +Versioning the JSON schema must be done manually by changing the `MatchesSchemaVersion` constant within `cmd/grype/cli/commands/internal/dbsearch/versions.go`. + +This schema is being versioned based off of the "SchemaVer" guidelines, which slightly diverges from Semantic Versioning to tailor for the purposes of data models. + +Given a version number format `MODEL.REVISION.ADDITION`: + +- `MODEL`: increment when you make a breaking schema change which will prevent interaction with any historical data +- `REVISION`: increment when you make a schema change which may prevent interaction with some historical data +- `ADDITION`: increment when you make a schema change that is compatible with all historical data + +## Generating a New Schema + +Create the new schema by running `make generate-json-schema` from the root of the repo: + +- If there is **not** an existing schema for the given version, then the new schema file will be written to `schema/grype/db-search/json/schema-$VERSION.json` +- If there is an existing schema for the given version and the new schema matches the existing schema, no action is taken +- If there is an existing schema for the given version and the new schema **does not** match the existing schema, an error is shown indicating to increment the version appropriately (see the "Versioning" section) + +***Note: never delete a JSON schema and never change an existing JSON schema once it has been published in a release!*** Only add new schemas with a newly incremented version. diff --git a/schema/grype/db-search/json/schema-1.0.0.json b/schema/grype/db-search/json/schema-1.0.0.json new file mode 100644 index 00000000000..0901a1db845 --- /dev/null +++ b/schema/grype/db-search/json/schema-1.0.0.json @@ -0,0 +1,442 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "anchore.io/schema/grype/db-search/json/1.0.0/matches", + "$ref": "#/$defs/Matches", + "$defs": { + "AffectedPackageBlob": { + "$defs": { + "cves": { + "description": "is a list of Common Vulnerabilities and Exposures (CVE) identifiers related to this vulnerability." + }, + "qualifiers": { + "description": "are package attributes that confirm the package is affected by the vulnerability." + }, + "ranges": { + "description": "specifies the affected version ranges and fixes if available." + } + }, + "properties": { + "cves": { + "items": { + "type": "string" + }, + "type": "array" + }, + "qualifiers": { + "$ref": "#/$defs/AffectedPackageQualifiers" + }, + "ranges": { + "items": { + "$ref": "#/$defs/AffectedRange" + }, + "type": "array" + } + }, + "type": "object" + }, + "AffectedPackageInfo": { + "$defs": { + "cpe": { + "description": "is a Common Platform Enumeration that is affected by the vulnerability" + }, + "detail": { + "description": "is the detailed information about the affected package" + }, + "os": { + "description": "identifies the operating system release that the affected package is released for" + }, + "package": { + "description": "identifies the name of the package in a specific ecosystem affected by the vulnerability" + } + }, + "properties": { + "os": { + "$ref": "#/$defs/OperatingSystem" + }, + "package": { + "$ref": "#/$defs/Package" + }, + "cpe": { + "$ref": "#/$defs/CPE" + }, + "detail": { + "$ref": "#/$defs/AffectedPackageBlob" + } + }, + "type": "object", + "required": [ + "detail" + ] + }, + "AffectedPackageQualifiers": { + "$defs": { + "platform_cpes": { + "description": "lists Common Platform Enumeration (CPE) identifiers for affected platforms." + }, + "rpm_modularity": { + "description": "indicates if the package follows RPM modularity for versioning." + } + }, + "properties": { + "rpm_modularity": { + "type": "string" + }, + "platform_cpes": { + "items": { + "type": "string" + }, + "type": "array" + } + }, + "type": "object" + }, + "AffectedRange": { + "$defs": { + "fix": { + "description": "provides details on the fix version and its state if available." + }, + "version": { + "description": "defines the version constraints for affected software." + } + }, + "properties": { + "version": { + "$ref": "#/$defs/AffectedVersion" + }, + "fix": { + "$ref": "#/$defs/Fix" + } + }, + "type": "object", + "required": [ + "version" + ] + }, + "AffectedVersion": { + "$defs": { + "constraint": { + "description": "defines the version range constraint for affected versions." + }, + "type": { + "description": "specifies the versioning system used (e.g., 'semver', 'rpm')." + } + }, + "properties": { + "type": { + "type": "string" + }, + "constraint": { + "type": "string" + } + }, + "type": "object", + "required": [ + "constraint" + ] + }, + "CPE": { + "properties": { + "ID": { + "type": "integer" + }, + "PackageID": { + "type": "integer" + }, + "Part": { + "type": "string" + }, + "Vendor": { + "type": "string" + }, + "Product": { + "type": "string" + }, + "Edition": { + "type": "string" + }, + "Language": { + "type": "string" + }, + "SoftwareEdition": { + "type": "string" + }, + "TargetHardware": { + "type": "string" + }, + "TargetSoftware": { + "type": "string" + }, + "Other": { + "type": "string" + } + }, + "type": "object", + "required": [ + "ID", + "PackageID", + "Part", + "Vendor", + "Product", + "Edition", + "Language", + "SoftwareEdition", + "TargetHardware", + "TargetSoftware", + "Other" + ] + }, + "Fix": { + "$defs": { + "detail": { + "description": "provides additional fix information, such as commit details." + }, + "state": { + "description": "represents the status of the fix (e.g., 'fixed', 'unaffected')." + }, + "version": { + "description": "is the version number of the fix." + } + }, + "properties": { + "version": { + "type": "string" + }, + "state": { + "type": "string" + }, + "detail": { + "$ref": "#/$defs/FixDetail" + } + }, + "type": "object", + "required": [ + "state" + ] + }, + "FixDetail": { + "$defs": { + "git_commit": { + "description": "is the identifier for the Git commit associated with the fix." + }, + "references": { + "description": "contains URLs or identifiers for additional resources on the fix." + }, + "timestamp": { + "description": "is the date and time when the fix was committed." + } + }, + "properties": { + "git_commit": { + "type": "string" + }, + "timestamp": { + "type": "string", + "format": "date-time" + }, + "references": { + "items": { + "$ref": "#/$defs/Reference" + }, + "type": "array" + } + }, + "type": "object" + }, + "Match": { + "$defs": { + "packages": { + "description": "is the list of packages affected by the vulnerability." + }, + "vulnerability": { + "description": "is the core advisory record for a single known vulnerability from a specific provider." + } + }, + "properties": { + "vulnerability": { + "$ref": "#/$defs/VulnerabilityInfo" + }, + "packages": { + "items": { + "$ref": "#/$defs/AffectedPackageInfo" + }, + "type": "array" + } + }, + "type": "object", + "required": [ + "vulnerability", + "packages" + ] + }, + "Matches": { + "items": { + "$ref": "#/$defs/Match" + }, + "type": "array" + }, + "OperatingSystem": { + "properties": { + "name": { + "type": "string" + }, + "version": { + "type": "string" + } + }, + "type": "object", + "required": [ + "name", + "version" + ] + }, + "Package": { + "properties": { + "name": { + "type": "string" + }, + "ecosystem": { + "type": "string" + } + }, + "type": "object", + "required": [ + "name", + "ecosystem" + ] + }, + "Reference": { + "$defs": { + "tags": { + "description": "is a free-form organizational field to convey additional information about the reference" + }, + "url": { + "description": "is the external resource" + } + }, + "properties": { + "url": { + "type": "string" + }, + "tags": { + "items": { + "type": "string" + }, + "type": "array" + } + }, + "type": "object", + "required": [ + "url" + ] + }, + "Severity": { + "$defs": { + "rank": { + "description": "is a free-form organizational field to convey priority over other severities" + }, + "scheme": { + "description": "describes the quantitative method used to determine the Score, such as 'CVSS_V3'. Alternatively this makes\nclaim that Value is qualitative, for example 'HML' (High, Medium, Low), CHMLN (critical-high-medium-low-negligible)" + }, + "source": { + "description": "is the name of the source of the severity score (e.g. 'nvd@nist.gov' or 'security-advisories@github.com')" + }, + "value": { + "description": "is the severity score (e.g. '7.5', 'CVSS:4.0/AV:N/AC:L/AT:N/PR:H/UI:N/VC:L/VI:L/VA:N/SC:N/SI:N/SA:N', or 'high' )" + } + }, + "properties": { + "scheme": { + "type": "string" + }, + "value": true, + "source": { + "type": "string" + }, + "rank": { + "type": "integer" + } + }, + "type": "object", + "required": [ + "scheme", + "value", + "rank" + ] + }, + "VulnerabilityInfo": { + "$defs": { + "modified_date": { + "description": "is the date the vulnerability record was last modified" + }, + "provider": { + "description": "is the upstream data processor (usually Vunnel) that is responsible for vulnerability records. Each provider\nshould be scoped to a specific vulnerability dataset, for instance, the 'ubuntu' provider for all records from\nCanonicals' Ubuntu Security Notices (for all Ubuntu distro versions)." + }, + "published_date": { + "description": "is the date the vulnerability record was first published" + }, + "status": { + "description": "conveys the actionability of the current record (one of 'active', 'analyzing', 'rejected', 'disputed')" + }, + "withdrawn_date": { + "description": "is the date the vulnerability record was withdrawn" + } + }, + "properties": { + "id": { + "type": "string" + }, + "assigner": { + "items": { + "type": "string" + }, + "type": "array" + }, + "description": { + "type": "string" + }, + "refs": { + "items": { + "$ref": "#/$defs/Reference" + }, + "type": "array" + }, + "aliases": { + "items": { + "type": "string" + }, + "type": "array" + }, + "severities": { + "items": { + "$ref": "#/$defs/Severity" + }, + "type": "array" + }, + "provider": { + "type": "string" + }, + "status": { + "type": "string" + }, + "published_date": { + "type": "string", + "format": "date-time" + }, + "modified_date": { + "type": "string", + "format": "date-time" + }, + "withdrawn_date": { + "type": "string", + "format": "date-time" + } + }, + "type": "object", + "required": [ + "id", + "provider", + "status" + ] + } + } +} diff --git a/schema/grype/db-search/json/schema-latest.json b/schema/grype/db-search/json/schema-latest.json new file mode 100644 index 00000000000..0901a1db845 --- /dev/null +++ b/schema/grype/db-search/json/schema-latest.json @@ -0,0 +1,442 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "anchore.io/schema/grype/db-search/json/1.0.0/matches", + "$ref": "#/$defs/Matches", + "$defs": { + "AffectedPackageBlob": { + "$defs": { + "cves": { + "description": "is a list of Common Vulnerabilities and Exposures (CVE) identifiers related to this vulnerability." + }, + "qualifiers": { + "description": "are package attributes that confirm the package is affected by the vulnerability." + }, + "ranges": { + "description": "specifies the affected version ranges and fixes if available." + } + }, + "properties": { + "cves": { + "items": { + "type": "string" + }, + "type": "array" + }, + "qualifiers": { + "$ref": "#/$defs/AffectedPackageQualifiers" + }, + "ranges": { + "items": { + "$ref": "#/$defs/AffectedRange" + }, + "type": "array" + } + }, + "type": "object" + }, + "AffectedPackageInfo": { + "$defs": { + "cpe": { + "description": "is a Common Platform Enumeration that is affected by the vulnerability" + }, + "detail": { + "description": "is the detailed information about the affected package" + }, + "os": { + "description": "identifies the operating system release that the affected package is released for" + }, + "package": { + "description": "identifies the name of the package in a specific ecosystem affected by the vulnerability" + } + }, + "properties": { + "os": { + "$ref": "#/$defs/OperatingSystem" + }, + "package": { + "$ref": "#/$defs/Package" + }, + "cpe": { + "$ref": "#/$defs/CPE" + }, + "detail": { + "$ref": "#/$defs/AffectedPackageBlob" + } + }, + "type": "object", + "required": [ + "detail" + ] + }, + "AffectedPackageQualifiers": { + "$defs": { + "platform_cpes": { + "description": "lists Common Platform Enumeration (CPE) identifiers for affected platforms." + }, + "rpm_modularity": { + "description": "indicates if the package follows RPM modularity for versioning." + } + }, + "properties": { + "rpm_modularity": { + "type": "string" + }, + "platform_cpes": { + "items": { + "type": "string" + }, + "type": "array" + } + }, + "type": "object" + }, + "AffectedRange": { + "$defs": { + "fix": { + "description": "provides details on the fix version and its state if available." + }, + "version": { + "description": "defines the version constraints for affected software." + } + }, + "properties": { + "version": { + "$ref": "#/$defs/AffectedVersion" + }, + "fix": { + "$ref": "#/$defs/Fix" + } + }, + "type": "object", + "required": [ + "version" + ] + }, + "AffectedVersion": { + "$defs": { + "constraint": { + "description": "defines the version range constraint for affected versions." + }, + "type": { + "description": "specifies the versioning system used (e.g., 'semver', 'rpm')." + } + }, + "properties": { + "type": { + "type": "string" + }, + "constraint": { + "type": "string" + } + }, + "type": "object", + "required": [ + "constraint" + ] + }, + "CPE": { + "properties": { + "ID": { + "type": "integer" + }, + "PackageID": { + "type": "integer" + }, + "Part": { + "type": "string" + }, + "Vendor": { + "type": "string" + }, + "Product": { + "type": "string" + }, + "Edition": { + "type": "string" + }, + "Language": { + "type": "string" + }, + "SoftwareEdition": { + "type": "string" + }, + "TargetHardware": { + "type": "string" + }, + "TargetSoftware": { + "type": "string" + }, + "Other": { + "type": "string" + } + }, + "type": "object", + "required": [ + "ID", + "PackageID", + "Part", + "Vendor", + "Product", + "Edition", + "Language", + "SoftwareEdition", + "TargetHardware", + "TargetSoftware", + "Other" + ] + }, + "Fix": { + "$defs": { + "detail": { + "description": "provides additional fix information, such as commit details." + }, + "state": { + "description": "represents the status of the fix (e.g., 'fixed', 'unaffected')." + }, + "version": { + "description": "is the version number of the fix." + } + }, + "properties": { + "version": { + "type": "string" + }, + "state": { + "type": "string" + }, + "detail": { + "$ref": "#/$defs/FixDetail" + } + }, + "type": "object", + "required": [ + "state" + ] + }, + "FixDetail": { + "$defs": { + "git_commit": { + "description": "is the identifier for the Git commit associated with the fix." + }, + "references": { + "description": "contains URLs or identifiers for additional resources on the fix." + }, + "timestamp": { + "description": "is the date and time when the fix was committed." + } + }, + "properties": { + "git_commit": { + "type": "string" + }, + "timestamp": { + "type": "string", + "format": "date-time" + }, + "references": { + "items": { + "$ref": "#/$defs/Reference" + }, + "type": "array" + } + }, + "type": "object" + }, + "Match": { + "$defs": { + "packages": { + "description": "is the list of packages affected by the vulnerability." + }, + "vulnerability": { + "description": "is the core advisory record for a single known vulnerability from a specific provider." + } + }, + "properties": { + "vulnerability": { + "$ref": "#/$defs/VulnerabilityInfo" + }, + "packages": { + "items": { + "$ref": "#/$defs/AffectedPackageInfo" + }, + "type": "array" + } + }, + "type": "object", + "required": [ + "vulnerability", + "packages" + ] + }, + "Matches": { + "items": { + "$ref": "#/$defs/Match" + }, + "type": "array" + }, + "OperatingSystem": { + "properties": { + "name": { + "type": "string" + }, + "version": { + "type": "string" + } + }, + "type": "object", + "required": [ + "name", + "version" + ] + }, + "Package": { + "properties": { + "name": { + "type": "string" + }, + "ecosystem": { + "type": "string" + } + }, + "type": "object", + "required": [ + "name", + "ecosystem" + ] + }, + "Reference": { + "$defs": { + "tags": { + "description": "is a free-form organizational field to convey additional information about the reference" + }, + "url": { + "description": "is the external resource" + } + }, + "properties": { + "url": { + "type": "string" + }, + "tags": { + "items": { + "type": "string" + }, + "type": "array" + } + }, + "type": "object", + "required": [ + "url" + ] + }, + "Severity": { + "$defs": { + "rank": { + "description": "is a free-form organizational field to convey priority over other severities" + }, + "scheme": { + "description": "describes the quantitative method used to determine the Score, such as 'CVSS_V3'. Alternatively this makes\nclaim that Value is qualitative, for example 'HML' (High, Medium, Low), CHMLN (critical-high-medium-low-negligible)" + }, + "source": { + "description": "is the name of the source of the severity score (e.g. 'nvd@nist.gov' or 'security-advisories@github.com')" + }, + "value": { + "description": "is the severity score (e.g. '7.5', 'CVSS:4.0/AV:N/AC:L/AT:N/PR:H/UI:N/VC:L/VI:L/VA:N/SC:N/SI:N/SA:N', or 'high' )" + } + }, + "properties": { + "scheme": { + "type": "string" + }, + "value": true, + "source": { + "type": "string" + }, + "rank": { + "type": "integer" + } + }, + "type": "object", + "required": [ + "scheme", + "value", + "rank" + ] + }, + "VulnerabilityInfo": { + "$defs": { + "modified_date": { + "description": "is the date the vulnerability record was last modified" + }, + "provider": { + "description": "is the upstream data processor (usually Vunnel) that is responsible for vulnerability records. Each provider\nshould be scoped to a specific vulnerability dataset, for instance, the 'ubuntu' provider for all records from\nCanonicals' Ubuntu Security Notices (for all Ubuntu distro versions)." + }, + "published_date": { + "description": "is the date the vulnerability record was first published" + }, + "status": { + "description": "conveys the actionability of the current record (one of 'active', 'analyzing', 'rejected', 'disputed')" + }, + "withdrawn_date": { + "description": "is the date the vulnerability record was withdrawn" + } + }, + "properties": { + "id": { + "type": "string" + }, + "assigner": { + "items": { + "type": "string" + }, + "type": "array" + }, + "description": { + "type": "string" + }, + "refs": { + "items": { + "$ref": "#/$defs/Reference" + }, + "type": "array" + }, + "aliases": { + "items": { + "type": "string" + }, + "type": "array" + }, + "severities": { + "items": { + "$ref": "#/$defs/Severity" + }, + "type": "array" + }, + "provider": { + "type": "string" + }, + "status": { + "type": "string" + }, + "published_date": { + "type": "string", + "format": "date-time" + }, + "modified_date": { + "type": "string", + "format": "date-time" + }, + "withdrawn_date": { + "type": "string", + "format": "date-time" + } + }, + "type": "object", + "required": [ + "id", + "provider", + "status" + ] + } + } +} diff --git a/test/cli/db_providers_test.go b/test/cli/db_providers_test.go index 3437aa8860a..3e8d0e42cb7 100644 --- a/test/cli/db_providers_test.go +++ b/test/cli/db_providers_test.go @@ -25,7 +25,7 @@ func TestDBProviders(t *testing.T) { name: "db providers command help", args: []string{"db", "providers", "-h"}, assertions: []traitAssertion{ - assertInOutput("list vulnerability database providers"), + assertInOutput("List vulnerability providers that are in the database"), assertNoStderr, }, },