diff --git a/README.md b/README.md index 3efb44ab..e8f8daf2 100644 --- a/README.md +++ b/README.md @@ -55,6 +55,7 @@ To include software from all image layers in the vulnerability scan, regardless ``` grype --scope all-layers ``` +### Supported sources Grype can scan a variety of sources beyond those found in Docker. @@ -89,6 +90,8 @@ dir:path/to/yourproject read directly from a path on disk (any di registry:yourrepo/yourimage:tag pull image directly from a registry (no container runtime required) ``` +### Output formats + The output format for Grype is configurable as well: ``` grype -o @@ -98,9 +101,9 @@ Where the `format`s available are: - `table`: A columnar summary (default). - `cyclonedx`: An XML report conforming to the [CycloneDX 1.2](https://cyclonedx.org/) specification. - `json`: Use this to get as much information out of Grype as possible! -- `template`: Lets the user specify the output format. See [Using Templates](#using-templates) below. +- `template`: Lets the user specify the output format. See ["Using templates"](#using-templates) below. -### Using Templates +### Using templates Grype lets you define custom output formats, using [Go templates](https://golang.org/pkg/text/template/). Here's how it works: @@ -131,7 +134,60 @@ Which would produce output like: ... ``` -### Grype's Database +### Gating on severity of vulnerabilities + +You can have Grype exit with an error if any vulnerabilities are reported at or above the specified severity level. This comes in handy when using Grype within a script or CI pipeline. To do this, use the `--fail-on ` CLI flag. + +For example, here's how you could trigger a CI pipeline failure if any vulnerabilities are found in the `ubuntu:latest` image with a severity of "medium" or higher: + +``` +grype ubuntu:latest --fail-on medium +``` + +### Specifying matches to ignore + +If you're seeing Grype report **false positives** or any other vulnerability matches that you just don't want to see, you can tell Grype to **ignore** matches by specifying one or more _"ignore rules"_ in your Grype configuration file (e.g. `~/.grype.yaml`). This causes Grype not to report any vulnerability matches that meet the criteria specified by any of your ignore rules. + +Each rule can specify any combination of the following criteria: + +- vulnerability ID (e.g. `"CVE-2008-4318"`) +- package name (e.g. `"libcurl"`) +- package version (e.g. `"1.5.1"`) +- package type (e.g. `"npm"`; these values are defined [here](https://github.com/anchore/syft/blob/main/syft/pkg/type.go#L10-L21)) +- package location (e.g. `"/usr/local/lib/node_modules/**"`; supports glob patterns) + +Here's an example `~/.grype.yaml` that demonstrates the expected format for ignore rules: + +```yaml +ignore: + + # This is the full set of supported rule fields: + - vulnerability: CVE-2008-4318 + package: + name: libcurl + version: 1.5.1 + type: npm + location: "/usr/local/lib/node_modules/**" + + # We can make rules to match just by vulnerability ID: + - vulnerability: CVE-2017-41432 + + # ...or just by a single package field: + - package: + type: gem +``` + +Vulnerability matches will be ignored if **any** rules apply to the match. A rule is considered to apply to a given vulnerability match only if **all** fields specified in the rule apply to the vulnerability match. + +When you run Grype while specifying ignore rules, the following happens to the vulnerability matches that are "ignored": + +- Ignored matches are **completely hidden** from Grype's output, except for when using the `json` or `template` output formats; however, in these two formats, the ignored matches are **removed** from the existing `matches` array field, and they are placed in a new `ignoredMatches` array field. Each listed ignored match also has an additional field, `appliedIgnoreRules`, which is an array of any rules that caused Grype to ignore this vulnerability match. + +- Ignored matches **do not** factor into Grype's exit status decision when using `--fail-on `. For instance, if a user specifies `--fail-on critical`, and all of the vulnerability matches found with a "critical" severity have been _ignored_, Grype will exit zero. + +**Note:** Please continue to **[report](https://github.com/anchore/grype/issues/new/choose)** any false positives you see! Even if you can reliably filter out false positives using ignore rules, it's very helpful to the Grype community if we have as much knowledge about Grype's false positives as possible. This helps us continuously improve Grype! + +### Grype's database Grype pulls a database of vulnerabilities derived from the publicly available [Anchore Feed Service](https://ancho.re/v1/service/feeds). This database is updated at the beginning of each scan, but an update can also be triggered manually. @@ -158,7 +214,7 @@ brew tap anchore/grype brew install grype ``` -## Shell Completion +## Shell completion Grype supplies shell completion through its CLI implementation ([cobra](https://github.com/spf13/cobra/blob/master/shell_completions.md)). Generate the completion code for your shell by running one of the following commands: diff --git a/cmd/root.go b/cmd/root.go index fa793d72..20bf57a8 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -228,18 +228,23 @@ func startWorker(userInput string, failOnSeverity *vulnerability.Severity) <-cha return } - matches := grype.FindVulnerabilitiesForPackage(provider, context.Distro, packages...) + allMatches := grype.FindVulnerabilitiesForPackage(provider, context.Distro, packages...) + remainingMatches, ignoredMatches := match.ApplyIgnoreRules(allMatches, appConfig.Ignore) + + if count := len(ignoredMatches); count > 0 { + log.Infof("Ignoring %d matches due to user-provided ignore rules", count) + } // determine if there are any severities >= to the max allowable severity (which is optional). // note: until the shared file lock in sqlittle is fixed the sqlite DB cannot be access concurrently, // implying that the fail-on-severity check must be done before sending the presenter object. - if hitSeverityThreshold(failOnSeverity, matches, metadataProvider) { + if hitSeverityThreshold(failOnSeverity, remainingMatches, metadataProvider) { errs <- grypeerr.ErrAboveSeverityThreshold } bus.Publish(partybus.Event{ Type: event.VulnerabilityScanningFinished, - Value: presenter.GetPresenter(presenterConfig, matches, packages, context, metadataProvider, appConfig, dbStatus), + Value: presenter.GetPresenter(presenterConfig, remainingMatches, ignoredMatches, packages, context, metadataProvider, appConfig, dbStatus), }) }() return errs diff --git a/go.mod b/go.mod index a59f41bb..982493b4 100644 --- a/go.mod +++ b/go.mod @@ -10,10 +10,12 @@ require ( github.com/anchore/grype-db v0.0.0-20210928194208-f146397d6cd0 github.com/anchore/stereoscope v0.0.0-20210817160504-0f4abc2a5a5a github.com/anchore/syft v0.24.1 + github.com/bmatcuk/doublestar/v2 v2.0.4 github.com/docker/docker v17.12.0-ce-rc1.0.20200309214505-aa6a9891b09c+incompatible github.com/dustin/go-humanize v1.0.0 github.com/facebookincubator/nvdtools v0.1.4 github.com/go-test/deep v1.0.7 + github.com/google/go-cmp v0.4.1 github.com/google/uuid v1.1.1 github.com/gookit/color v1.2.7 github.com/hashicorp/go-getter v1.4.1 diff --git a/grype/match/ignore.go b/grype/match/ignore.go new file mode 100644 index 00000000..48915fe2 --- /dev/null +++ b/grype/match/ignore.go @@ -0,0 +1,167 @@ +package match + +import "github.com/bmatcuk/doublestar/v2" + +// An IgnoredMatch is a vulnerability Match that has been ignored because one or more IgnoreRules applied to the match. +type IgnoredMatch struct { + Match + + // AppliedIgnoreRules are the rules that were applied to the match that caused Grype to ignore it. + AppliedIgnoreRules []IgnoreRule +} + +// An IgnoreRule specifies criteria for a vulnerability match to meet in order +// to be ignored. Not all criteria (fields) need to be specified, but all +// specified criteria must be met by the vulnerability match in order for the +// rule to apply. +type IgnoreRule struct { + Vulnerability string `yaml:"vulnerability" json:"vulnerability" mapstructure:"vulnerability"` + Package IgnoreRulePackage `yaml:"package" json:"package" mapstructure:"package"` +} + +// IgnoreRulePackage describes the Package-specific fields that comprise the IgnoreRule. +type IgnoreRulePackage struct { + Name string `yaml:"name" json:"name" mapstructure:"name"` + Version string `yaml:"version" json:"version" mapstructure:"version"` + Type string `yaml:"type" json:"type" mapstructure:"type"` + Location string `yaml:"location" json:"location" mapstructure:"location"` +} + +// ApplyIgnoreRules iterates through the provided matches and, for each match, +// determines if the match should be ignored, by evaluating if any of the +// provided IgnoreRules apply to the match. If any rules apply to the match, all +// applicable rules are attached to the Match to form an IgnoredMatch. +// ApplyIgnoreRules returns two collections: the matches that are not being +// ignored, and the matches that are being ignored. +func ApplyIgnoreRules(matches Matches, rules []IgnoreRule) (Matches, []IgnoredMatch) { + if len(rules) == 0 { + return matches, nil + } + + var ignoredMatches []IgnoredMatch + remainingMatches := NewMatches() + + for match := range matches.Enumerate() { + var applicableRules []IgnoreRule + + for _, rule := range rules { + if shouldIgnore(match, rule) { + applicableRules = append(applicableRules, rule) + } + } + + if len(applicableRules) > 0 { + ignoredMatches = append(ignoredMatches, IgnoredMatch{ + Match: match, + AppliedIgnoreRules: applicableRules, + }) + + continue + } + + remainingMatches.add(match.Package.ID, match) + } + + return remainingMatches, ignoredMatches +} + +func shouldIgnore(match Match, rule IgnoreRule) bool { + ignoreConditions := getIgnoreConditionsForRule(rule) + if len(ignoreConditions) == 0 { + // this rule specifies no criteria, so it doesn't apply to the Match + return false + } + + for _, condition := range ignoreConditions { + if !condition(match) { + // as soon as one rule criterion doesn't apply, we know this rule doesn't apply to the Match + return false + } + } + + // all criteria specified in the rule apply to this Match + return true +} + +// An ignoreCondition is a function that returns a boolean indicating whether +// the given Match should be ignored. +type ignoreCondition func(match Match) bool + +func getIgnoreConditionsForRule(rule IgnoreRule) []ignoreCondition { + var ignoreConditions []ignoreCondition + + if v := rule.Vulnerability; v != "" { + ignoreConditions = append(ignoreConditions, ifVulnerabilityApplies(v)) + } + + if n := rule.Package.Name; n != "" { + ignoreConditions = append(ignoreConditions, ifPackageNameApplies(n)) + } + + if v := rule.Package.Version; v != "" { + ignoreConditions = append(ignoreConditions, ifPackageVersionApplies(v)) + } + + if t := rule.Package.Type; t != "" { + ignoreConditions = append(ignoreConditions, ifPackageTypeApplies(t)) + } + + if l := rule.Package.Location; l != "" { + ignoreConditions = append(ignoreConditions, ifPackageLocationApplies(l)) + } + + return ignoreConditions +} + +func ifVulnerabilityApplies(vulnerability string) ignoreCondition { + return func(match Match) bool { + return vulnerability == match.Vulnerability.ID + } +} + +func ifPackageNameApplies(name string) ignoreCondition { + return func(match Match) bool { + return name == match.Package.Name + } +} + +func ifPackageVersionApplies(version string) ignoreCondition { + return func(match Match) bool { + return version == match.Package.Version + } +} + +func ifPackageTypeApplies(t string) ignoreCondition { + return func(match Match) bool { + return t == string(match.Package.Type) + } +} + +func ifPackageLocationApplies(location string) ignoreCondition { + return func(match Match) bool { + return ruleLocationAppliesToMatch(location, match) + } +} + +func ruleLocationAppliesToMatch(location string, match Match) bool { + for _, packageLocation := range match.Package.Locations { + if ruleLocationAppliesToPath(location, packageLocation.RealPath) { + return true + } + + if ruleLocationAppliesToPath(location, packageLocation.VirtualPath) { + return true + } + } + + return false +} + +func ruleLocationAppliesToPath(location, path string) bool { + doesMatch, err := doublestar.Match(location, path) + if err != nil { + return false + } + + return doesMatch +} diff --git a/grype/match/ignore_test.go b/grype/match/ignore_test.go new file mode 100644 index 00000000..bf450c34 --- /dev/null +++ b/grype/match/ignore_test.go @@ -0,0 +1,318 @@ +package match + +import ( + "testing" + + "github.com/google/go-cmp/cmp" + + "github.com/anchore/syft/syft/source" + + "github.com/anchore/grype/grype/pkg" + "github.com/anchore/grype/grype/vulnerability" + + "github.com/stretchr/testify/assert" +) + +var ( + allMatches = []Match{ + { + Vulnerability: vulnerability.Vulnerability{ + ID: "CVE-123", + }, + Package: pkg.Package{ + Name: "dive", + Version: "0.5.2", + Type: "deb", + Locations: []source.Location{ + { + RealPath: "/path/that/has/dive", + }, + }, + }, + }, + { + Vulnerability: vulnerability.Vulnerability{ + ID: "CVE-456", + }, + Package: pkg.Package{ + Name: "reach", + Version: "100.0.50", + Type: "gem", + Locations: []source.Location{ + { + RealPath: "/real/path/with/reach", + VirtualPath: "/virtual/path/that/has/reach", + }, + }, + }, + }, + } +) + +func TestApplyIgnoreRules(t *testing.T) { + cases := []struct { + name string + allMatches []Match + ignoreRules []IgnoreRule + expectedRemainingMatches []Match + expectedIgnoredMatches []IgnoredMatch + }{ + { + name: "no ignore rules", + allMatches: allMatches, + ignoreRules: nil, + expectedRemainingMatches: allMatches, + expectedIgnoredMatches: nil, + }, + { + name: "no applicable ignore rules", + allMatches: allMatches, + ignoreRules: []IgnoreRule{ + { + Vulnerability: "CVE-789", + }, + { + Package: IgnoreRulePackage{ + Name: "bashful", + Version: "5", + Type: "npm", + }, + }, + { + Package: IgnoreRulePackage{ + Name: "reach", + Version: "3000", + }, + }, + }, + expectedRemainingMatches: allMatches, + expectedIgnoredMatches: nil, + }, + { + name: "ignore all matches", + allMatches: allMatches, + ignoreRules: []IgnoreRule{ + { + Vulnerability: "CVE-123", + }, + { + Package: IgnoreRulePackage{ + Location: "/virtual/path/that/has/reach", + }, + }, + }, + expectedRemainingMatches: nil, + expectedIgnoredMatches: []IgnoredMatch{ + { + Match: allMatches[0], + AppliedIgnoreRules: []IgnoreRule{ + { + Vulnerability: "CVE-123", + }, + }, + }, + { + Match: allMatches[1], + AppliedIgnoreRules: []IgnoreRule{ + { + Package: IgnoreRulePackage{ + Location: "/virtual/path/that/has/reach", + }, + }, + }, + }, + }, + }, + { + name: "ignore subset of matches", + allMatches: allMatches, + ignoreRules: []IgnoreRule{ + { + Vulnerability: "CVE-456", + }, + }, + expectedRemainingMatches: []Match{ + allMatches[0], + }, + expectedIgnoredMatches: []IgnoredMatch{ + { + Match: allMatches[1], + AppliedIgnoreRules: []IgnoreRule{ + { + Vulnerability: "CVE-456", + }, + }, + }, + }, + }, + } + + for _, testCase := range cases { + t.Run(testCase.name, func(t *testing.T) { + locationComparerOption := cmp.Comparer(func(x, y source.Location) bool { + return x.RealPath == y.RealPath && x.VirtualPath == y.VirtualPath + }) + + actualRemainingMatches, actualIgnoredMatches := ApplyIgnoreRules(sliceToMatches(testCase.allMatches), testCase.ignoreRules) + + if diff := cmp.Diff(testCase.expectedRemainingMatches, matchesToSlice(actualRemainingMatches), locationComparerOption); diff != "" { + t.Errorf("unexpected diff in remaining matches (-expected +actual):\n%s", diff) + } + + if diff := cmp.Diff(testCase.expectedIgnoredMatches, actualIgnoredMatches, locationComparerOption); diff != "" { + t.Errorf("unexpected diff in ignored matches (-expected +actual):\n%s", diff) + } + }) + } +} + +func sliceToMatches(s []Match) Matches { + matches := NewMatches() + matches.add("123", s...) + return matches +} + +func matchesToSlice(m Matches) []Match { + slice := m.Sorted() + if len(slice) == 0 { + return nil + } + + return slice +} + +var ( + exampleMatch = Match{ + Vulnerability: vulnerability.Vulnerability{ + ID: "CVE-2000-1234", + }, + Package: pkg.Package{ + Name: "a-pkg", + Version: "1.0", + Locations: []source.Location{ + { + RealPath: "/some/path", + }, + { + RealPath: "/some/path", + VirtualPath: "/some/virtual/path", + }, + }, + Type: "rpm", + }, + } +) + +func TestShouldIgnore(t *testing.T) { + cases := []struct { + name string + match Match + rule IgnoreRule + expected bool + }{ + { + name: "empty rule", + match: exampleMatch, + rule: IgnoreRule{}, + expected: false, + }, + { + name: "rule applies via vulnerability ID", + match: exampleMatch, + rule: IgnoreRule{ + Vulnerability: exampleMatch.Vulnerability.ID, + }, + expected: true, + }, + { + name: "rule applies via package name", + match: exampleMatch, + rule: IgnoreRule{ + Package: IgnoreRulePackage{ + Name: exampleMatch.Package.Name, + }, + }, + expected: true, + }, + { + name: "rule applies via package version", + match: exampleMatch, + rule: IgnoreRule{ + Package: IgnoreRulePackage{ + Version: exampleMatch.Package.Version, + }, + }, + expected: true, + }, + { + name: "rule applies via package type", + match: exampleMatch, + rule: IgnoreRule{ + Package: IgnoreRulePackage{ + Type: string(exampleMatch.Package.Type), + }, + }, + expected: true, + }, + { + name: "rule applies via package location real path", + match: exampleMatch, + rule: IgnoreRule{ + Package: IgnoreRulePackage{ + Location: exampleMatch.Package.Locations[0].RealPath, + }, + }, + expected: true, + }, + { + name: "rule applies via package location virtual path", + match: exampleMatch, + rule: IgnoreRule{ + Package: IgnoreRulePackage{ + Location: exampleMatch.Package.Locations[1].VirtualPath, + }, + }, + expected: true, + }, + { + name: "rule applies via package location glob", + match: exampleMatch, + rule: IgnoreRule{ + Package: IgnoreRulePackage{ + Location: "/some/**", + }, + }, + expected: true, + }, + { + name: "rule applies via multiple fields", + match: exampleMatch, + rule: IgnoreRule{ + Vulnerability: exampleMatch.Vulnerability.ID, + Package: IgnoreRulePackage{ + Type: string(exampleMatch.Package.Type), + }, + }, + expected: true, + }, + { + name: "rule doesn't apply despite some fields matching", + match: exampleMatch, + rule: IgnoreRule{ + Vulnerability: exampleMatch.Vulnerability.ID, + Package: IgnoreRulePackage{ + Name: "not-the-right-package", + Version: exampleMatch.Package.Version, + }, + }, + expected: false, + }, + } + + for _, testCase := range cases { + t.Run(testCase.name, func(t *testing.T) { + actual := shouldIgnore(testCase.match, testCase.rule) + assert.Equal(t, testCase.expected, actual) + }) + } +} diff --git a/grype/presenter/json/presenter.go b/grype/presenter/json/presenter.go index 220c5a62..ce8ff3dd 100644 --- a/grype/presenter/json/presenter.go +++ b/grype/presenter/json/presenter.go @@ -14,6 +14,7 @@ import ( // Presenter is a generic struct for holding fields needed for reporting type Presenter struct { matches match.Matches + ignoredMatches []match.IgnoredMatch packages []pkg.Package context pkg.Context metadataProvider vulnerability.MetadataProvider @@ -22,10 +23,10 @@ type Presenter struct { } // NewPresenter is a *Presenter constructor -func NewPresenter(matches match.Matches, packages []pkg.Package, context pkg.Context, - metadataProvider vulnerability.MetadataProvider, appConfig interface{}, dbStatus interface{}) *Presenter { +func NewPresenter(matches match.Matches, ignoredMatches []match.IgnoredMatch, packages []pkg.Package, context pkg.Context, metadataProvider vulnerability.MetadataProvider, appConfig interface{}, dbStatus interface{}) *Presenter { return &Presenter{ matches: matches, + ignoredMatches: ignoredMatches, packages: packages, metadataProvider: metadataProvider, context: context, @@ -36,7 +37,8 @@ func NewPresenter(matches match.Matches, packages []pkg.Package, context pkg.Con // Present creates a JSON-based reporting func (pres *Presenter) Present(output io.Writer) error { - doc, err := models.NewDocument(pres.packages, pres.context, pres.matches, pres.metadataProvider, pres.appConfig, pres.dbStatus) + doc, err := models.NewDocument(pres.packages, pres.context, pres.matches, pres.ignoredMatches, pres.metadataProvider, + pres.appConfig, pres.dbStatus) if err != nil { return err } diff --git a/grype/presenter/json/presenter_test.go b/grype/presenter/json/presenter_test.go index b6ca4da0..536eda11 100644 --- a/grype/presenter/json/presenter_test.go +++ b/grype/presenter/json/presenter_test.go @@ -156,7 +156,7 @@ func TestJsonImgsPresenter(t *testing.T) { Source: &src.Metadata, Distro: &d, } - pres := NewPresenter(matches, packages, ctx, models.NewMetadataMock(), nil, nil) + pres := NewPresenter(matches, nil, packages, ctx, models.NewMetadataMock(), nil, nil) // TODO: add a constructor for a match.Match when the data is better shaped @@ -300,7 +300,7 @@ func TestJsonDirsPresenter(t *testing.T) { Source: &s.Metadata, Distro: &d, } - pres := NewPresenter(matches, pkg.FromCatalog(catalog), ctx, models.NewMetadataMock(), nil, nil) + pres := NewPresenter(matches, nil, pkg.FromCatalog(catalog), ctx, models.NewMetadataMock(), nil, nil) // TODO: add a constructor for a match.Match when the data is better shaped @@ -355,7 +355,7 @@ func TestEmptyJsonPresenter(t *testing.T) { Distro: &d, } - pres := NewPresenter(matches, []pkg.Package{}, ctx, nil, nil, nil) + pres := NewPresenter(matches, nil, []pkg.Package{}, ctx, nil, nil, nil) // run presenter if err = pres.Present(&buffer); err != nil { diff --git a/grype/presenter/models/document.go b/grype/presenter/models/document.go index 78efc179..1fc81686 100644 --- a/grype/presenter/models/document.go +++ b/grype/presenter/models/document.go @@ -12,15 +12,15 @@ import ( // Document represents the JSON document to be presented type Document struct { - Matches []Match `json:"matches"` - Source *source `json:"source"` - Distro distribution `json:"distro"` - Descriptor descriptor `json:"descriptor"` + Matches []Match `json:"matches"` + IgnoredMatches []IgnoredMatch `json:"ignoredMatches,omitempty"` + Source *source `json:"source"` + Distro distribution `json:"distro"` + Descriptor descriptor `json:"descriptor"` } // NewDocument creates and populates a new Document struct, representing the populated JSON document. -func NewDocument(packages []pkg.Package, context pkg.Context, matches match.Matches, - metadataProvider vulnerability.MetadataProvider, appConfig interface{}, dbStatus interface{}) (Document, error) { +func NewDocument(packages []pkg.Package, context pkg.Context, matches match.Matches, ignoredMatches []match.IgnoredMatch, metadataProvider vulnerability.MetadataProvider, appConfig interface{}, dbStatus interface{}) (Document, error) { // we must preallocate the findings to ensure the JSON document does not show "null" when no matches are found var findings = make([]Match, 0) for _, m := range matches.Sorted() { @@ -46,10 +46,30 @@ func NewDocument(packages []pkg.Package, context pkg.Context, matches match.Matc src = &theSrc } + var ignoredMatchModels []IgnoredMatch + for _, m := range ignoredMatches { + p := pkg.ByID(m.Package.ID, packages) + if p == nil { + return Document{}, fmt.Errorf("unable to find package in collection: %+v", p) + } + + matchModel, err := newMatch(m.Match, *p, metadataProvider) + if err != nil { + return Document{}, err + } + + ignoredMatch := IgnoredMatch{ + Match: *matchModel, + AppliedIgnoreRules: mapIgnoreRules(m.AppliedIgnoreRules), + } + ignoredMatchModels = append(ignoredMatchModels, ignoredMatch) + } + return Document{ - Matches: findings, - Source: src, - Distro: newDistribution(context.Distro), + Matches: findings, + IgnoredMatches: ignoredMatchModels, + Source: src, + Distro: newDistribution(context.Distro), Descriptor: descriptor{ Name: internal.ApplicationName, Version: version.FromBuild().Version, diff --git a/grype/presenter/models/document_test.go b/grype/presenter/models/document_test.go index 4eac0051..a26a900b 100644 --- a/grype/presenter/models/document_test.go +++ b/grype/presenter/models/document_test.go @@ -67,7 +67,7 @@ func TestPackagesAreSorted(t *testing.T) { }, Distro: &d, } - doc, err := NewDocument(packages, ctx, matches, NewMetadataMock(), nil, nil) + doc, err := NewDocument(packages, ctx, matches, nil, NewMetadataMock(), nil, nil) if err != nil { t.Fatalf("unable to get document: %+v", err) } diff --git a/grype/presenter/models/ignore.go b/grype/presenter/models/ignore.go new file mode 100644 index 00000000..ec79edc2 --- /dev/null +++ b/grype/presenter/models/ignore.go @@ -0,0 +1,49 @@ +package models + +import "github.com/anchore/grype/grype/match" + +type IgnoredMatch struct { + Match + AppliedIgnoreRules []IgnoreRule `json:"appliedIgnoreRules"` +} + +type IgnoreRule struct { + Vulnerability string `json:"vulnerability,omitempty"` + Package *IgnoreRulePackage `json:"package,omitempty"` +} + +type IgnoreRulePackage struct { + Name string `json:"name,omitempty"` + Version string `json:"version,omitempty"` + Type string `json:"type,omitempty"` + Location string `json:"location,omitempty"` +} + +func newIgnoreRule(r match.IgnoreRule) IgnoreRule { + var ignoreRulePackage *IgnoreRulePackage + + // We'll only set the package part of the rule not to `nil` if there are any values to fill out. + if p := r.Package; p.Name != "" || p.Version != "" || p.Type != "" || p.Location != "" { + ignoreRulePackage = &IgnoreRulePackage{ + Name: r.Package.Name, + Version: r.Package.Version, + Type: r.Package.Type, + Location: r.Package.Location, + } + } + + return IgnoreRule{ + Vulnerability: r.Vulnerability, + Package: ignoreRulePackage, + } +} + +func mapIgnoreRules(rules []match.IgnoreRule) []IgnoreRule { + var result []IgnoreRule + + for _, rule := range rules { + result = append(result, newIgnoreRule(rule)) + } + + return result +} diff --git a/grype/presenter/models/ignore_test.go b/grype/presenter/models/ignore_test.go new file mode 100644 index 00000000..12bb6fb5 --- /dev/null +++ b/grype/presenter/models/ignore_test.go @@ -0,0 +1,97 @@ +package models + +import ( + "testing" + + "github.com/google/go-cmp/cmp" + + "github.com/anchore/grype/grype/match" +) + +func TestNewIgnoreRule(t *testing.T) { + cases := []struct { + name string + input match.IgnoreRule + expected IgnoreRule + }{ + { + name: "no values", + input: match.IgnoreRule{}, + expected: IgnoreRule{ + Vulnerability: "", + Package: nil, + }, + }, + { + name: "only vulnerability field", + input: match.IgnoreRule{ + Vulnerability: "CVE-2020-1234", + }, + expected: IgnoreRule{ + Vulnerability: "CVE-2020-1234", + }, + }, + { + name: "all package fields", + input: match.IgnoreRule{ + Package: match.IgnoreRulePackage{ + Name: "libc", + Version: "3.0.0", + Type: "rpm", + Location: "/some/location", + }, + }, + expected: IgnoreRule{ + Package: &IgnoreRulePackage{ + Name: "libc", + Version: "3.0.0", + Type: "rpm", + Location: "/some/location", + }, + }, + }, + { + name: "only one package field", + input: match.IgnoreRule{ + Package: match.IgnoreRulePackage{ + Type: "apk", + }, + }, + expected: IgnoreRule{ + Package: &IgnoreRulePackage{ + Type: "apk", + }, + }, + }, + { + name: "all fields", + input: match.IgnoreRule{ + Vulnerability: "CVE-2020-1234", + Package: match.IgnoreRulePackage{ + Name: "libc", + Version: "3.0.0", + Type: "rpm", + Location: "/some/location", + }, + }, + expected: IgnoreRule{ + Vulnerability: "CVE-2020-1234", + Package: &IgnoreRulePackage{ + Name: "libc", + Version: "3.0.0", + Type: "rpm", + Location: "/some/location", + }, + }, + }, + } + + for _, testCase := range cases { + t.Run(testCase.name, func(t *testing.T) { + actual := newIgnoreRule(testCase.input) + if diff := cmp.Diff(testCase.expected, actual); diff != "" { + t.Errorf("(-expected +actual):\n%s", diff) + } + }) + } +} diff --git a/grype/presenter/presenter.go b/grype/presenter/presenter.go index 07a132fe..5ee84d48 100644 --- a/grype/presenter/presenter.go +++ b/grype/presenter/presenter.go @@ -19,17 +19,16 @@ type Presenter interface { } // GetPresenter retrieves a Presenter that matches a CLI option -func GetPresenter(presenterConfig Config, matches match.Matches, packages []pkg.Package, context pkg.Context, - metadataProvider vulnerability.MetadataProvider, appConfig interface{}, dbStatus interface{}) Presenter { +func GetPresenter(presenterConfig Config, matches match.Matches, ignoredMatches []match.IgnoredMatch, packages []pkg.Package, context pkg.Context, metadataProvider vulnerability.MetadataProvider, appConfig interface{}, dbStatus interface{}) Presenter { switch presenterConfig.format { case jsonFormat: - return json.NewPresenter(matches, packages, context, metadataProvider, appConfig, dbStatus) + return json.NewPresenter(matches, ignoredMatches, packages, context, metadataProvider, appConfig, dbStatus) case tableFormat: return table.NewPresenter(matches, packages, metadataProvider) case cycloneDXFormat: return cyclonedx.NewPresenter(matches, packages, context.Source, metadataProvider) case templateFormat: - return template.NewPresenter(matches, packages, context, metadataProvider, appConfig, dbStatus, presenterConfig.templateFilePath) + return template.NewPresenter(matches, ignoredMatches, packages, context, metadataProvider, appConfig, dbStatus, presenterConfig.templateFilePath) default: return nil } diff --git a/grype/presenter/template/presenter.go b/grype/presenter/template/presenter.go index 9b7ff5d4..3676c93d 100644 --- a/grype/presenter/template/presenter.go +++ b/grype/presenter/template/presenter.go @@ -19,6 +19,7 @@ import ( // Presenter is an implementation of presenter.Presenter that formats output according to a user-provided Go text template. type Presenter struct { matches match.Matches + ignoredMatches []match.IgnoredMatch packages []pkg.Package context pkg.Context metadataProvider vulnerability.MetadataProvider @@ -28,9 +29,10 @@ type Presenter struct { } // NewPresenter returns a new template.Presenter. -func NewPresenter(matches match.Matches, packages []pkg.Package, context pkg.Context, metadataProvider vulnerability.MetadataProvider, appConfig interface{}, dbStatus interface{}, pathToTemplateFile string) *Presenter { +func NewPresenter(matches match.Matches, ignoredMatches []match.IgnoredMatch, packages []pkg.Package, context pkg.Context, metadataProvider vulnerability.MetadataProvider, appConfig interface{}, dbStatus interface{}, pathToTemplateFile string) *Presenter { return &Presenter{ matches: matches, + ignoredMatches: ignoredMatches, packages: packages, metadataProvider: metadataProvider, context: context, @@ -58,7 +60,8 @@ func (pres *Presenter) Present(output io.Writer) error { return fmt.Errorf("unable to parse template: %w", err) } - document, err := models.NewDocument(pres.packages, pres.context, pres.matches, pres.metadataProvider, pres.appConfig, pres.dbStatus) + document, err := models.NewDocument(pres.packages, pres.context, pres.matches, pres.ignoredMatches, pres.metadataProvider, + pres.appConfig, pres.dbStatus) if err != nil { return err } diff --git a/grype/presenter/template/presenter_test.go b/grype/presenter/template/presenter_test.go index 012fae07..517555cf 100644 --- a/grype/presenter/template/presenter_test.go +++ b/grype/presenter/template/presenter_test.go @@ -23,7 +23,7 @@ func TestPresenter_Present(t *testing.T) { } templateFilePath := path.Join(workingDirectory, "./test-fixtures/test.template") - templatePresenter := NewPresenter(matches, packages, context, metadataProvider, appConfig, dbStatus, templateFilePath) + templatePresenter := NewPresenter(matches, nil, packages, context, metadataProvider, appConfig, dbStatus, templateFilePath) var buffer bytes.Buffer if err := templatePresenter.Present(&buffer); err != nil { diff --git a/internal/config/config.go b/internal/config/config.go index a4ecfaf8..b6fc23f5 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -7,6 +7,7 @@ import ( "github.com/adrg/xdg" "github.com/anchore/grype/grype/db" + "github.com/anchore/grype/grype/match" "github.com/anchore/grype/grype/vulnerability" "github.com/anchore/grype/internal" "github.com/anchore/syft/syft/source" @@ -35,6 +36,7 @@ type Application struct { FailOn string `mapstructure:"fail-on-severity"` FailOnSeverity *vulnerability.Severity `json:"-"` Registry registry `yaml:"registry" json:"registry" mapstructure:"registry"` + Ignore []match.IgnoreRule `yaml:"ignore" json:"ignore" mapstructure:"ignore"` } type Logging struct {