feat(imagescan): add an image scanning command

This commit adds a CLI command and an associated package that scan
images for vulnerabilities.

Signed-off-by: Vlad Klokun <vklokun@protonmail.ch>

feat(imagescan): fail on exceeding the severity threshold

Signed-off-by: Vlad Klokun <vklokun@protonmail.ch>
This commit is contained in:
Vlad Klokun
2023-07-10 10:49:35 +03:00
parent 602591e7f2
commit 3b8bd7735e
5 changed files with 495 additions and 0 deletions

93
cmd/scan/image.go Normal file
View File

@@ -0,0 +1,93 @@
package scan
import (
"context"
"fmt"
"os"
logger "github.com/kubescape/go-logger"
"github.com/kubescape/kubescape/v2/core/cautils"
"github.com/kubescape/kubescape/v2/core/meta"
"github.com/kubescape/kubescape/v2/pkg/imagescan"
"github.com/anchore/grype/grype/presenter"
"github.com/spf13/cobra"
)
type imageScanInfo struct {
Username string
Password string
}
// TODO(vladklokun): document image scanning on the Kubescape Docs Hub?
var (
imageExample = fmt.Sprintf(`
# Scan the 'nginx' image
%[1]s scan image "nginx"
# Image scan documentation:
# https://hub.armosec.io/docs/images
`, cautils.ExecName())
)
// imageCmd represents the image command
func getImageCmd(ks meta.IKubescape, scanInfo *cautils.ScanInfo, imgScanInfo *imageScanInfo) *cobra.Command {
cmd := &cobra.Command{
Use: "image <IMAGE_NAME>",
Short: "Scans an image for vulnerabilities",
Example: imageExample,
Args: func(cmd *cobra.Command, args []string) error {
if len(args) != 1 {
return fmt.Errorf("The command takes exactly one image.")
}
return nil
},
RunE: func(cmd *cobra.Command, args []string) error {
if err := validateImageScanInfo(scanInfo); err != nil {
return err
}
failOnSeverity := imagescan.ParseSeverity(scanInfo.FailThresholdSeverity)
ctx := context.Background()
dbCfg, _ := imagescan.NewDefaultDBConfig()
svc := imagescan.NewScanService(dbCfg)
creds := imagescan.RegistryCredentials{
Username: imgScanInfo.Username,
Password: imgScanInfo.Password,
}
userInput := args[0]
scanResults, err := svc.Scan(ctx, userInput, creds)
if err != nil {
return err
}
presenterConfig, _ := presenter.ValidatedConfig("table", "", false)
pres := presenter.GetPresenter(presenterConfig, *scanResults)
pres.Present(os.Stdout)
if imagescan.ExceedsSeverityThreshold(scanResults, failOnSeverity) {
terminateOnExceedingSeverity(scanInfo, logger.L())
}
return err
},
}
cmd.PersistentFlags().StringVarP(&imgScanInfo.Username, "username", "u", "", "Username for registry login")
cmd.PersistentFlags().StringVarP(&imgScanInfo.Password, "password", "p", "", "Password for registry login")
return cmd
}
// validateImageScanInfo validates the ScanInfo struct for the `image` command
func validateImageScanInfo(scanInfo *cautils.ScanInfo) error {
severity := scanInfo.FailThresholdSeverity
if err := validateSeverity(severity); severity != "" && err != nil {
return err
}
return nil
}

View File

@@ -118,5 +118,8 @@ func GetScanCommand(ks meta.IKubescape) *cobra.Command {
scanCmd.AddCommand(getControlCmd(ks, &scanInfo))
scanCmd.AddCommand(getFrameworkCmd(ks, &scanInfo))
isi := &imageScanInfo{}
scanCmd.AddCommand(getImageCmd(ks, &scanInfo, isi))
return scanCmd
}

1
pkg/imagescan/doc.go Normal file
View File

@@ -0,0 +1 @@
package imagescan

177
pkg/imagescan/imagescan.go Normal file
View File

@@ -0,0 +1,177 @@
package imagescan
import (
"context"
"errors"
"fmt"
"path/filepath"
"github.com/adrg/xdg"
"github.com/anchore/grype/grype"
"github.com/anchore/grype/grype/db"
"github.com/anchore/grype/grype/grypeerr"
"github.com/anchore/grype/grype/matcher"
"github.com/anchore/grype/grype/matcher/dotnet"
"github.com/anchore/grype/grype/matcher/golang"
"github.com/anchore/grype/grype/matcher/java"
"github.com/anchore/grype/grype/matcher/javascript"
"github.com/anchore/grype/grype/matcher/python"
"github.com/anchore/grype/grype/matcher/ruby"
"github.com/anchore/grype/grype/matcher/stock"
"github.com/anchore/grype/grype/pkg"
"github.com/anchore/grype/grype/presenter/models"
"github.com/anchore/grype/grype/store"
"github.com/anchore/grype/grype/vulnerability"
"github.com/anchore/stereoscope/pkg/image"
"github.com/anchore/syft/syft/pkg/cataloger"
)
const (
defaultGrypeListingURL = "https://toolbox-data.anchore.io/grype/databases/listing.json"
defaultDBDirName = "grypedb"
)
type RegistryCredentials struct {
Username string
Password string
}
func (c RegistryCredentials) IsEmpty() bool {
return c.Username == "" || c.Password == ""
}
// ExceedsSeverityThreshold returns true if vulnerabilities in the scan results exceed the severity threshold, false otherwise.
//
// Values equal to the threshold are considered failing, too.
func ExceedsSeverityThreshold(scanResults *models.PresenterConfig, severity vulnerability.Severity) bool {
return grype.HasSeverityAtOrAbove(scanResults.MetadataProvider, severity, scanResults.Matches)
}
func NewDefaultDBConfig() (db.Config, bool) {
dir := filepath.Join(xdg.CacheHome, defaultDBDirName)
url := defaultGrypeListingURL
shouldUpdate := true
return db.Config{
DBRootDir: dir,
ListingURL: url,
}, shouldUpdate
}
func getMatchers() []matcher.Matcher {
return matcher.NewDefaultMatchers(
matcher.Config{
Java: java.MatcherConfig{
ExternalSearchConfig: java.ExternalSearchConfig{MavenBaseURL: "https://search.maven.org/solrsearch/select"},
UseCPEs: true,
},
Ruby: ruby.MatcherConfig{UseCPEs: true},
Python: python.MatcherConfig{UseCPEs: true},
Dotnet: dotnet.MatcherConfig{UseCPEs: true},
Javascript: javascript.MatcherConfig{UseCPEs: true},
Golang: golang.MatcherConfig{UseCPEs: true},
Stock: stock.MatcherConfig{UseCPEs: true},
},
)
}
func validateDBLoad(loadErr error, status *db.Status) error {
if loadErr != nil {
return fmt.Errorf("failed to load vulnerability db: %w", loadErr)
}
if status == nil {
return fmt.Errorf("unable to determine the status of the vulnerability db")
}
if status.Err != nil {
return fmt.Errorf("db could not be loaded: %w", status.Err)
}
return nil
}
func getProviderConfig(creds RegistryCredentials) pkg.ProviderConfig {
syftCreds := []image.RegistryCredentials{{Username: creds.Username, Password: creds.Password}}
regOpts := &image.RegistryOptions{
Credentials: syftCreds,
}
catOpts := cataloger.DefaultConfig()
pc := pkg.ProviderConfig{
SyftProviderConfig: pkg.SyftProviderConfig{
RegistryOptions: regOpts,
CatalogingOptions: catOpts,
// Platform: appConfig.Platform,
// Name: appConfig.Name,
// DefaultImagePullSource: appConfig.DefaultImagePullSource,
},
SynthesisConfig: pkg.SynthesisConfig{
GenerateMissingCPEs: true,
},
}
return pc
}
// Service is a facade for image scanning functionality.
//
// It performs image scanning and everything needed in between.
type Service struct {
dbCfg db.Config
}
func (s *Service) Scan(ctx context.Context, userInput string, creds RegistryCredentials) (*models.PresenterConfig, error) {
var err error
store, status, dbCloser, err := grype.LoadVulnerabilityDB(s.dbCfg, true)
if err = validateDBLoad(err, status); err != nil {
return nil, err
}
packages, pkgContext, sbom, err := pkg.Provide(userInput, getProviderConfig(creds))
if err != nil {
return nil, err
}
if dbCloser != nil {
defer dbCloser.Close()
}
// applyDistroHint(packages, &pkgContext, appConfig)
matcher := grype.VulnerabilityMatcher{
Store: *store,
Matchers: getMatchers(),
}
remainingMatches, ignoredMatches, err := matcher.FindMatches(packages, pkgContext)
if err != nil {
if !errors.Is(err, grypeerr.ErrAboveSeverityThreshold) {
return nil, err
}
}
pb := models.PresenterConfig{
Matches: *remainingMatches,
IgnoredMatches: ignoredMatches,
Packages: packages,
Context: pkgContext,
MetadataProvider: store,
SBOM: sbom,
AppConfig: nil,
DBStatus: status,
}
return &pb, nil
}
func NewVulnerabilityDB(cfg db.Config, update bool) (*store.Store, *db.Status, *db.Closer, error) {
return grype.LoadVulnerabilityDB(cfg, update)
}
func NewScanService(dbCfg db.Config) Service {
return Service{dbCfg: dbCfg}
}
// ParseSeverity returns a Grype severity given a severity string
//
// Used as a thin wrapper for ease of access from one image scan package
func ParseSeverity(severity string) vulnerability.Severity {
return vulnerability.ParseSeverity(severity)
}

View File

@@ -0,0 +1,221 @@
package imagescan
import (
"context"
"testing"
"github.com/anchore/grype/grype/db"
grypedb "github.com/anchore/grype/grype/db/v5"
"github.com/anchore/grype/grype/match"
"github.com/anchore/grype/grype/pkg"
"github.com/anchore/grype/grype/presenter/models"
"github.com/anchore/grype/grype/vulnerability"
syftPkg "github.com/anchore/syft/syft/pkg"
"github.com/google/uuid"
"github.com/stretchr/testify/assert"
)
func TestNewScanService(t *testing.T) {
dbCfg, _ := NewDefaultDBConfig()
svc := NewScanService(dbCfg)
assert.IsType(t, Service{}, svc)
}
func TestRegistryCredentials(t *testing.T) {
tt := []struct {
name string
username string
password string
want bool
}{
{
name: "Valid credentials should not be empty",
username: "user",
password: "pass",
want: false,
},
{
name: "Empty username should be considered empty credentials",
username: "",
password: "pass",
want: true,
},
{
name: "Empty password should be considered empty credentials",
username: "user",
password: "",
want: true,
},
{
name: "Empty user and password should be considered empty credentials",
username: "",
password: "",
want: true,
},
}
for _, tc := range tt {
t.Run(tc.name, func(t *testing.T) {
creds := RegistryCredentials{Username: tc.username, Password: tc.password}
got := creds.IsEmpty()
assert.Equal(t, tc.want, got)
})
}
}
func TestScan(t *testing.T) {
tt := []struct {
name string
image string
creds RegistryCredentials
}{
{
name: "Valid image name produces a non-nil scan result",
image: "nginx",
},
{
name: "Scanning a valid image with provided credentials should produce a non-nil scan result",
image: "nginx",
creds: RegistryCredentials{
Username: "test",
Password: "password",
},
},
}
for _, tc := range tt {
t.Run(tc.name, func(t *testing.T) {
ctx := context.Background()
dbCfg, _ := NewDefaultDBConfig()
svc := NewScanService(dbCfg)
creds := RegistryCredentials{}
scanResults, err := svc.Scan(ctx, tc.image, creds)
assert.NoError(t, err)
assert.IsType(t, &models.PresenterConfig{}, scanResults)
})
}
}
// fakeMetaProvider is a test double that fakes an actual MetadataProvider
type fakeMetaProvider struct {
vulnerabilities map[string]map[string][]grypedb.Vulnerability
metadata map[string]map[string]*grypedb.VulnerabilityMetadata
}
func newFakeMetaProvider() *fakeMetaProvider {
d := fakeMetaProvider{
vulnerabilities: make(map[string]map[string][]grypedb.Vulnerability),
metadata: make(map[string]map[string]*grypedb.VulnerabilityMetadata),
}
d.fillWithData()
return &d
}
func (d *fakeMetaProvider) GetAllVulnerabilityMetadata() (*[]grypedb.VulnerabilityMetadata, error) {
return nil, nil
}
func (d *fakeMetaProvider) GetVulnerabilityMatchExclusion(id string) ([]grypedb.VulnerabilityMatchExclusion, error) {
return nil, nil
}
func (d *fakeMetaProvider) GetVulnerabilityMetadata(id, namespace string) (*grypedb.VulnerabilityMetadata, error) {
return d.metadata[id][namespace], nil
}
func (d *fakeMetaProvider) fillWithData() {
d.metadata["CVE-2014-fake-1"] = map[string]*grypedb.VulnerabilityMetadata{
"debian:distro:debian:8": {
ID: "CVE-2014-fake-1",
Namespace: "debian:distro:debian:8",
Severity: "medium",
},
}
d.vulnerabilities["debian:distro:debian:8"] = map[string][]grypedb.Vulnerability{
"neutron": {
{
PackageName: "neutron",
Namespace: "debian:distro:debian:8",
VersionConstraint: "< 2014.1.3-6",
ID: "CVE-2014-fake-1",
VersionFormat: "deb",
},
},
}
}
func TestExceedsSeverityThreshold(t *testing.T) {
thePkg := pkg.Package{
ID: pkg.ID(uuid.NewString()),
Name: "the-package",
Version: "v0.1",
Type: syftPkg.RpmPkg,
}
matches := match.NewMatches()
matches.Add(match.Match{
Vulnerability: vulnerability.Vulnerability{
ID: "CVE-2014-fake-1",
Namespace: "debian:distro:debian:8",
},
Package: thePkg,
Details: match.Details{
{
Type: match.ExactDirectMatch,
},
},
})
tt := []struct {
name string
failOnSeverity string
matches match.Matches
expectedResult bool
}{
{
name: "No severity set should pass",
failOnSeverity: "",
matches: matches,
expectedResult: false,
},
{
name: "Fail severity higher than vulnerability should not fail",
failOnSeverity: "high",
matches: matches,
expectedResult: false,
},
{
name: "Fail severity equal to vulnerability should fail",
failOnSeverity: "medium",
matches: matches,
expectedResult: true,
},
{
name: "Fail severity below found vuln should fail",
failOnSeverity: "low",
matches: matches,
expectedResult: true,
},
}
metadataProvider := db.NewVulnerabilityMetadataProvider(newFakeMetaProvider())
for _, tc := range tt {
t.Run(tc.name, func(t *testing.T) {
scanResults := &models.PresenterConfig{
Matches: tc.matches,
MetadataProvider: metadataProvider,
}
inputSeverity := vulnerability.ParseSeverity(tc.failOnSeverity)
ours := ExceedsSeverityThreshold(scanResults, inputSeverity)
assert.Equal(t, tc.expectedResult, ours)
})
}
}