add --fail-on threshold support (#156)

* add --fail-on threshold support

Signed-off-by: Alex Goodman <alex.goodman@anchore.com>

* rename fail-on support functions and variables

Signed-off-by: Alex Goodman <alex.goodman@anchore.com>

* remove UK spelling of canceled

Signed-off-by: Alex Goodman <alex.goodman@anchore.com>
This commit is contained in:
Alex Goodman 2020-09-21 17:12:21 -04:00 committed by GitHub
parent 0397206376
commit f0f8f4bf02
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
9 changed files with 298 additions and 26 deletions

View file

@ -14,7 +14,7 @@ RESET := $(shell tput -T linux sgr0)
TITLE := $(BOLD)$(PURPLE)
SUCCESS := $(BOLD)$(GREEN)
# the quality gate lower threshold for unit test total % coverage (by function statements)
COVERAGE_THRESHOLD := 60
COVERAGE_THRESHOLD := 55
## Build variables
DISTDIR=./dist

View file

@ -2,6 +2,7 @@ package cmd
import (
"context"
"errors"
"fmt"
"os"
"runtime/pprof"
@ -10,7 +11,9 @@ import (
"github.com/anchore/grype/grype"
"github.com/anchore/grype/grype/event"
"github.com/anchore/grype/grype/grypeerr"
"github.com/anchore/grype/grype/presenter"
"github.com/anchore/grype/grype/result"
"github.com/anchore/grype/grype/vulnerability"
"github.com/anchore/grype/internal"
"github.com/anchore/grype/internal/bus"
@ -68,13 +71,18 @@ var rootCmd = &cobra.Command{
}
if err != nil {
log.Errorf(err.Error())
var grypeErr grypeerr.ExpectedErr
if errors.As(err, &grypeErr) {
fmt.Fprintln(os.Stderr, format.Red.Format(grypeErr.Error()))
} else {
log.Errorf(err.Error())
}
os.Exit(1)
}
},
ValidArgsFunction: func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) {
// Since we use ValidArgsFunction, Cobra will call this AFTER having parsed all flags and arguments provided
dockerImageRepoTags, err := ListLocalDockerImages(toComplete)
dockerImageRepoTags, err := listLocalDockerImages(toComplete)
if err != nil {
// Indicates that an error occurred and completions should be ignored
return []string{"completion failed"}, cobra.ShellCompDirectiveError
@ -112,9 +120,19 @@ func init() {
fmt.Printf("unable to bind flag '%s': %+v", flag, err)
os.Exit(1)
}
rootCmd.Flags().StringP(
"fail-on", "f", "",
fmt.Sprintf("set the return code to 1 if a vulnerability is found with a severity >= the given severity, options=%v", vulnerability.AllSeverities),
)
if err := viper.BindPFlag("fail-on-severity", rootCmd.Flags().Lookup("fail-on")); err != nil {
fmt.Printf("unable to bind flag '%s': %+v", "fail-on", err)
os.Exit(1)
}
}
func startWorker(userInput string) <-chan error {
// nolint:funlen
func startWorker(userInput string, failOnSeverity *vulnerability.Severity) <-chan error {
errs := make(chan error)
go func() {
defer close(errs)
@ -169,6 +187,13 @@ func startWorker(userInput string) <-chan error {
matches := grype.FindVulnerabilitiesForCatalog(provider, *theDistro, catalog)
// 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, results, metadataProvider) {
errs <- grypeerr.ErrAboveSeverityThreshold
}
bus.Publish(partybus.Event{
Type: event.VulnerabilityScanningFinished,
Value: presenter.GetPresenter(appConfig.PresenterOpt, matches, catalog, *theScope, metadataProvider),
@ -179,12 +204,34 @@ func startWorker(userInput string) <-chan error {
func runDefaultCmd(_ *cobra.Command, args []string) error {
userInput := args[0]
errs := startWorker(userInput)
errs := startWorker(userInput, appConfig.FailOnSeverity)
ux := ui.Select(appConfig.CliOptions.Verbosity > 0, appConfig.Quiet)
return ux(errs, eventSubscription)
}
func ListLocalDockerImages(prefix string) ([]string, error) {
// hitSeverityThreshold indicates if there are any severities >= to the max allowable severity (which is optional)
func hitSeverityThreshold(thresholdSeverity *vulnerability.Severity, results result.Result, metadataProvider vulnerability.MetadataProvider) bool {
if thresholdSeverity != nil {
var maxDiscoveredSeverity vulnerability.Severity
for m := range results.Enumerate() {
metadata, err := metadataProvider.GetMetadata(m.Vulnerability.ID, m.Vulnerability.RecordSource)
if err != nil {
continue
}
severity := vulnerability.ParseSeverity(metadata.Severity)
if severity > maxDiscoveredSeverity {
maxDiscoveredSeverity = severity
}
}
if maxDiscoveredSeverity >= *thresholdSeverity {
return true
}
}
return false
}
func listLocalDockerImages(prefix string) ([]string, error) {
var repoTags = make([]string, 0)
ctx := context.Background()
cli, err := client.NewClientWithOpts(client.FromEnv, client.WithAPIVersionNegotiation())

107
cmd/root_test.go Normal file
View file

@ -0,0 +1,107 @@
package cmd
import (
"github.com/anchore/grype/grype/match"
"github.com/anchore/grype/grype/result"
"github.com/anchore/grype/grype/vulnerability"
"github.com/anchore/syft/syft/pkg"
"testing"
)
import v1 "github.com/anchore/grype-db/pkg/db/v1"
type mockMetadataStore struct {
data map[string]map[string]*v1.VulnerabilityMetadata
}
func newMockStore() *mockMetadataStore {
d := mockMetadataStore{
data: make(map[string]map[string]*v1.VulnerabilityMetadata),
}
d.stub()
return &d
}
func (d *mockMetadataStore) stub() {
d.data["CVE-2014-fake-1"] = map[string]*v1.VulnerabilityMetadata{
"source-1": {
Severity: "medium",
},
}
}
func (d *mockMetadataStore) GetVulnerabilityMetadata(id, recordSource string) (*v1.VulnerabilityMetadata, error) {
return d.data[id][recordSource], nil
}
func TestAboveAllowableSeverity(t *testing.T) {
thePkg := &pkg.Package{
Name: "the-package",
Version: "v0.1",
FoundBy: "nothing",
Type: pkg.RpmPkg,
}
matches := result.NewResult()
matches.Add(thePkg, match.Match{
Type: match.ExactDirectMatch,
Vulnerability: vulnerability.Vulnerability{
ID: "CVE-2014-fake-1",
RecordSource: "source-1",
},
Package: thePkg,
})
tests := []struct {
name string
failOnSeverity string
matches result.Result
expectedResult bool
}{
{
name: "no-severity-set",
failOnSeverity: "",
matches: matches,
expectedResult: false,
},
{
name: "below-threshold",
failOnSeverity: "high",
matches: matches,
expectedResult: false,
},
{
name: "at-threshold",
failOnSeverity: "medium",
matches: matches,
expectedResult: true,
},
{
name: "above-threshold",
failOnSeverity: "low",
matches: matches,
expectedResult: true,
},
}
metadataProvider := vulnerability.NewMetadataStoreProvider(newMockStore())
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
var failOnSeverity *vulnerability.Severity
if test.failOnSeverity != "" {
sev := vulnerability.ParseSeverity(test.failOnSeverity)
if sev == vulnerability.UnknownSeverity {
t.Fatalf("could not parse severity")
}
failOnSeverity = &sev
}
actual := hitSeverityThreshold(failOnSeverity, test.matches, metadataProvider)
if test.expectedResult != actual {
t.Errorf("expected: %v got : %v", test.expectedResult, actual)
}
})
}
}

6
grype/grypeerr/errors.go Normal file
View file

@ -0,0 +1,6 @@
package grypeerr
var (
// ErrAboveSeverityThreshold indicates when a vulnerability severity is discovered that is above the given --fail-on severity value
ErrAboveSeverityThreshold = NewExpectedErr("discovered vulnerabilities at or above the severity threshold")
)

View file

@ -0,0 +1,22 @@
package grypeerr
import (
"fmt"
)
// ExpectedErr represents a class of expected errors that grype may produce.
type ExpectedErr struct {
Err error
}
// New generates a new ExpectedErr.
func NewExpectedErr(msgFormat string, args ...interface{}) ExpectedErr {
return ExpectedErr{
Err: fmt.Errorf(msgFormat, args...),
}
}
// Error returns a string representing the underlying error condition.
func (e ExpectedErr) Error() string {
return e.Err.Error()
}

View file

@ -0,0 +1,56 @@
package vulnerability
import "strings"
const (
UnknownSeverity Severity = iota
NegligibleSeverity
LowSeverity
MediumSeverity
HighSeverity
CriticalSeverity
)
var matcherTypeStr = []string{
"UnknownSeverity",
"negligible",
"low",
"medium",
"high",
"critical",
}
var AllSeverities = []Severity{
NegligibleSeverity,
LowSeverity,
MediumSeverity,
HighSeverity,
CriticalSeverity,
}
type Severity int
func (f Severity) String() string {
if int(f) >= len(matcherTypeStr) || f < 0 {
return matcherTypeStr[0]
}
return matcherTypeStr[f]
}
func ParseSeverity(severity string) Severity {
switch strings.ToLower(severity) {
case NegligibleSeverity.String():
return NegligibleSeverity
case LowSeverity.String():
return LowSeverity
case MediumSeverity.String():
return MediumSeverity
case HighSeverity.String():
return HighSeverity
case CriticalSeverity.String():
return CriticalSeverity
default:
return UnknownSeverity
}
}

View file

@ -5,15 +5,14 @@ import (
"path"
"strings"
"github.com/sirupsen/logrus"
"github.com/anchore/grype/grype/presenter"
"github.com/adrg/xdg"
"github.com/anchore/grype/grype/db"
"github.com/anchore/grype/grype/presenter"
"github.com/anchore/grype/grype/vulnerability"
"github.com/anchore/grype/internal"
"github.com/anchore/syft/syft/scope"
"github.com/mitchellh/go-homedir"
"github.com/sirupsen/logrus"
"github.com/spf13/viper"
)
@ -34,6 +33,8 @@ type Application struct {
Db Database `mapstructure:"db"`
Dev Development `mapstructure:"dev"`
CheckForAppUpdate bool `mapstructure:"check-for-app-update"`
FailOn string `mapstructure:"fail-on-severity"`
FailOnSeverity *vulnerability.Severity
}
type Logging struct {
@ -143,6 +144,15 @@ func (cfg *Application) Build() error {
}
}
// set the fail-on option
if cfg.FailOn != "" {
failOnSeverity := vulnerability.ParseSeverity(cfg.FailOn)
if failOnSeverity == vulnerability.UnknownSeverity {
return fmt.Errorf("bad --fail-on severity value '%s'", cfg.FailOn)
}
cfg.FailOnSeverity = &failOnSeverity
}
return nil
}

View file

@ -3,11 +3,13 @@ package etui
import (
"bytes"
"context"
"errors"
"fmt"
"os"
"sync"
grypeEvent "github.com/anchore/grype/grype/event"
"github.com/anchore/grype/grype/grypeerr"
"github.com/anchore/grype/internal/log"
"github.com/anchore/grype/internal/logger"
"github.com/anchore/grype/internal/ui/common"
@ -73,16 +75,25 @@ func OutputToEphemeralTUI(workerErrs <-chan error, subscription *partybus.Subscr
ctx := context.Background()
grypeUIHandler := grypeUI.NewHandler()
eventLoop:
var errResult error
for {
select {
case err := <-workerErrs:
case err, ok := <-workerErrs:
if err != nil {
if errors.Is(err, grypeerr.ErrAboveSeverityThreshold) {
errResult = err
continue
}
return err
}
if !ok {
// worker completed
workerErrs = nil
}
case e, ok := <-events:
if !ok {
break eventLoop
// event bus closed
events = nil
}
switch {
case grypeUIHandler.RespondsTo(e):
@ -112,15 +123,15 @@ eventLoop:
}
// this is the last expected event
break eventLoop
events = nil
}
case <-ctx.Done():
if ctx.Err() != nil {
log.Errorf("cancelled (%+v)", err)
}
break eventLoop
return grypeerr.NewExpectedErr("canceled: %w", ctx.Err())
}
if events == nil && workerErrs == nil {
break
}
}
return nil
return errResult
}

View file

@ -1,7 +1,10 @@
package ui
import (
"errors"
grypeEvent "github.com/anchore/grype/grype/event"
"github.com/anchore/grype/grype/grypeerr"
"github.com/anchore/grype/internal/log"
"github.com/anchore/grype/internal/ui/common"
"github.com/wagoodman/go-partybus"
@ -9,17 +12,25 @@ import (
func LoggerUI(workerErrs <-chan error, subscription *partybus.Subscription) error {
events := subscription.Events()
eventLoop:
var errResult error
for {
select {
case err := <-workerErrs:
case err, ok := <-workerErrs:
if err != nil {
if errors.Is(err, grypeerr.ErrAboveSeverityThreshold) {
errResult = err
continue
}
return err
}
if !ok {
// worker completed
workerErrs = nil
}
case e, ok := <-events:
if !ok {
// event bus closed...
break eventLoop
// event bus closed
events = nil
}
// ignore all events except for the final event
@ -30,10 +41,12 @@ eventLoop:
}
// this is the last expected event
break eventLoop
events = nil
}
}
if events == nil && workerErrs == nil {
break
}
}
return nil
return errResult
}