add initial support for embedded CycloneDX VEX documents (#678)

This commit is contained in:
Sambhav Kothari 2022-04-28 17:49:12 +01:00 committed by GitHub
parent 523f5ce9c0
commit 9f70cdbf24
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
24 changed files with 7807 additions and 2 deletions

View file

@ -74,7 +74,7 @@ all: clean static-analysis test ## Run all checks (linting, license check, unit,
@printf '$(SUCCESS)All checks pass!$(RESET)\n'
.PHONY: test
test: unit validate-cyclonedx-schema integration cli ## Run all tests (unit, integration, linux acceptance, and CLI tests)
test: unit validate-cyclonedx-schema validate-cyclonedx-vex-schema integration cli ## Run all tests (unit, integration, linux acceptance, and CLI tests)
help:
@grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) | sort | awk 'BEGIN {FS = ":.*?## "}; {printf "$(BOLD)$(CYAN)%-25s$(RESET)%s\n", $$1, $$2}'
@ -96,6 +96,7 @@ bootstrap-tools: $(TEMPDIR)
curl -sSfL https://raw.githubusercontent.com/anchore/chronicle/main/install.sh | sh -s -- -b $(TEMPDIR)/ v0.3.0
# the only difference between goimports and gosimports is that gosimports removes extra whitespace between import blocks (see https://github.com/golang/go/issues/20818)
GOBIN="$(shell realpath $(TEMPDIR))" go install github.com/rinchsan/gosimports/cmd/gosimports@v0.1.5
GOBIN="$(shell realpath $(TEMPDIR))" go install github.com/neilpa/yajsv@v1.4.0
.github/scripts/goreleaser-install.sh -b $(TEMPDIR)/ v1.4.1
.PHONY: bootstrap-go
@ -143,6 +144,10 @@ check-go-mod-tidy:
validate-cyclonedx-schema:
cd schema/cyclonedx && make
.PHONY: validate-cyclonedx-vex-schema
validate-cyclonedx-vex-schema:
cd schema/cyclonedxvex && make
.PHONY: validate-grype-db-schema
validate-grype-db-schema:
# ensure the codebase is only referencing a single grype-db schema version, multiple is not allowed

2
go.mod
View file

@ -3,6 +3,7 @@ module github.com/anchore/grype
go 1.18
require (
github.com/CycloneDX/cyclonedx-go v0.5.0
github.com/Masterminds/sprig/v3 v3.2.2
github.com/acarl005/stripansi v0.0.0-20180116102854-5a71ef0e047d
github.com/adrg/xdg v0.2.1
@ -75,7 +76,6 @@ require (
github.com/Azure/go-autorest/autorest/validation v0.3.1 // indirect
github.com/Azure/go-autorest/logger v0.2.1 // indirect
github.com/Azure/go-autorest/tracing v0.6.0 // indirect
github.com/CycloneDX/cyclonedx-go v0.5.0 // indirect
github.com/Masterminds/goutils v1.1.1 // indirect
github.com/Masterminds/semver/v3 v3.1.1 // indirect
github.com/Microsoft/go-winio v0.5.1 // indirect

View file

@ -0,0 +1,39 @@
package cyclonedxvex
import (
"time"
"github.com/CycloneDX/cyclonedx-go"
"github.com/anchore/syft/syft/source"
)
// NewBomMetadata returns a new BomDescriptor tailored for the current time and "syft" tool details.
func NewBomMetadata(name, version string, srcMetadata *source.Metadata) *cyclonedx.Metadata {
metadata := cyclonedx.Metadata{
Timestamp: time.Now().Format(time.RFC3339),
Tools: &[]cyclonedx.Tool{
{
Vendor: "anchore",
Name: name,
Version: version,
},
},
}
if srcMetadata != nil {
switch srcMetadata.Scheme {
case source.ImageScheme:
metadata.Component = &cyclonedx.Component{
Type: "container",
Name: srcMetadata.ImageMetadata.UserInput,
Version: srcMetadata.ImageMetadata.ManifestDigest,
}
case source.DirectoryScheme:
metadata.Component = &cyclonedx.Component{
Type: "file",
Name: srcMetadata.Path,
}
}
}
return &metadata
}

View file

@ -0,0 +1,88 @@
package cyclonedxvex
import (
"github.com/CycloneDX/cyclonedx-go"
"github.com/google/uuid"
"github.com/anchore/grype/grype/match"
"github.com/anchore/grype/grype/pkg"
"github.com/anchore/grype/grype/vulnerability"
"github.com/anchore/grype/internal"
"github.com/anchore/grype/internal/version"
"github.com/anchore/packageurl-go"
"github.com/anchore/syft/syft/source"
)
// NewDocument returns a CycloneDX Document object populated with the SBOM and vulnerability findings.
func NewDocument(packages []pkg.Package, matches match.Matches, srcMetadata *source.Metadata, provider vulnerability.MetadataProvider) (*cyclonedx.BOM, error) {
versionInfo := version.FromBuild()
doc := cyclonedx.NewBOM()
doc.SerialNumber = uuid.New().URN()
if srcMetadata != nil {
doc.Metadata = NewBomMetadata(internal.ApplicationName, versionInfo.Version, srcMetadata)
}
// attach matches
components := []cyclonedx.Component{}
vulnerabilities := []cyclonedx.Vulnerability{}
for _, p := range packages {
component := getComponent(p)
pkgMatches := matches.GetByPkgID(p.ID)
if len(pkgMatches) > 0 {
for _, m := range pkgMatches {
v, err := NewVulnerability(m, provider)
if err != nil {
return &cyclonedx.BOM{}, err
}
v.Affects = &[]cyclonedx.Affects{
{
Ref: component.BOMRef,
},
}
vulnerabilities = append(vulnerabilities, v)
}
}
// add a *copy* of the Component to the bom document
components = append(components, component)
}
doc.Components = &components
doc.Vulnerabilities = &vulnerabilities
return doc, nil
}
func getComponent(p pkg.Package) cyclonedx.Component {
bomRef := string(p.ID)
// try and parse the PURL if possible and append syft id to it, to make
// the purl unique in the BOM.
// TODO: In the future we may want to dedupe by PURL and combine components with
// the same PURL while preserving their unique metadata.
if parsedPURL, err := packageurl.FromString(p.PURL); err == nil {
parsedPURL.Qualifiers = append(parsedPURL.Qualifiers, packageurl.Qualifier{Key: "package-id", Value: string(p.ID)})
bomRef = parsedPURL.ToString()
}
// make a new Component (by value)
component := cyclonedx.Component{
Type: "library", // TODO: this is not accurate, syft does the same thing
Name: p.Name,
Version: p.Version,
PackageURL: p.PURL,
BOMRef: bomRef,
}
var licenses cyclonedx.Licenses
for _, licenseName := range p.Licenses {
licenses = append(licenses, cyclonedx.LicenseChoice{
License: &cyclonedx.License{
Name: licenseName,
},
})
}
if len(licenses) > 0 {
// adding licenses to the Component
component.Licenses = &licenses
}
return component
}

View file

@ -0,0 +1,52 @@
package cyclonedxvex
import (
"io"
"github.com/CycloneDX/cyclonedx-go"
"github.com/anchore/grype/grype/match"
"github.com/anchore/grype/grype/pkg"
"github.com/anchore/grype/grype/vulnerability"
"github.com/anchore/syft/syft/source"
)
// Presenter writes a CycloneDX report from the given Matches and Scope contents
type Presenter struct {
results match.Matches
packages []pkg.Package
srcMetadata *source.Metadata
metadataProvider vulnerability.MetadataProvider
embedded bool
format cyclonedx.BOMFileFormat
}
// NewPresenter is a *Presenter constructor
func NewPresenter(results match.Matches, packages []pkg.Package, srcMetadata *source.Metadata, metadataProvider vulnerability.MetadataProvider, embedded bool, format cyclonedx.BOMFileFormat) *Presenter {
return &Presenter{
results: results,
packages: packages,
metadataProvider: metadataProvider,
srcMetadata: srcMetadata,
embedded: embedded,
format: format,
}
}
// Present creates a CycloneDX-based reporting
func (pres *Presenter) Present(output io.Writer) error {
bom, err := NewDocument(pres.packages, pres.results, pres.srcMetadata, pres.metadataProvider)
if err != nil {
return err
}
encoder := cyclonedx.NewBOMEncoder(output, pres.format)
encoder.SetPretty(true)
err = encoder.Encode(bom)
if err != nil {
return err
}
return err
}

View file

@ -0,0 +1,186 @@
package cyclonedxvex
import (
"bytes"
"flag"
"regexp"
"testing"
"github.com/CycloneDX/cyclonedx-go"
"github.com/sergi/go-diff/diffmatchpatch"
"github.com/anchore/go-testutils"
"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"
"github.com/anchore/stereoscope/pkg/imagetest"
syftPkg "github.com/anchore/syft/syft/pkg"
"github.com/anchore/syft/syft/source"
)
var update = flag.Bool("update", false, "update the *.golden files for json presenters")
func createResults() (match.Matches, []pkg.Package) {
pkg1 := pkg.Package{
ID: "package-1-id",
Name: "package-1",
Version: "1.0.1",
Type: syftPkg.DebPkg,
}
pkg2 := pkg.Package{
ID: "package-2-id",
Name: "package-2",
Version: "2.0.1",
Type: syftPkg.DebPkg,
Licenses: []string{
"MIT",
"Apache-v2",
},
}
var match1 = match.Match{
Vulnerability: vulnerability.Vulnerability{
ID: "CVE-1999-0001",
Namespace: "source-1",
},
Package: pkg1,
Details: []match.Detail{
{
Type: match.ExactDirectMatch,
Matcher: match.DpkgMatcher,
},
},
}
var match2 = match.Match{
Vulnerability: vulnerability.Vulnerability{
ID: "CVE-1999-0002",
Namespace: "source-2",
},
Package: pkg2,
Details: []match.Detail{
{
Type: match.ExactIndirectMatch,
Matcher: match.DpkgMatcher,
SearchedBy: map[string]interface{}{
"some": "key",
},
},
},
}
matches := match.NewMatches()
matches.Add(match1, match2)
return matches, []pkg.Package{pkg1, pkg2}
}
func TestCycloneDxPresenterImage(t *testing.T) {
for _, tcase := range []struct {
name string
format cyclonedx.BOMFileFormat
}{
{name: "json", format: cyclonedx.BOMFileFormatJSON},
{name: "xml", format: cyclonedx.BOMFileFormatXML},
} {
t.Run(tcase.name, func(t *testing.T) {
var buffer bytes.Buffer
matches, packages := createResults()
img := imagetest.GetFixtureImage(t, "docker-archive", "image-simple")
s, _ := source.NewFromImage(img, "user-input")
// This accounts for the non-deterministic digest value that we end up with when
// we build a container image dynamically during testing. Ultimately, we should
// use a golden image as a test fixture in place of building this image during
// testing. At that time, this line will no longer be necessary.
//
// This value is sourced from the "version" node in "./test-fixtures/snapshot/TestCycloneDxImgsPresenter.golden"
s.Metadata.ImageMetadata.ManifestDigest = "sha256:2731251dc34951c0e50fcc643b4c5f74922dad1a5d98f302b504cf46cd5d9368"
pres := NewPresenter(matches, packages, &s.Metadata, models.NewMetadataMock(), true, tcase.format)
// run presenter
err := pres.Present(&buffer)
if err != nil {
t.Fatal(err)
}
actual := buffer.Bytes()
if *update {
testutils.UpdateGoldenFileContents(t, actual)
}
var expected = testutils.GetGoldenFileContents(t)
// remove dynamic values, which are tested independently
actual = redact(actual)
expected = redact(expected)
if !bytes.Equal(expected, actual) {
dmp := diffmatchpatch.New()
diffs := dmp.DiffMain(string(expected), string(actual), true)
t.Errorf("mismatched output:\n%s", dmp.DiffPrettyText(diffs))
}
})
}
}
func TestCycloneDxPresenterDir(t *testing.T) {
for _, tcase := range []struct {
name string
format cyclonedx.BOMFileFormat
}{
{name: "json", format: cyclonedx.BOMFileFormatJSON},
{name: "xml", format: cyclonedx.BOMFileFormatXML},
} {
t.Run(tcase.name, func(t *testing.T) {
var buffer bytes.Buffer
matches, packages := createResults()
s, err := source.NewFromDirectory("/some/path")
if err != nil {
t.Fatal(err)
}
pres := NewPresenter(matches, packages, &s.Metadata, models.NewMetadataMock(), true, tcase.format)
// run presenter
err = pres.Present(&buffer)
if err != nil {
t.Fatal(err)
}
actual := buffer.Bytes()
if *update {
testutils.UpdateGoldenFileContents(t, actual)
}
var expected = testutils.GetGoldenFileContents(t)
// remove dynamic values, which are tested independently
actual = redact(actual)
expected = redact(expected)
if !bytes.Equal(expected, actual) {
dmp := diffmatchpatch.New()
diffs := dmp.DiffMain(string(expected), string(actual), true)
t.Errorf("mismatched output:\n%s", dmp.DiffPrettyText(diffs))
}
})
}
}
func redact(s []byte) []byte {
serialPattern := regexp.MustCompile(`urn:uuid:[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}`)
rfc3339Pattern := regexp.MustCompile(`([0-9]+)-(0[1-9]|1[012])-(0[1-9]|[12][0-9]|3[01])[Tt]([01][0-9]|2[0-3]):([0-5][0-9]):([0-5][0-9]|60)(\.[0-9]+)?(([Zz])|([\+|\-]([01][0-9]|2[0-3]):[0-5][0-9]))`)
for _, pattern := range []*regexp.Regexp{serialPattern, rfc3339Pattern} {
s = pattern.ReplaceAll(s, []byte("redacted"))
}
return s
}

View file

@ -0,0 +1,6 @@
# Note: changes to this file will result in updating several test values. Consider making a new image fixture instead of editing this one.
FROM scratch
ADD file-1.txt /somefile-1.txt
ADD file-2.txt /somefile-2.txt
# note: adding a directory will behave differently on docker engine v18 vs v19
ADD target /

View file

@ -0,0 +1 @@
this file has contents

View file

@ -0,0 +1 @@
file-2 contents!

View file

@ -0,0 +1,2 @@
another file!
with lines...

View file

@ -0,0 +1,104 @@
{
"bomFormat": "CycloneDX",
"specVersion": "1.4",
"serialNumber": "urn:uuid:ef5bc559-c07e-46bd-be44-dc400d6ff87b",
"version": 1,
"metadata": {
"timestamp": "2022-03-31T22:50:49+01:00",
"tools": [
{
"vendor": "anchore",
"name": "grype",
"version": "[not provided]"
}
],
"component": {
"type": "file",
"name": "/some/path"
}
},
"components": [
{
"bom-ref": "package-1-id",
"type": "library",
"name": "package-1",
"version": "1.0.1"
},
{
"bom-ref": "package-2-id",
"type": "library",
"name": "package-2",
"version": "2.0.1",
"licenses": [
{
"license": {
"name": "MIT"
}
},
{
"license": {
"name": "Apache-v2"
}
}
]
}
],
"vulnerabilities": [
{
"id": "CVE-1999-0001",
"source": {
"name": "source-1",
"url": "http://cve.mitre.org/cgi-bin/cvename.cgi?name=CVE-1999-0001"
},
"ratings": [
{
"score": 0,
"severity": "low"
},
{
"score": 4,
"method": "CVSSv3",
"vector": "another vector"
}
],
"description": "1999-01 description",
"advisories": [],
"analysis": {
"state": "in_triage"
},
"affects": [
{
"ref": "package-1-id"
}
]
},
{
"id": "CVE-1999-0002",
"source": {
"name": "source-2",
"url": "http://cve.mitre.org/cgi-bin/cvename.cgi?name=CVE-1999-0002"
},
"ratings": [
{
"score": 0,
"severity": "critical"
},
{
"score": 1,
"method": "CVSSv2",
"vector": "vector"
}
],
"description": "1999-02 description",
"advisories": [],
"analysis": {
"state": "in_triage"
},
"affects": [
{
"ref": "package-2-id"
}
]
}
]
}

View file

@ -0,0 +1,92 @@
<?xml version="1.0" encoding="UTF-8"?>
<bom xmlns="http://cyclonedx.org/schema/bom/1.4" serialNumber="urn:uuid:88a468fe-e518-4932-8af7-984a41026f6a" version="1">
<metadata>
<timestamp>2022-03-31T22:50:49+01:00</timestamp>
<tools>
<tool>
<vendor>anchore</vendor>
<name>grype</name>
<version>[not provided]</version>
</tool>
</tools>
<component type="file">
<name>/some/path</name>
</component>
</metadata>
<components>
<component bom-ref="package-1-id" type="library">
<name>package-1</name>
<version>1.0.1</version>
</component>
<component bom-ref="package-2-id" type="library">
<name>package-2</name>
<version>2.0.1</version>
<licenses>
<license>
<name>MIT</name>
</license>
<license>
<name>Apache-v2</name>
</license>
</licenses>
</component>
</components>
<vulnerabilities>
<vulnerability>
<id>CVE-1999-0001</id>
<source>
<name>source-1</name>
<url>http://cve.mitre.org/cgi-bin/cvename.cgi?name=CVE-1999-0001</url>
</source>
<ratings>
<rating>
<score>0</score>
<severity>low</severity>
</rating>
<rating>
<score>4</score>
<method>CVSSv3</method>
<vector>another vector</vector>
</rating>
</ratings>
<description>1999-01 description</description>
<advisories></advisories>
<analysis>
<state>in_triage</state>
</analysis>
<affects>
<target>
<ref>package-1-id</ref>
</target>
</affects>
</vulnerability>
<vulnerability>
<id>CVE-1999-0002</id>
<source>
<name>source-2</name>
<url>http://cve.mitre.org/cgi-bin/cvename.cgi?name=CVE-1999-0002</url>
</source>
<ratings>
<rating>
<score>0</score>
<severity>critical</severity>
</rating>
<rating>
<score>1</score>
<method>CVSSv2</method>
<vector>vector</vector>
</rating>
</ratings>
<description>1999-02 description</description>
<advisories></advisories>
<analysis>
<state>in_triage</state>
</analysis>
<affects>
<target>
<ref>package-2-id</ref>
</target>
</affects>
</vulnerability>
</vulnerabilities>
</bom>

View file

@ -0,0 +1,105 @@
{
"bomFormat": "CycloneDX",
"specVersion": "1.4",
"serialNumber": "urn:uuid:7266e5f9-8e64-464a-b998-fed127984f96",
"version": 1,
"metadata": {
"timestamp": "2022-03-31T22:50:49+01:00",
"tools": [
{
"vendor": "anchore",
"name": "grype",
"version": "[not provided]"
}
],
"component": {
"type": "container",
"name": "user-input",
"version": "sha256:2731251dc34951c0e50fcc643b4c5f74922dad1a5d98f302b504cf46cd5d9368"
}
},
"components": [
{
"bom-ref": "package-1-id",
"type": "library",
"name": "package-1",
"version": "1.0.1"
},
{
"bom-ref": "package-2-id",
"type": "library",
"name": "package-2",
"version": "2.0.1",
"licenses": [
{
"license": {
"name": "MIT"
}
},
{
"license": {
"name": "Apache-v2"
}
}
]
}
],
"vulnerabilities": [
{
"id": "CVE-1999-0001",
"source": {
"name": "source-1",
"url": "http://cve.mitre.org/cgi-bin/cvename.cgi?name=CVE-1999-0001"
},
"ratings": [
{
"score": 0,
"severity": "low"
},
{
"score": 4,
"method": "CVSSv3",
"vector": "another vector"
}
],
"description": "1999-01 description",
"advisories": [],
"analysis": {
"state": "in_triage"
},
"affects": [
{
"ref": "package-1-id"
}
]
},
{
"id": "CVE-1999-0002",
"source": {
"name": "source-2",
"url": "http://cve.mitre.org/cgi-bin/cvename.cgi?name=CVE-1999-0002"
},
"ratings": [
{
"score": 0,
"severity": "critical"
},
{
"score": 1,
"method": "CVSSv2",
"vector": "vector"
}
],
"description": "1999-02 description",
"advisories": [],
"analysis": {
"state": "in_triage"
},
"affects": [
{
"ref": "package-2-id"
}
]
}
]
}

View file

@ -0,0 +1,93 @@
<?xml version="1.0" encoding="UTF-8"?>
<bom xmlns="http://cyclonedx.org/schema/bom/1.4" serialNumber="urn:uuid:0f37ab1e-55b1-46ec-bb8b-5e1c5f6b7904" version="1">
<metadata>
<timestamp>2022-03-31T22:50:49+01:00</timestamp>
<tools>
<tool>
<vendor>anchore</vendor>
<name>grype</name>
<version>[not provided]</version>
</tool>
</tools>
<component type="container">
<name>user-input</name>
<version>sha256:2731251dc34951c0e50fcc643b4c5f74922dad1a5d98f302b504cf46cd5d9368</version>
</component>
</metadata>
<components>
<component bom-ref="package-1-id" type="library">
<name>package-1</name>
<version>1.0.1</version>
</component>
<component bom-ref="package-2-id" type="library">
<name>package-2</name>
<version>2.0.1</version>
<licenses>
<license>
<name>MIT</name>
</license>
<license>
<name>Apache-v2</name>
</license>
</licenses>
</component>
</components>
<vulnerabilities>
<vulnerability>
<id>CVE-1999-0001</id>
<source>
<name>source-1</name>
<url>http://cve.mitre.org/cgi-bin/cvename.cgi?name=CVE-1999-0001</url>
</source>
<ratings>
<rating>
<score>0</score>
<severity>low</severity>
</rating>
<rating>
<score>4</score>
<method>CVSSv3</method>
<vector>another vector</vector>
</rating>
</ratings>
<description>1999-01 description</description>
<advisories></advisories>
<analysis>
<state>in_triage</state>
</analysis>
<affects>
<target>
<ref>package-1-id</ref>
</target>
</affects>
</vulnerability>
<vulnerability>
<id>CVE-1999-0002</id>
<source>
<name>source-2</name>
<url>http://cve.mitre.org/cgi-bin/cvename.cgi?name=CVE-1999-0002</url>
</source>
<ratings>
<rating>
<score>0</score>
<severity>critical</severity>
</rating>
<rating>
<score>1</score>
<method>CVSSv2</method>
<vector>vector</vector>
</rating>
</ratings>
<description>1999-02 description</description>
<advisories></advisories>
<analysis>
<state>in_triage</state>
</analysis>
<affects>
<target>
<ref>package-2-id</ref>
</target>
</affects>
</vulnerability>
</vulnerabilities>
</bom>

View file

@ -0,0 +1,108 @@
package cyclonedxvex
import (
"fmt"
"strconv"
"strings"
"github.com/CycloneDX/cyclonedx-go"
"github.com/anchore/grype/grype/match"
"github.com/anchore/grype/grype/vulnerability"
"github.com/anchore/grype/internal/log"
)
// cvssVersionToMethod accepts a CVSS version as string (e.g. "3.1") and converts it to a
// CycloneDx rating Method, for example "CVSSv3"
func cvssVersionToMethod(version string) (cyclonedx.ScoringMethod, error) {
value, err := strconv.ParseFloat(version, 64)
if err != nil {
return "", err
}
switch value {
case 2:
return cyclonedx.ScoringMethodCVSSv2, nil
case 3:
return cyclonedx.ScoringMethodCVSSv3, nil
case 3.1:
return cyclonedx.ScoringMethodCVSSv31, nil
default:
return "", fmt.Errorf("unable to parse %s into a CVSS version", version)
}
}
// NewVulnerability creates a Vulnerability document from a match and the metadata provider
func NewVulnerability(m match.Match, p vulnerability.MetadataProvider) (cyclonedx.Vulnerability, error) {
metadata, err := p.GetMetadata(m.Vulnerability.ID, m.Vulnerability.Namespace)
if err != nil {
return cyclonedx.Vulnerability{}, fmt.Errorf("unable to fetch vuln=%q metadata: %+v", m.Vulnerability.ID, err)
}
// The schema does not allow "Negligible", only allowing the following:
// 'None', 'Low', 'Medium', 'High', 'Critical', 'Unknown'
severityMap := map[string]cyclonedx.Severity{
"unknown": cyclonedx.SeverityUnknown,
"none": cyclonedx.SeverityNone,
"info": cyclonedx.SeverityInfo,
"negligible": cyclonedx.SeverityLow,
"low": cyclonedx.SeverityLow,
"medium": cyclonedx.SeverityMedium,
"high": cyclonedx.SeverityHigh,
"critical": cyclonedx.SeverityCritical,
}
severity, ok := severityMap[strings.ToLower(metadata.Severity)]
if !ok {
severity = cyclonedx.SeverityUnknown
}
var ratings = []cyclonedx.VulnerabilityRating{
{
Severity: severity,
},
}
for _, cvss := range metadata.Cvss {
method, err := cvssVersionToMethod(cvss.Version)
if err != nil {
log.Errorf("unable to parse CVSS version: %v", err)
// do not halt execution if one CVSS fails to provide an accurate Version
continue
}
rating := cyclonedx.VulnerabilityRating{
Method: method,
Vector: cvss.Vector,
Score: cvss.Metrics.BaseScore,
}
ratings = append(ratings, rating)
}
advisories := []cyclonedx.Advisory{}
for _, url := range metadata.URLs {
advisories = append(advisories, cyclonedx.Advisory{
URL: url,
})
}
v := cyclonedx.Vulnerability{
ID: m.Vulnerability.ID,
Source: &cyclonedx.Source{
Name: m.Vulnerability.Namespace,
URL: makeVulnerabilityURL(m.Vulnerability.ID),
},
Ratings: &ratings,
Description: metadata.Description,
Advisories: &advisories,
Analysis: &cyclonedx.VulnerabilityAnalysis{
State: cyclonedx.IASInTriage,
},
}
return v, nil
}
func makeVulnerabilityURL(id string) string {
if strings.HasPrefix(id, "CVE-") {
return fmt.Sprintf("http://cve.mitre.org/cgi-bin/cvename.cgi?name=%s", id)
}
if strings.HasPrefix(id, "GHSA") {
return fmt.Sprintf("https://github.com/advisories/%s", id)
}
return id
}

View file

@ -0,0 +1,146 @@
package cyclonedxvex
import (
"strings"
"testing"
"github.com/CycloneDX/cyclonedx-go"
"github.com/stretchr/testify/assert"
"github.com/anchore/grype/grype/match"
"github.com/anchore/grype/grype/pkg"
"github.com/anchore/grype/grype/vulnerability"
)
func TestCvssVersionToMethod(t *testing.T) {
testCases := []struct {
desc string
input string
expected cyclonedx.ScoringMethod
errors bool
}{
{
desc: "invalid (not float)",
input: "",
expected: "",
errors: true,
},
{
desc: "CVSS v2",
input: "2.0",
expected: cyclonedx.ScoringMethodCVSSv2,
errors: false,
},
{
desc: "CVSS v3",
input: "3.1",
expected: cyclonedx.ScoringMethodCVSSv31,
errors: false,
},
{
desc: "CVSS v3",
input: "3.0",
expected: cyclonedx.ScoringMethodCVSSv3,
errors: false,
},
{
desc: "invalid (no match)",
input: "15.4",
expected: "",
errors: true,
},
}
for _, tc := range testCases {
t.Run(tc.desc, func(t *testing.T) {
actual, err := cvssVersionToMethod(tc.input)
if !tc.errors {
assert.NoError(t, err)
} else {
assert.Error(t, err)
}
assert.Equal(t, tc.expected, actual)
})
}
}
type metadataProvider struct {
severity string
cvss []vulnerability.Cvss
}
func (m metadataProvider) GetMetadata(id, namespace string) (*vulnerability.Metadata, error) {
return &vulnerability.Metadata{
ID: id,
DataSource: "",
Namespace: namespace,
Severity: m.severity,
URLs: nil,
Description: "",
Cvss: m.cvss,
}, nil
}
func TestNewVulnerability_AlwaysIncludesSeverity(t *testing.T) {
tests := []struct {
name string
match match.Match
metadataProvider *metadataProvider
}{
{
name: "populates severity with missing CVSS records",
match: match.Match{
Vulnerability: vulnerability.Vulnerability{},
Package: pkg.Package{},
Details: nil,
},
metadataProvider: &metadataProvider{
severity: "High",
},
},
{
name: "populates severity with all CVSS records",
match: match.Match{
Vulnerability: vulnerability.Vulnerability{},
Package: pkg.Package{},
Details: nil,
},
metadataProvider: &metadataProvider{
severity: "High",
cvss: []vulnerability.Cvss{
{
Version: "2.0",
Metrics: vulnerability.CvssMetrics{
BaseScore: 1.1,
},
},
{
Version: "3.0",
Metrics: vulnerability.CvssMetrics{
BaseScore: 2.1,
},
},
{
Version: "3.1",
Metrics: vulnerability.CvssMetrics{
BaseScore: 3.1,
},
},
},
},
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
actual, err := NewVulnerability(test.match, test.metadataProvider)
assert.NoError(t, err)
if len(*actual.Ratings) == 0 {
t.Fatalf("expected a rating but found none")
}
assert.Equal(t, string((*actual.Ratings)[0].Severity), strings.ToLower(test.metadataProvider.severity))
for _, r := range (*actual.Ratings)[1:] {
assert.Equal(t, string(r.Severity), "")
}
})
}
}

View file

@ -11,6 +11,8 @@ const (
cycloneDXFormat format = "cyclonedx"
sarifFormat format = "sarif"
templateFormat format = "template"
embeddedVEXJSON format = "embedded-cyclonedx-vex-json"
embeddedVEXXML format = "embedded-cyclonedx-vex-xml"
)
// format is a dedicated type to represent a specific kind of presenter output format.
@ -35,6 +37,10 @@ func parse(userInput string) format {
return sarifFormat
case strings.ToLower(templateFormat.String()):
return templateFormat
case strings.ToLower(embeddedVEXJSON.String()):
return embeddedVEXJSON
case strings.ToLower(embeddedVEXXML.String()):
return embeddedVEXXML
default:
return unknownFormat
}

View file

@ -3,9 +3,12 @@ package presenter
import (
"io"
cdx "github.com/CycloneDX/cyclonedx-go"
"github.com/anchore/grype/grype/match"
"github.com/anchore/grype/grype/pkg"
"github.com/anchore/grype/grype/presenter/cyclonedx"
"github.com/anchore/grype/grype/presenter/cyclonedxvex"
"github.com/anchore/grype/grype/presenter/json"
"github.com/anchore/grype/grype/presenter/sarif"
"github.com/anchore/grype/grype/presenter/table"
@ -27,6 +30,10 @@ func GetPresenter(presenterConfig Config, matches match.Matches, ignoredMatches
return table.NewPresenter(matches, packages, metadataProvider)
case cycloneDXFormat:
return cyclonedx.NewPresenter(matches, packages, context.Source, metadataProvider)
case embeddedVEXJSON:
return cyclonedxvex.NewPresenter(matches, packages, context.Source, metadataProvider, true, cdx.BOMFileFormatJSON)
case embeddedVEXXML:
return cyclonedxvex.NewPresenter(matches, packages, context.Source, metadataProvider, true, cdx.BOMFileFormatXML)
case sarifFormat:
return sarif.NewPresenter(matches, packages, context.Source, metadataProvider)
case templateFormat:

2
schema/cyclonedxvex/.gitignore vendored Normal file
View file

@ -0,0 +1,2 @@
bom.xml
bom.json

View file

@ -0,0 +1,14 @@
.DEFAULT_GOAL := validate-schema
.PHONY: validate-schema
validate-schema: validate-schema-xml validate-schema-json
.PHONY: validate-schema-xml
validate-schema-xml:
go run ../../main.go -c ../../test/grype-test-config.yaml ubuntu:latest -vv -o embedded-cyclondex-vex-xml > bom.xml
xmllint --noout --schema ./cyclonedx.xsd bom.xml
.PHONY: validate-schema-json
validate-schema-json:
go run ../../main.go -c ../../test/grype-test-config.yaml ubuntu:latest -vv -o embedded-cyclondex-vex-json > bom.json
../../.tmp/yajsv -s cyclonedx.json bom.json

View file

@ -0,0 +1,5 @@
# CycloneDX Schemas
`grype` generates a CycloneDX VEX output. This validation is similar to what is done in `syft`, validating output against CycloneDX schemas.
Validation is done with `xmllint`, which requires a copy of all schemas because it can't work with HTTP references. The schemas are modified to reference local copies of dependent schemas.

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

2639
schema/cyclonedxvex/spdx.xsd Normal file

File diff suppressed because it is too large Load diff