Skip to content

Commit

Permalink
feat(cyclonedx): Add initial support for loading external VEX files f…
Browse files Browse the repository at this point in the history
…rom SBOM references (aquasecurity#8254)
  • Loading branch information
RingoDev authored and dstrelbytskyi committed Mar 5, 2025
1 parent 43577d8 commit 8291af1
Show file tree
Hide file tree
Showing 8 changed files with 357 additions and 14 deletions.
44 changes: 44 additions & 0 deletions docs/docs/supply-chain/vex/sbom-ref.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
# VEX SBOM Reference

!!! warning "EXPERIMENTAL"
This feature might change without preserving backwards compatibility.

## Using externally referenced VEX documents

Trivy can discover and download VEX documents referenced in the `externalReferences` of a scanned CycloneDX SBOM. This
requires the references to be of type `exploitability-statement`.

To be picked up by Trivy, following top level content needs to be part of a CycloneDx SBOM to dynamically resolve a
remotely hosted file VEX file at the location `https://vex.example.com`:

```
"externalReferences": [
{
"type": "exploitability-statement",
"url": "https://vex.example.com/vex"
}
]
```

This can also be used to dynamically retrieve VEX files stored on GitHub with an `externalReference` such as:

```
"externalReferences": [
{
"type": "exploitability-statement",
"url": "https://raw.githubusercontent.com/aquasecurity/trivy/refs/heads/main/.vex/trivy.openvex.json"
}
]
```

This is not enabled by default at the moment, but can be used when scanning a CycloneDx SBOM and explicitly specifying
`--vex sbom-ref`.

```shell
$ trivy sbom trivy.cdx.json --vex sbom-ref
2025-01-19T13:29:31+01:00 INFO [vex] Retrieving external VEX document from host vex.example.com type="externalReference"
2025-01-19T13:29:31+01:00 INFO Some vulnerabilities have been ignored/suppressed. Use the "--show-suppressed" flag to display them.
```

All the referenced VEX files are retrieved via HTTP/HTTPS and used in the same way as if they were explicitly specified
via a [file reference](./file.md).
1 change: 1 addition & 0 deletions mkdocs.yml
Original file line number Diff line number Diff line change
Expand Up @@ -133,6 +133,7 @@ nav:
- Overview: docs/supply-chain/vex/index.md
- VEX Repository: docs/supply-chain/vex/repo.md
- Local VEX Files: docs/supply-chain/vex/file.md
- VEX SBOM Reference: docs/supply-chain/vex/sbom-ref.md
- VEX Attestation: docs/supply-chain/vex/oci.md
- Compliance:
- Built-in Compliance: docs/compliance/compliance.md
Expand Down
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 @@ -3,6 +3,7 @@ package cyclonedx
import (
"bytes"
"errors"
"fmt"
"io"
"strings"

Expand Down Expand Up @@ -87,6 +88,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 +109,39 @@ 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:
return "", fmt.Errorf("unsupported external reference type: %s", t)
}
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
20 changes: 15 additions & 5 deletions pkg/vex/document.go
Original file line number Diff line number Diff line change
Expand Up @@ -27,29 +27,39 @@ func NewDocument(filePath string, report *types.Report) (VEX, error) {
}
defer f.Close()

v, errs := decodeVEX(f, filePath, report)
if errs != nil {
return nil, xerrors.Errorf("unable to load VEX from file: %w", errs)
} 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, xerrors.Errorf("unable to decode VEX: %w", errs)
}

func decodeCycloneDXJSON(r io.ReadSeeker, report *types.Report) (*CycloneDX, error) {
Expand Down
114 changes: 114 additions & 0 deletions pkg/vex/sbomref.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
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) {

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 documents: %w", err)
} else if v == nil {
return nil, nil
}

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", "external_reference"))

var docs []VEX
for _, ref := range refs {
doc, err := retrieveExternalVEXDocument(ref, report)
if err != nil {
return nil, xerrors.Errorf("failed to retrieve external VEX document: %w", err)
}
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(vexUrl url.URL, report *types.Report) (VEX, error) {

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

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

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

if res.StatusCode != http.StatusOK {
return nil, xerrors.Errorf("did not receive 2xx status code: %w", res.StatusCode)
}

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), vexUrl.String(), report); err != nil {
return nil, xerrors.Errorf("unable to load VEX from external reference: %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
}
Loading

0 comments on commit 8291af1

Please sign in to comment.