Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(cyclonedx): Add initial support for loading external VEX files from SBOM references #8254

Open
wants to merge 2 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 27 additions & 6 deletions pkg/sbom/core/bom.go
Original file line number Diff line number Diff line change
Expand Up @@ -48,10 +48,13 @@ const (
RelationshipDescribes RelationshipType = "describes"
RelationshipContains RelationshipType = "contains"
RelationshipDependsOn RelationshipType = "depends_on"

ExternalReferenceVex ExternalReferenceType = "external_reference_vex"
)

type ComponentType string
type RelationshipType string
type ExternalReferenceType string

// BOM represents an intermediate representation of a component for SBOM.
type BOM struct {
Expand All @@ -62,6 +65,10 @@ type BOM struct {
components map[uuid.UUID]*Component
relationships map[uuid.UUID][]Relationship

// externalReferences is a list of documents that are referenced from this BOM but hosted elsewhere.
// They are currently used to look for linked VEX documents
externalReferences []ExternalReference

// Vulnerabilities is a list of vulnerabilities that affect the component.
// CycloneDX: vulnerabilities
// SPDX: N/A
Expand Down Expand Up @@ -192,6 +199,11 @@ type Relationship struct {
Type RelationshipType
}

type ExternalReference struct {
URL string
Type ExternalReferenceType
}

type Vulnerability struct {
dtypes.Vulnerability
ID string
Expand All @@ -209,12 +221,13 @@ type Options struct {

func NewBOM(opts Options) *BOM {
return &BOM{
components: make(map[uuid.UUID]*Component),
relationships: make(map[uuid.UUID][]Relationship),
vulnerabilities: make(map[uuid.UUID][]Vulnerability),
purls: make(map[string][]uuid.UUID),
parents: make(map[uuid.UUID][]uuid.UUID),
opts: opts,
components: make(map[uuid.UUID]*Component),
relationships: make(map[uuid.UUID][]Relationship),
vulnerabilities: make(map[uuid.UUID][]Vulnerability),
purls: make(map[string][]uuid.UUID),
parents: make(map[uuid.UUID][]uuid.UUID),
externalReferences: make([]ExternalReference, 0),
opts: opts,
}
}

Expand Down Expand Up @@ -279,6 +292,10 @@ func (b *BOM) AddVulnerabilities(c *Component, vulns []Vulnerability) {
b.vulnerabilities[c.id] = vulns
}

func (b *BOM) AddExternalReferences(refs []ExternalReference) {
b.externalReferences = append(b.externalReferences, refs...)
}

func (b *BOM) Root() *Component {
root, ok := b.components[b.rootID]
if !ok {
Expand Down Expand Up @@ -308,6 +325,10 @@ func (b *BOM) Vulnerabilities() map[uuid.UUID][]Vulnerability {
return b.vulnerabilities
}

func (b *BOM) ExternalReferences() []ExternalReference {
return b.externalReferences
}

func (b *BOM) Parents() map[uuid.UUID][]uuid.UUID {
return b.parents
}
Expand Down
39 changes: 39 additions & 0 deletions pkg/sbom/cyclonedx/unmarshal.go
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,11 @@ func (b *BOM) parseBOM(bom *cdx.BOM) error {
b.BOM.AddRelationship(ref, dependency, core.RelationshipDependsOn)
}
}

if refs := b.parseExternalReferences(bom); refs != nil {
b.BOM.AddExternalReferences(refs)
}

return nil
}

Expand All @@ -103,6 +108,40 @@ func (b *BOM) parseMetadataComponent(bom *cdx.BOM) (*core.Component, error) {
return root, nil
}

func (b *BOM) parseExternalReferences(bom *cdx.BOM) []core.ExternalReference {
if bom.ExternalReferences == nil {
return nil
}
var refs = make([]core.ExternalReference, 0)

for _, ref := range *bom.ExternalReferences {
t, err := b.unmarshalReferenceType(ref.Type)
if err != nil {
continue
}

externalReference := core.ExternalReference{
Type: t,
URL: ref.URL,
}

refs = append(refs, externalReference)
}
return refs
}

func (b *BOM) unmarshalReferenceType(t cdx.ExternalReferenceType) (core.ExternalReferenceType, error) {
var referenceType core.ExternalReferenceType
switch t {
case cdx.ERTypeExploitabilityStatement:
referenceType = core.ExternalReferenceVex
default:
// no need to treat as an error - we are only supporting 1 of 25 different ref types
return referenceType, nil
}
return referenceType, nil
}

func (b *BOM) parseComponents(cdxComponents *[]cdx.Component) map[string]*core.Component {
components := make(map[string]*core.Component)
for _, component := range lo.FromPtr(cdxComponents) {
Expand Down
19 changes: 14 additions & 5 deletions pkg/vex/document.go
Original file line number Diff line number Diff line change
Expand Up @@ -27,29 +27,38 @@ func NewDocument(filePath string, report *types.Report) (VEX, error) {
}
defer f.Close()

if v, err := decodeVEX(f, filePath, report); err != nil {
return nil, xerrors.Errorf("unable to load VEX: %w", err)
} else {
return v, nil
}
}

func decodeVEX(r io.ReadSeeker, source string, report *types.Report) (VEX, error) {

var errs error
// Try CycloneDX JSON
if ok, err := sbom.IsCycloneDXJSON(f); err != nil {
if ok, err := sbom.IsCycloneDXJSON(r); err != nil {
errs = multierror.Append(errs, err)
} else if ok {
return decodeCycloneDXJSON(f, report)
return decodeCycloneDXJSON(r, report)
}

// Try OpenVEX
if v, err := decodeOpenVEX(f, filePath); err != nil {
if v, err := decodeOpenVEX(r, source); err != nil {
errs = multierror.Append(errs, err)
} else if v != nil {
return v, nil
}

// Try CSAF
if v, err := decodeCSAF(f, filePath); err != nil {
if v, err := decodeCSAF(r, source); err != nil {
errs = multierror.Append(errs, err)
} else if v != nil {
return v, nil
}

return nil, xerrors.Errorf("unable to load VEX: %w", errs)
return nil, errs
}

func decodeCycloneDXJSON(r io.ReadSeeker, report *types.Report) (*CycloneDX, error) {
Expand Down
108 changes: 108 additions & 0 deletions pkg/vex/sbomref.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
package vex

import (
"bytes"
"fmt"
"io"
"net/http"
"net/url"

"golang.org/x/xerrors"

"github.com/aquasecurity/trivy/pkg/fanal/artifact"
"github.com/aquasecurity/trivy/pkg/log"
"github.com/aquasecurity/trivy/pkg/sbom/core"
"github.com/aquasecurity/trivy/pkg/types"
)

type SBOMReferenceSet struct {
Vexes []VEX
}

func NewSBOMReferenceSet(report *types.Report) (*SBOMReferenceSet, error) {
fmt.Println("hello there")

if report.ArtifactType != artifact.TypeCycloneDX {
return nil, xerrors.Errorf("externalReferences can only be used when scanning CycloneDX SBOMs: %w", report.ArtifactType)
}

var externalRefs = report.BOM.ExternalReferences()
urls := ParseToURLs(externalRefs)

v, err := RetrieveExternalVEXDocuments(urls, report)
if err != nil {
return nil, xerrors.Errorf("failed to fetch external VEX document: %w", err)
}

return &SBOMReferenceSet{Vexes: v}, nil
}

func ParseToURLs(refs []core.ExternalReference) []url.URL {
var urls []url.URL
for _, ref := range refs {
if ref.Type == core.ExternalReferenceVex {
val, err := url.Parse(ref.URL)
// do not concern ourselves with relative URLs at this point
if err != nil || (val.Scheme != "https" && val.Scheme != "http") {
continue
}
urls = append(urls, *val)
}
}
return urls
}

func RetrieveExternalVEXDocuments(refs []url.URL, report *types.Report) ([]VEX, error) {

logger := log.WithPrefix("vex").With(log.String("type", "externalReference"))

var docs []VEX
for _, ref := range refs {
doc, err := RetrieveExternalVEXDocument(ref, report)
if err != nil && doc != nil {
docs = append(docs, doc)
}
}
logger.Debug("Retrieved external VEX documents", "count", len(docs))

if len(docs) == 0 {
logger.Info("No external VEX documents found")
return nil, nil
}
return docs, nil

}

func RetrieveExternalVEXDocument(url url.URL, report *types.Report) (VEX, error) {

Check failure on line 76 in pkg/vex/sbomref.go

View workflow job for this annotation

GitHub Actions / Test (ubuntu-latest)

importShadow: shadow of imported package 'url' (gocritic)

logger := log.WithPrefix("vex").With(log.String("type", "externalReference"))

logger.Info(fmt.Sprintf("Retrieving external VEX document from host %s", url.Host))

res, err := http.Get(url.String())
if err != nil {
return nil, xerrors.Errorf("unable to fetch file via HTTP: %w", err)
}
defer res.Body.Close()

val, err := io.ReadAll(res.Body)
if err != nil {
return nil, xerrors.Errorf("unable to read response into memory: %w", err)
}

if v, err := decodeVEX(bytes.NewReader(val), url.String(), report); err != nil {
return nil, xerrors.Errorf("unable to load VEX: %w", err)
} else {
return v, nil
}
}

func (set *SBOMReferenceSet) NotAffected(vuln types.DetectedVulnerability, product, subComponent *core.Component) (types.ModifiedFinding, bool) {

for _, vex := range set.Vexes {
if m, notAffected := vex.NotAffected(vuln, product, subComponent); notAffected {
return m, notAffected
}
}
return types.ModifiedFinding{}, false
}
75 changes: 75 additions & 0 deletions pkg/vex/sbomref_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
package vex_test

import (
"io"
"net/http"
"net/http/httptest"
"os"
"testing"

"github.com/stretchr/testify/require"

"github.com/aquasecurity/trivy/pkg/fanal/artifact"
"github.com/aquasecurity/trivy/pkg/sbom/core"
"github.com/aquasecurity/trivy/pkg/types"
"github.com/aquasecurity/trivy/pkg/vex"
)

const (
vexExternalRef = "/openvex"
vexUnknown = "/unknown"
)

func setUpServer(t *testing.T) *httptest.Server {
s := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == vexExternalRef {
f, err := os.Open("testdata/" + vexExternalRef + ".json")
t.Error(err)
defer f.Close()

_, err = io.Copy(w, f)
t.Error(err)
} else if r.URL.Path == vexUnknown {
f, err := os.Open("testdata/" + vexUnknown + ".json")
t.Error(err)
defer f.Close()

_, err = io.Copy(w, f)
t.Error(err)
}

http.NotFound(w, r)
return
}))
return s
}

func setupTestReport(s *httptest.Server, path string) *types.Report {
r := types.Report{
ArtifactType: artifact.TypeCycloneDX,
BOM: &core.BOM{},
}
r.BOM.AddExternalReferences([]core.ExternalReference{{
URL: s.URL + path,
Type: core.ExternalReferenceVex,
}})

return &r
}

func TestRetrieveExternalVEXDocuments(t *testing.T) {
s := setUpServer(t)
t.Cleanup(s.Close)

t.Run("external vex retrieval", func(t *testing.T) {
set, err := vex.NewSBOMReferenceSet(setupTestReport(s, vexExternalRef))
require.NoError(t, err)
require.Len(t, set.Vexes, 1)
})

t.Run("incompatible external vex", func(t *testing.T) {
set, err := vex.NewSBOMReferenceSet(setupTestReport(s, vexUnknown))
require.NoError(t, err)
require.Empty(t, set.Vexes)
})
}
16 changes: 13 additions & 3 deletions pkg/vex/vex.go
Original file line number Diff line number Diff line change
Expand Up @@ -15,9 +15,10 @@ import (
)

const (
TypeFile SourceType = "file"
TypeRepository SourceType = "repo"
TypeOCI SourceType = "oci"
TypeFile SourceType = "file"
TypeRepository SourceType = "repo"
TypeOCI SourceType = "oci"
TypeSBOMReference SourceType = "sbom-ref"
)

// VEX represents Vulnerability Exploitability eXchange. It abstracts multiple VEX formats.
Expand Down Expand Up @@ -49,6 +50,8 @@ func NewSource(src string) Source {
return Source{Type: TypeRepository}
case "oci":
return Source{Type: TypeOCI}
case "sbom-ref":
return Source{Type: TypeSBOMReference}
default:
return Source{
Type: TypeFile,
Expand Down Expand Up @@ -111,6 +114,13 @@ func New(ctx context.Context, report *types.Report, opts Options) (*Client, erro
} else if v == nil {
continue
}
case TypeSBOMReference:
v, err = NewSBOMReferenceSet(report)
if err != nil {
return nil, xerrors.Errorf("Failed to create set of external VEX documents: %w", err)
} else if v == nil {
continue
}
default:
log.Warn("Unsupported VEX source", log.String("type", string(src.Type)))
continue
Expand Down
Loading