diff --git a/pkg/sbom/core/bom.go b/pkg/sbom/core/bom.go index c0a082d013b5..d402aab675c8 100644 --- a/pkg/sbom/core/bom.go +++ b/pkg/sbom/core/bom.go @@ -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 { @@ -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 @@ -192,6 +199,11 @@ type Relationship struct { Type RelationshipType } +type ExternalReference struct { + URL string + Type ExternalReferenceType +} + type Vulnerability struct { dtypes.Vulnerability ID string @@ -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, } } @@ -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 { @@ -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 } diff --git a/pkg/sbom/cyclonedx/unmarshal.go b/pkg/sbom/cyclonedx/unmarshal.go index 2a2456063670..5ceadf5ada93 100644 --- a/pkg/sbom/cyclonedx/unmarshal.go +++ b/pkg/sbom/cyclonedx/unmarshal.go @@ -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 } @@ -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) { diff --git a/pkg/vex/document.go b/pkg/vex/document.go index 9dae8ec54732..b27eb7ca5b6b 100644 --- a/pkg/vex/document.go +++ b/pkg/vex/document.go @@ -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) { diff --git a/pkg/vex/sbomref.go b/pkg/vex/sbomref.go new file mode 100644 index 000000000000..b511a082c1f5 --- /dev/null +++ b/pkg/vex/sbomref.go @@ -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) { + + 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 +} diff --git a/pkg/vex/sbomref_test.go b/pkg/vex/sbomref_test.go new file mode 100644 index 000000000000..0273858fb890 --- /dev/null +++ b/pkg/vex/sbomref_test.go @@ -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) + }) +} diff --git a/pkg/vex/vex.go b/pkg/vex/vex.go index fa3f3151340e..067519e0bdc9 100644 --- a/pkg/vex/vex.go +++ b/pkg/vex/vex.go @@ -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. @@ -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, @@ -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