Support for SBOMs with incomplete linux distribution or CPE information (#606)

This commit is contained in:
Keith Zantow 2022-03-03 16:31:46 -05:00 committed by GitHub
parent ad9918a681
commit fc8e13f5b8
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
13 changed files with 196 additions and 51 deletions

View file

@ -91,19 +91,6 @@ grype path/to/image.tar
grype dir:path/to/dir
```
Use [Syft](https://github.com/anchore/syft) SBOMs for even faster vulnerability scanning in Grype:
```
# Just need to generate the SBOM once
syft <image> -o json > ./image-sbom.json
# Then scan for new vulnerabilities as frequently as needed
grype sbom:./image-sbom.json
# (You can also pipe the SBOM into Grype)
cat ./image-sbom.json | grype
```
Sources can be explicitly provided with a scheme:
```
docker:yourrepo/yourimage:tag use images from the Docker daemon
@ -114,6 +101,27 @@ 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)
```
Use SBOMs for even faster vulnerability scanning in Grype:
```
# Then scan for new vulnerabilities as frequently as needed
grype sbom:./sbom.json
# (You can also pipe the SBOM into Grype)
cat ./sbom.json | grype
```
Grype supports input of [Syft](https://github.com/anchore/syft), [SPDX](https://spdx.dev/), and [CycloneDX](https://cyclonedx.org/)
SBOM formats. If Syft has generated any of these file types, they should have the appropriate information to work properly with Grype.
It is also possible to use SBOMs generated by other tools with varying degrees of success. Two things that make Grype matching
more successful are inclusion of CPE and Linux distribution information. If an SBOM does not include any CPE information, it
is possible to generate these based on package information using the `--add-cpes-if-none` flag. To specify a distribution,
use the `--distro <distro>:<version>` flag. A full example is:
```
grype --add-cpes-if-none --distro alpine:3.10 sbom:some-apline-3.10.spdx.json
```
### Vulnerability Summary
#### Basic Grype Vulnerability Data Shape
@ -485,6 +493,11 @@ file: ""
# same as --exclude ; GRYPE_EXCLUDE env var
exclude:
# If using SBOM input, automatically generate CPEs when packages have none
add-cpes-if-none: false
# Explicitly specify a linux distribution to use as <distro>:<version> like alpine:3.10
distro:
db:
# check for database updates on execution
@ -562,4 +575,3 @@ log:
The following areas of potential development are currently being investigated:
- Support for allowlist, package mapping
- Accept alternative SBOM formats (CycloneDX, SPDX) as input

View file

@ -3,6 +3,7 @@ package cmd
import (
"fmt"
"os"
"strings"
"sync"
"github.com/pkg/profile"
@ -28,6 +29,7 @@ import (
"github.com/anchore/grype/internal/ui"
"github.com/anchore/grype/internal/version"
"github.com/anchore/stereoscope"
"github.com/anchore/syft/syft/linux"
"github.com/anchore/syft/syft/source"
)
@ -118,6 +120,16 @@ func setRootFlags(flags *pflag.FlagSet) {
"file to write the report output to (default is STDOUT)",
)
flags.StringP(
"distro", "", "",
"distro to match against in the format: <distro>:<version>",
)
flags.BoolP(
"add-cpes-if-none", "", false,
"generate CPEs for packages with no CPE data",
)
flags.StringP("template", "t", "", "specify the path to a Go template file ("+
"requires 'template' output to be selected)")
@ -150,6 +162,14 @@ func bindRootConfigOptions(flags *pflag.FlagSet) error {
return err
}
if err := viper.BindPFlag("distro", flags.Lookup("distro")); err != nil {
return err
}
if err := viper.BindPFlag("add-cpes-if-none", flags.Lookup("add-cpes-if-none")); err != nil {
return err
}
if err := viper.BindPFlag("output-template-file", flags.Lookup("template")); err != nil {
return err
}
@ -260,12 +280,7 @@ func startWorker(userInput string, failOnSeverity *vulnerability.Severity) <-cha
go func() {
defer wg.Done()
log.Debugf("gathering packages")
providerConfig := pkg.ProviderConfig{
RegistryOptions: appConfig.Registry.ToOptions(),
Exclusions: appConfig.Exclusions,
CatalogingOptions: appConfig.Search.ToConfig(),
}
packages, context, err = pkg.Provide(userInput, providerConfig)
packages, context, err = pkg.Provide(userInput, getProviderConfig())
if err != nil {
errs <- fmt.Errorf("failed to catalog: %w", err)
return
@ -282,6 +297,8 @@ func startWorker(userInput string, failOnSeverity *vulnerability.Severity) <-cha
appConfig.Ignore = append(appConfig.Ignore, ignoreNonFixedMatches...)
}
applyDistroHint(&context, appConfig)
allMatches := grype.FindVulnerabilitiesForPackage(provider, context.Distro, packages...)
remainingMatches, ignoredMatches := match.ApplyIgnoreRules(allMatches, appConfig.Ignore)
@ -304,6 +321,42 @@ func startWorker(userInput string, failOnSeverity *vulnerability.Severity) <-cha
return errs
}
func applyDistroHint(context *pkg.Context, appConfig *config.Application) {
if appConfig.Distro != "" {
log.Infof("using distro: %s", appConfig.Distro)
split := strings.Split(appConfig.Distro, ":")
d := split[0]
v := ""
if len(split) > 1 {
v = split[1]
}
context.Distro = &linux.Release{
PrettyName: d,
Name: d,
ID: d,
IDLike: []string{
d,
},
Version: v,
VersionID: v,
}
}
if context.Distro == nil {
log.Warnf("Unable to determine the OS distribution. This may result in missing vulnerabilities. You may specify a distro using: --distro <distro>:<version>")
}
}
func getProviderConfig() pkg.ProviderConfig {
return pkg.ProviderConfig{
RegistryOptions: appConfig.Registry.ToOptions(),
Exclusions: appConfig.Exclusions,
CatalogingOptions: appConfig.Search.ToConfig(),
GenerateMissingCPEs: appConfig.GenerateMissingCPEs,
}
}
func validateDBLoad(loadErr error, status *db.Status) error {
if loadErr != nil {
return fmt.Errorf("failed to load vulnerability db: %w", loadErr)

View file

@ -4,12 +4,14 @@ import (
"testing"
"github.com/google/uuid"
"github.com/stretchr/testify/assert"
"github.com/anchore/grype/grype/db"
grypeDB "github.com/anchore/grype/grype/db/v3"
"github.com/anchore/grype/grype/match"
"github.com/anchore/grype/grype/pkg"
"github.com/anchore/grype/grype/vulnerability"
"github.com/anchore/grype/internal/config"
syftPkg "github.com/anchore/syft/syft/pkg"
)
@ -112,3 +114,35 @@ func TestAboveAllowableSeverity(t *testing.T) {
})
}
}
func Test_applyDistroHint(t *testing.T) {
ctx := pkg.Context{}
cfg := config.Application{}
applyDistroHint(&ctx, &cfg)
assert.Nil(t, ctx.Distro)
// works when distro is nil
cfg.Distro = "alpine:3.10"
applyDistroHint(&ctx, &cfg)
assert.NotNil(t, ctx.Distro)
assert.Equal(t, "alpine", ctx.Distro.Name)
assert.Equal(t, "3.10", ctx.Distro.Version)
// does override an existing distro
cfg.Distro = "ubuntu:latest"
applyDistroHint(&ctx, &cfg)
assert.NotNil(t, ctx.Distro)
assert.Equal(t, "ubuntu", ctx.Distro.Name)
assert.Equal(t, "latest", ctx.Distro.Version)
// doesn't remove an existing distro when empty
cfg.Distro = ""
applyDistroHint(&ctx, &cfg)
assert.NotNil(t, ctx.Distro)
assert.Equal(t, "ubuntu", ctx.Distro.Name)
assert.Equal(t, "latest", ctx.Distro.Version)
}

View file

@ -7,6 +7,7 @@ import (
"github.com/anchore/grype/internal"
"github.com/anchore/grype/internal/log"
"github.com/anchore/syft/syft/pkg"
"github.com/anchore/syft/syft/pkg/cataloger/common/cpe"
"github.com/anchore/syft/syft/source"
)
@ -56,11 +57,24 @@ func New(p pkg.Package) Package {
}
}
func FromCatalog(catalog *pkg.Catalog) []Package {
func FromCatalog(catalog *pkg.Catalog, config ProviderConfig) []Package {
result := make([]Package, 0, catalog.PackageCount())
missingCPEs := false
for _, p := range catalog.Sorted() {
if len(p.CPEs) == 0 {
// For SPDX (or any format, really) we may have no CPEs
if config.GenerateMissingCPEs {
p.CPEs = cpe.Generate(p)
} else {
log.Debugf("no CPEs for package: %s", p)
missingCPEs = true
}
}
result = append(result, New(p))
}
if missingCPEs {
log.Warnf("some package(s) are missing CPEs. This may result in missing vulnerabilities. You may autogenerate these using: --add-cpes-if-none")
}
return result
}

View file

@ -288,10 +288,39 @@ func TestFromCatalog_DoesNotPanic(t *testing.T) {
catalog.Add(examplePackage)
assert.NotPanics(t, func() {
_ = FromCatalog(catalog)
_ = FromCatalog(catalog, ProviderConfig{})
})
}
func TestFromCatalog_GeneratesCPEs(t *testing.T) {
catalog := syftPkg.NewCatalog()
catalog.Add(syftPkg.Package{
Name: "first",
Version: "1",
CPEs: []syftPkg.CPE{
{},
},
})
catalog.Add(syftPkg.Package{
Name: "second",
Version: "2",
})
// doesn't generate cpes when no flag
pkgs := FromCatalog(catalog, ProviderConfig{})
assert.Len(t, pkgs[0].CPEs, 1)
assert.Len(t, pkgs[1].CPEs, 0)
// does generate cpes with the flag
pkgs = FromCatalog(catalog, ProviderConfig{
GenerateMissingCPEs: true,
})
assert.Len(t, pkgs[0].CPEs, 1)
assert.Len(t, pkgs[1].CPEs, 1)
}
func Test_getNameAndELVersion(t *testing.T) {
tests := []struct {
name string

View file

@ -13,7 +13,7 @@ var errDoesNotProvide = fmt.Errorf("cannot provide packages from the given sourc
// Provide a set of packages and context metadata describing where they were sourced from.
func Provide(userInput string, config ProviderConfig) ([]Package, Context, error) {
packages, ctx, err := syftSBOMProvider(userInput)
packages, ctx, err := syftSBOMProvider(userInput, config)
if !errors.Is(err, errDoesNotProvide) {
if len(config.Exclusions) > 0 {
packages, err = filterPackageExclusions(packages, config.Exclusions)

View file

@ -6,7 +6,8 @@ import (
)
type ProviderConfig struct {
RegistryOptions *image.RegistryOptions
Exclusions []string
CatalogingOptions cataloger.Config
RegistryOptions *image.RegistryOptions
Exclusions []string
CatalogingOptions cataloger.Config
GenerateMissingCPEs bool
}

View file

@ -21,7 +21,7 @@ func syftProvider(userInput string, config ProviderConfig) ([]Package, Context,
return nil, Context{}, err
}
return FromCatalog(catalog), Context{
return FromCatalog(catalog, config), Context{
Source: &src.Metadata,
Distro: theDistro,
}, nil

View file

@ -15,7 +15,7 @@ import (
"github.com/anchore/syft/syft/format"
)
func syftSBOMProvider(userInput string) ([]Package, Context, error) {
func syftSBOMProvider(userInput string, config ProviderConfig) ([]Package, Context, error) {
reader, err := getSBOMReader(userInput)
if err != nil {
return nil, Context{}, err
@ -29,7 +29,7 @@ func syftSBOMProvider(userInput string) ([]Package, Context, error) {
return nil, Context{}, errDoesNotProvide
}
return FromCatalog(sbom.Artifacts.PackageCatalog), Context{
return FromCatalog(sbom.Artifacts.PackageCatalog, config), Context{
Source: &sbom.Source,
Distro: sbom.Artifacts.LinuxDistribution,
}, nil

View file

@ -140,7 +140,7 @@ func TestParseSyftJSON(t *testing.T) {
for _, test := range tests {
t.Run(test.Fixture, func(t *testing.T) {
pkgs, context, err := syftSBOMProvider(test.Fixture)
pkgs, context, err := syftSBOMProvider(test.Fixture, ProviderConfig{})
if err != nil {
t.Fatalf("unable to parse: %+v", err)
}
@ -169,7 +169,7 @@ func TestParseSyftJSON(t *testing.T) {
}
func TestParseSyftJSON_BadCPEs(t *testing.T) {
pkgs, _, err := syftSBOMProvider("test-fixtures/syft-java-bad-cpes.json")
pkgs, _, err := syftSBOMProvider("test-fixtures/syft-java-bad-cpes.json", ProviderConfig{})
assert.NoError(t, err)
assert.Len(t, pkgs, 1)
}

View file

@ -311,7 +311,7 @@ func TestJsonDirsPresenter(t *testing.T) {
Version: "8.0",
},
}
pres := NewPresenter(matches, nil, pkg.FromCatalog(catalog), ctx, models.NewMetadataMock(), nil, nil)
pres := NewPresenter(matches, nil, pkg.FromCatalog(catalog, pkg.ProviderConfig{}), ctx, models.NewMetadataMock(), nil, nil)
// TODO: add a constructor for a match.Match when the data is better shaped

View file

@ -29,23 +29,25 @@ type parser interface {
}
type Application struct {
ConfigPath string `yaml:",omitempty" json:"configPath"` // the location where the application config was read from (either from -c or discovered while loading)
Output string `yaml:"output" json:"output" mapstructure:"output"` // -o, the Presenter hint string to use for report formatting
File string `yaml:"file" json:"file" mapstructure:"file"` // --file, the file to write report output to
OutputTemplateFile string `yaml:"output-template-file" json:"output-template-file" mapstructure:"output-template-file"` // -t, the template file to use for formatting the final report
Quiet bool `yaml:"quiet" json:"quiet" mapstructure:"quiet"` // -q, indicates to not show any status output to stderr (ETUI or logging UI)
CheckForAppUpdate bool `yaml:"check-for-app-update" json:"check-for-app-update" mapstructure:"check-for-app-update"` // whether to check for an application update on start up or not
OnlyFixed bool `yaml:"only-fixed" json:"only-fixed" mapstructure:"only-fixed"` // only fail if detected vulns have a fix
CliOptions CliOnlyOptions `yaml:"-" json:"-"`
Search search `yaml:"search" json:"search" mapstructure:"search"`
Ignore []match.IgnoreRule `yaml:"ignore" json:"ignore" mapstructure:"ignore"`
Exclusions []string `yaml:"exclude" json:"exclude" mapstructure:"exclude"`
DB database `yaml:"db" json:"db" mapstructure:"db"`
Dev development `yaml:"dev" json:"dev" mapstructure:"dev"`
FailOn string `yaml:"fail-on-severity" json:"fail-on-severity" mapstructure:"fail-on-severity"`
FailOnSeverity *vulnerability.Severity `yaml:"-" json:"-"`
Registry registry `yaml:"registry" json:"registry" mapstructure:"registry"`
Log logging `yaml:"log" json:"log" mapstructure:"log"`
ConfigPath string `yaml:",omitempty" json:"configPath"` // the location where the application config was read from (either from -c or discovered while loading)
Output string `yaml:"output" json:"output" mapstructure:"output"` // -o, the Presenter hint string to use for report formatting
File string `yaml:"file" json:"file" mapstructure:"file"` // --file, the file to write report output to
Distro string `yaml:"distro" json:"distro" mapstructure:"distro"` // --distro, specify a distro to explicitly use
GenerateMissingCPEs bool `yaml:"add-cpes-if-none" json:"add-cpes-if-none" mapstructure:"add-cpes-if-none"` // --add-cpes-if-none, automatically generate CPEs if they are not present in import (e.g. from a 3rd party SPDX document)
OutputTemplateFile string `yaml:"output-template-file" json:"output-template-file" mapstructure:"output-template-file"` // -t, the template file to use for formatting the final report
Quiet bool `yaml:"quiet" json:"quiet" mapstructure:"quiet"` // -q, indicates to not show any status output to stderr (ETUI or logging UI)
CheckForAppUpdate bool `yaml:"check-for-app-update" json:"check-for-app-update" mapstructure:"check-for-app-update"` // whether to check for an application update on start up or not
OnlyFixed bool `yaml:"only-fixed" json:"only-fixed" mapstructure:"only-fixed"` // only fail if detected vulns have a fix
CliOptions CliOnlyOptions `yaml:"-" json:"-"`
Search search `yaml:"search" json:"search" mapstructure:"search"`
Ignore []match.IgnoreRule `yaml:"ignore" json:"ignore" mapstructure:"ignore"`
Exclusions []string `yaml:"exclude" json:"exclude" mapstructure:"exclude"`
DB database `yaml:"db" json:"db" mapstructure:"db"`
Dev development `yaml:"dev" json:"dev" mapstructure:"dev"`
FailOn string `yaml:"fail-on-severity" json:"fail-on-severity" mapstructure:"fail-on-severity"`
FailOnSeverity *vulnerability.Severity `yaml:"-" json:"-"`
Registry registry `yaml:"registry" json:"registry" mapstructure:"registry"`
Log logging `yaml:"log" json:"log" mapstructure:"log"`
}
func newApplicationConfig(v *viper.Viper, cliOpts CliOnlyOptions) *Application {
@ -150,7 +152,7 @@ func (cfg *Application) parseLogLevelOption() error {
case v >= 2:
cfg.Log.LevelOpt = logrus.DebugLevel
default:
cfg.Log.LevelOpt = logrus.ErrorLevel
cfg.Log.LevelOpt = logrus.WarnLevel
}
}

View file

@ -376,7 +376,7 @@ func TestMatchByImage(t *testing.T) {
actualResults := grype.FindVulnerabilitiesForPackage(
db.NewVulnerabilityProvider(theStore),
theDistro,
pkg.FromCatalog(theCatalog)...,
pkg.FromCatalog(theCatalog, pkg.ProviderConfig{})...,
)
// build expected matches from what's discovered from the catalog