mirror of
https://github.com/kubescape/kubescape.git
synced 2026-02-14 18:09:55 +00:00
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:
93
cmd/scan/image.go
Normal file
93
cmd/scan/image.go
Normal 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
|
||||
}
|
||||
@@ -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
1
pkg/imagescan/doc.go
Normal file
@@ -0,0 +1 @@
|
||||
package imagescan
|
||||
177
pkg/imagescan/imagescan.go
Normal file
177
pkg/imagescan/imagescan.go
Normal 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)
|
||||
}
|
||||
|
||||
221
pkg/imagescan/imagescan_test.go
Normal file
221
pkg/imagescan/imagescan_test.go
Normal 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)
|
||||
})
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user