diff --git a/cmd/grype/cli/commands/db_search.go b/cmd/grype/cli/commands/db_search.go index 1e1af4492af..596f394e0b0 100644 --- a/cmd/grype/cli/commands/db_search.go +++ b/cmd/grype/cli/commands/db_search.go @@ -5,11 +5,14 @@ import ( "fmt" "io" "strings" + "time" + "github.com/araddon/dateparse" "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/grype" v6 "github.com/anchore/grype/grype/db/v6" "github.com/anchore/grype/grype/db/v6/distribution" @@ -20,7 +23,14 @@ import ( ) type dbQueryOptions struct { - Output string `yaml:"output" json:"output" mapstructure:"output"` + Output string `yaml:"output" json:"output" mapstructure:"output"` + + PublishedAfter string `yaml:"published-after" json:"published-after" mapstructure:"published-after"` + publishedAfter *time.Time + + ModifiedAfter string `yaml:"modified-after" json:"modified-after" mapstructure:"modified-after"` + modifiedAfter *time.Time + DBOptions `yaml:",inline" mapstructure:",squash"` } @@ -28,6 +38,35 @@ var _ clio.FlagAdder = (*dbQueryOptions)(nil) func (c *dbQueryOptions) AddFlags(flags clio.FlagSet) { flags.StringVarP(&c.Output, "output", "o", "format to display results (available=[table, json])") + flags.StringVarP(&c.PublishedAfter, "published-after", "", "only show vulnerabilities originally published after the given date (format: YYYY-MM-DD) (for v6+ schemas only)") + flags.StringVarP(&c.ModifiedAfter, "modified-after", "", "only show vulnerabilities originally published or modified since the given date (format: YYYY-MM-DD) (for v6+ schemas only)") +} + +func (c *dbQueryOptions) PostLoad() error { + handleTimeOption := func(val string, flag string) (*time.Time, error) { + if val == "" { + return nil, nil + } + parsed, err := dateparse.ParseIn(val, time.UTC) + if err != nil { + return nil, fmt.Errorf("invalid date format for %s=%q: %w", flag, val, err) + } + return &parsed, nil + } + + if c.PublishedAfter != "" && c.ModifiedAfter != "" { + return fmt.Errorf("only one of --published-after or --modified-after can be set") + } + + var err error + if c.publishedAfter, err = handleTimeOption(c.PublishedAfter, "published-after"); err != nil { + return err + } + if c.modifiedAfter, err = handleTimeOption(c.ModifiedAfter, "modified-after"); err != nil { + return err + } + + return nil } func DBSearch(app clio.Application) *cobra.Command { @@ -39,7 +78,7 @@ func DBSearch(app clio.Application) *cobra.Command { return app.SetupCommand(&cobra.Command{ Use: "search [vulnerability_id]", Short: "get information on a vulnerability from the db", - Args: cobra.ExactArgs(1), + Args: cobra.ArbitraryArgs, RunE: func(_ *cobra.Command, args []string) (err error) { id := args[0] return runDBSearch(*opts, id) @@ -69,26 +108,128 @@ func newDBSearch(opts dbQueryOptions, vulnerabilityID string) error { if err != nil { return fmt.Errorf("unable to get providers: %w", err) } + // TODO: refactor this in terms of search function pattern described in #2132 (in other words, the store should not be directly accessed here) - vh, err := reader.GetVulnerabilities(&v6.VulnerabilitySpecifier{Name: vulnerabilityID}, &v6.GetVulnerabilityOptions{ - Preload: true, + affectedPkgs, err := reader.GetAffectedPackages(nil, &v6.GetAffectedPackageOptions{ + PreloadOS: true, + PreloadPackage: true, + PreloadPackageCPEs: false, + PreloadVulnerability: true, + PreloadBlob: true, + Distro: nil, + Vulnerability: &v6.VulnerabilitySpecifier{ + Name: vulnerabilityID, + PublishedAfter: opts.publishedAfter, + ModifiedAfter: opts.modifiedAfter, + }, }) if err != nil { - return fmt.Errorf("unable to get vulnerability: %w", err) + return fmt.Errorf("unable to get affected packages: %w", err) } - if len(vh) == 0 { - return fmt.Errorf("vulnerability doesn't exist in the DB: %s", vulnerabilityID) + affectedCPEs, err := reader.GetAffectedCPEs(nil, &v6.GetAffectedCPEOptions{ + PreloadCPE: true, + PreloadVulnerability: true, + PreloadBlob: true, + Vulnerability: &v6.VulnerabilitySpecifier{ + Name: vulnerabilityID, + PublishedAfter: opts.publishedAfter, + ModifiedAfter: opts.modifiedAfter, + }, + }) + if err != nil { + return fmt.Errorf("unable to get affected cpes: %w", err) + } + + rows := dbsearch.NewRows(affectedPkgs, affectedCPEs) + + if len(rows) == 0 { + return fmt.Errorf("no packages affected by the given vulnerability ID: %s", vulnerabilityID) + } + + sb := &strings.Builder{} + err = present(opts.Output, rows, sb) + bus.Report(sb.String()) + return err +} + +func present(outputFormat string, structuredRows []dbsearch.Row, output io.Writer) error { + if len(structuredRows) == 0 { + // TODO: show a message that no results were found? + return nil + } + + switch outputFormat { + case tableOutputFormat: + rows := renderTableRows(structuredRows) + + table := tablewriter.NewWriter(output) + columns := []string{"ID", "Package", "Ecosystem", "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.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 renderTableRows(structuredRows []dbsearch.Row) [][]string { + var rows [][]string + for _, rr := range structuredRows { + var pkgOrCPE, ecosystem string + if rr.Package != nil { + pkgOrCPE = rr.Package.Name + ecosystem = rr.Package.Ecosystem + } else if rr.CPE != nil { + pkgOrCPE = rr.CPE.String() + ecosystem = rr.CPE.TargetSoftware + } + + namespace := rr.Vulnerability.Provider + if rr.OS != nil { + namespace = fmt.Sprintf("%s:%s", rr.OS.Family, rr.OS.Version) + } - // TODO: we need to implement the functions that inflate models to the grype vulnerability.Vulnerability struct - panic("not implemented") + var ranges []string + for _, ra := range rr.Detail.Ranges { + ranges = append(ranges, ra.Version.Constraint) + } + rangeStr := strings.Join(ranges, " || ") + rows = append(rows, []string{rr.Vulnerability.ID, pkgOrCPE, ecosystem, namespace, rangeStr}) + } + return rows } /////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// // all legacy processing below //////////////////////////////////////////////////////////////////////////////////////// func legacyDBSearch(opts dbQueryOptions, vulnerabilityID string) error { + if opts.modifiedAfter != nil || opts.publishedAfter != nil { + return fmt.Errorf("date filtering is only available for v6+ schemas") + } + log.Debug("loading DB") str, status, err := grype.LoadVulnerabilityDB(opts.DB.ToLegacyCuratorConfig(), opts.DB.AutoUpdate) err = validateDBLoad(err, status) diff --git a/cmd/grype/cli/commands/internal/dbsearch/row.go b/cmd/grype/cli/commands/internal/dbsearch/row.go new file mode 100644 index 00000000000..d669810b89f --- /dev/null +++ b/cmd/grype/cli/commands/internal/dbsearch/row.go @@ -0,0 +1,127 @@ +package dbsearch + +import ( + "encoding/json" + "time" + + v6 "github.com/anchore/grype/grype/db/v6" +) + +type Row struct { + Vulnerability vulnerability `json:"vulnerability"` + OS *OS `json:"os,omitempty"` + Package *Package `json:"package,omitempty"` + CPE *v6.Cpe `json:"cpe,omitempty"` + Detail v6.AffectedPackageBlob `json:"detail"` +} + +type vulnerability struct { + v6.VulnerabilityBlob `json:",inline"` + Provider string `json:"provider"` + Status string `json:"status"` + PublishedDate *time.Time `json:"published_date"` + ModifiedDate *time.Time `json:"modified_date"` + WithdrawnDate *time.Time `json:"withdrawn_date"` +} + +func (r Row) MarshalJSON() ([]byte, error) { + var cpe string + if r.CPE != nil { + cpe = r.CPE.String() + } + return json.Marshal(&struct { + Vulnerability vulnerability `json:"vulnerability"` + OS *OS `json:"os,omitempty"` + Package *Package `json:"package,omitempty"` + CPE string `json:"cpe,omitempty"` + Detail v6.AffectedPackageBlob `json:"detail"` + }{ + Vulnerability: r.Vulnerability, + OS: r.OS, + Package: r.Package, + CPE: cpe, + Detail: r.Detail, + }) +} + +type Package struct { + Name string `json:"name"` + Ecosystem string `json:"ecosystem"` +} + +type OS struct { + Family string `json:"family"` + Version string `json:"version"` +} + +func NewRows(affectedPkgs []v6.AffectedPackageHandle, affectedCPEs []v6.AffectedCPEHandle) []Row { + var rows []Row + for _, pkg := range affectedPkgs { + var detail v6.AffectedPackageBlob + if pkg.BlobValue != nil { + detail = *pkg.BlobValue + } + rows = append(rows, Row{ + Vulnerability: toVulnerability(pkg.Vulnerability), + OS: toOS(pkg.OperatingSystem), + Package: toPackage(pkg.Package), + Detail: detail, + }) + } + + for _, ac := range affectedCPEs { + var detail v6.AffectedPackageBlob + if ac.BlobValue != nil { + detail = *ac.BlobValue + } + rows = append(rows, Row{ + Vulnerability: toVulnerability(ac.Vulnerability), + CPE: ac.CPE, + Detail: detail, + }) + } + return rows +} + +func toVulnerability(vuln *v6.VulnerabilityHandle) vulnerability { + if vuln == nil { + return vulnerability{} + } + var blob v6.VulnerabilityBlob + if vuln.BlobValue != nil { + blob = *vuln.BlobValue + } + return vulnerability{ + VulnerabilityBlob: blob, + Provider: vuln.Provider.ID, + Status: vuln.Status, + PublishedDate: vuln.PublishedDate, + ModifiedDate: vuln.ModifiedDate, + WithdrawnDate: vuln.WithdrawnDate, + } +} + +func toPackage(pkg *v6.Package) *Package { + if pkg == nil { + return nil + } + return &Package{ + Name: pkg.Name, + Ecosystem: pkg.Type, + } +} + +func toOS(os *v6.OperatingSystem) *OS { + if os == nil { + return nil + } + version := os.VersionNumber() + if version == "" { + version = os.Version() + } + + return &OS{ + Family: os.Name, + Version: version, + } +} diff --git a/go.mod b/go.mod index c035ca95c56..7ee3e906b91 100644 --- a/go.mod +++ b/go.mod @@ -18,6 +18,7 @@ require ( github.com/anchore/stereoscope v0.0.9 github.com/anchore/syft v1.17.0 github.com/aquasecurity/go-pep440-version v0.0.0-20210121094942-22b2f8951d46 + github.com/araddon/dateparse v0.0.0-20210429162001-6b43995a97de github.com/bmatcuk/doublestar/v2 v2.0.4 github.com/charmbracelet/bubbletea v1.2.4 github.com/charmbracelet/lipgloss v1.0.0 diff --git a/go.sum b/go.sum index 6250aa4599c..ebcb1e7ad2d 100644 --- a/go.sum +++ b/go.sum @@ -273,6 +273,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= @@ -746,6 +748,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= @@ -868,6 +871,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= @@ -896,6 +900,7 @@ github.com/sassoftware/go-rpmutils v0.4.0 h1:ojND82NYBxgwrV+mX1CWsd5QJvvEZTKddtC github.com/sassoftware/go-rpmutils v0.4.0/go.mod h1:3goNWi7PGAT3/dlql2lv3+MSN5jNYPjT5mVcQcIsYzI= github.com/scylladb/go-set v1.0.3-0.20200225121959-cc7b2070d91e h1:7q6NSFZDeGfvvtIRwBrU/aegEYJYmvev0cHAwo17zZQ= github.com/scylladb/go-set v1.0.3-0.20200225121959-cc7b2070d91e/go.mod h1:DkpGd78rljTxKAnTDPFqXSGxvETQnJyuSOQwsHycqfs= +github.com/scylladb/termtables v0.0.0-20191203121021-c4c0b6d42ff4/go.mod h1:C1a7PQSMz9NShzorzCiG2fk9+xuCgLkPeCvMHYR2OWg= github.com/sean-/seed v0.0.0-20170313163322-e2103e2c3529/go.mod h1:DxrIzT+xaE7yg65j358z/aeFdxmN0P9QXhEzd20vsDc= github.com/sebdah/goldie/v2 v2.5.5 h1:rx1mwF95RxZ3/83sdS4Yp7t2C5TCokvWP4TBRbAyEWY= github.com/sebdah/goldie/v2 v2.5.5/go.mod h1:oZ9fp0+se1eapSRjfYbsV/0Hqhbuu3bJVvKI/NNtssI= diff --git a/grype/db/v6/blobs.go b/grype/db/v6/blobs.go index 6cea2d8bb88..d0c35abdd7c 100644 --- a/grype/db/v6/blobs.go +++ b/grype/db/v6/blobs.go @@ -129,7 +129,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"` @@ -153,7 +153,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 3a4dbf30c9e..32c2369eedb 100644 --- a/grype/db/v6/models.go +++ b/grype/db/v6/models.go @@ -3,6 +3,7 @@ package v6 import ( "encoding/json" "fmt" + "strings" "time" "github.com/OneOfOne/xxhash" @@ -285,6 +286,32 @@ type OperatingSystem struct { Codename string `gorm:"column:codename"` } +func (os *OperatingSystem) VersionNumber() string { + if os.MinorVersion != "" { + return fmt.Sprintf("%s.%s", os.MajorVersion, os.MinorVersion) + } + return os.MajorVersion +} + +func (os *OperatingSystem) Version() string { + if os == nil { + return "" + } + + if os.LabelVersion != "" { + return os.LabelVersion + } + + if os.MajorVersion != "" { + if os.MinorVersion != "" { + return fmt.Sprintf("%s.%s", os.MajorVersion, os.MinorVersion) + } + return os.MajorVersion + } + + return os.Codename +} + func (os *OperatingSystem) BeforeCreate(tx *gorm.DB) (err error) { if (os.MajorVersion != "" || os.MinorVersion != "") && os.LabelVersion != "" { return fmt.Errorf("cannot have both label_version and major_version/minor_version set") @@ -410,7 +437,13 @@ type Cpe struct { } func (c Cpe) String() string { - return fmt.Sprintf("%s:%s:%s:::%s:%s:%s:%s:%s:%s", c.Part, c.Vendor, c.Product, c.Edition, c.Language, c.SoftwareEdition, c.TargetHardware, c.TargetSoftware, c.Other) + parts := []string{"cpe:2.3", c.Part, c.Vendor, c.Product, c.Edition, c.Language, c.SoftwareEdition, c.TargetHardware, c.TargetSoftware, c.Other} + for i, part := range parts { + if part == "" { + parts[i] = "*" + } + } + return strings.Join(parts, ":") } func (c *Cpe) BeforeCreate(tx *gorm.DB) (err error) {