feat(sbom): add VEX support (#4053)

This commit is contained in:
Teppei Fukuda
2023-04-27 10:21:06 +03:00
committed by GitHub
parent 5eab464987
commit 11a5b91a1a
39 changed files with 1121 additions and 142 deletions

View File

@@ -62,6 +62,7 @@ trivy sbom [flags] SBOM_PATH
-t, --template string output template
--token string for authentication in client/server mode
--token-header string specify a header name for token in client/server mode (default "Trivy-Token")
--vex string [EXPERIMENTAL] file path to VEX
--vuln-type string comma-separated list of vulnerability types (os,library) (default "os,library")
```

View File

@@ -0,0 +1,181 @@
# Vulnerability Exploitability Exchange (VEX)
!!! warning "EXPERIMENTAL"
This feature might change without preserving backwards compatibility.
Trivy supports filtering detected vulnerabilities using [the Vulnerability Exploitability Exchange (VEX)](https://www.ntia.gov/files/ntia/publications/vex_one-page_summary.pdf), a standardized format for sharing and exchanging information about vulnerabilities.
By providing VEX alongside the Software Bill of Materials (SBOM) during scanning, it is possible to filter vulnerabilities based on their status.
Currently, Trivy supports the following two formats:
- [CycloneDX](https://cyclonedx.org/capabilities/vex/)
- [OpenVEX](https://github.com/openvex/spec)
This is still an experimental implementation, with only minimal functionality added.
## CycloneDX
There are [two VEX formats](https://cyclonedx.org/capabilities/vex/) for CycloneDX:
- Independent BOM and VEX BOM
- BOM With Embedded VEX
Trivy only supports the Independent BOM and VEX BOM format, so you need to provide a separate VEX file alongside the SBOM.
The input SBOM format must be in CycloneDX format.
The following steps are required:
1. Generate a CycloneDX SBOM
2. Create a VEX based on the SBOM generated in step 1
3. Provide the VEX when scanning the CycloneDX SBOM
### Generating the SBOM
You can generate a CycloneDX SBOM with Trivy as follows:
```shell
$ trivy image --format cyclonedx --output debian11.sbom.cdx debian:11
```
### Create the VEX
Next, create a VEX based on the generated SBOM.
Multiple vulnerability statuses can be defined under `vulnerabilities`.
Take a look at the example below.
```
$ cat <<EOF > trivy.vex.cdx
{
"bomFormat": "CycloneDX",
"specVersion": "1.4",
"version": 1,
"vulnerabilities": [
{
"id": "CVE-2020-8911",
"analysis": {
"state": "not_affected",
"justification": "code_not_reachable",
"response": ["will_not_fix", "update"],
"detail": "The vulnerable function is not called"
},
"affects": [
{
"ref": "urn:cdx:3e671687-395b-41f5-a30f-a58921a69b79/1#pkg:golang/github.com/aws/aws-sdk-go@1.44.234"
}
]
}
]
}
EOF
```
This is a VEX document in the CycloneDX format.
The vulnerability ID, such as a CVE-ID or GHSA-ID, should be placed in `vulnerabilities.id`.
When the `analysis.state` is set to `not_affected`, Trivy will not detect the vulnerability.
BOM-Links must be placed in `affects.ref`.
The BOM-Link has the following syntax and consists of three elements:
```
urn:cdx:serialNumber/version#bom-ref
```
- serialNumber
- version
- bom-ref
These values must be obtained from the CycloneDX SBOM.
Please note that while the serialNumber starts with `urn:uuid:`, the BOM-Link starts with `urn:cdx:`.
The `bom-ref` must contain the BOM-Ref of the package affected by the vulnerability.
In the example above, since the Go package `github.com/aws/aws-sdk-go` is affected by CVE-2020-8911, it was necessary to specify the SBOM's BOM-Ref, `pkg:golang/github.com/aws/aws-sdk-go@1.44.234`.
For more details on CycloneDX VEX and BOM-Link, please refer to the following links:
- [CycloneDX VEX](https://cyclonedx.org/capabilities/vex/)
- [BOM-Link](https://cyclonedx.org/capabilities/bomlink/)
- [Examples](https://github.com/CycloneDX/bom-examples/tree/master)
### Scan SBOM with VEX
Provide the VEX when scanning the CycloneDX SBOM.
```
$ trivy sbom trivy.sbom.cdx --vex trivy.vex.cdx
...
2023-04-13T12:55:44.838+0300 INFO Filtered out the detected vulnerability {"VEX format": "CycloneDX", "vulnerability-id": "CVE-2020-8911", "status": "not_affected", "justification": "code_not_reachable"}
go.mod (gomod)
==============
Total: 1 (UNKNOWN: 0, LOW: 1, MEDIUM: 0, HIGH: 0, CRITICAL: 0)
┌───────────────────────────┬───────────────┬──────────┬───────────────────┬───────────────┬────────────────────────────────────────────────────────────┐
│ Library │ Vulnerability │ Severity │ Installed Version │ Fixed Version │ Title │
├───────────────────────────┼───────────────┼──────────┼───────────────────┼───────────────┼────────────────────────────────────────────────────────────┤
│ github.com/aws/aws-sdk-go │ CVE-2020-8912 │ LOW │ 1.44.234 │ │ aws-sdk-go: In-band key negotiation issue in AWS S3 Crypto │
│ │ │ │ │ │ SDK for golang... │
│ │ │ │ │ │ https://avd.aquasec.com/nvd/cve-2020-8912 │
└───────────────────────────┴───────────────┴──────────┴───────────────────┴───────────────┴────────────────────────────────────────────────────────────┘
```
CVE-2020-8911 is no longer shown as it is filtered out according to the given CycloneDX VEX document.
## OpenVEX
Trivy also supports [OpenVEX](https://github.com/openvex/spec) that is designed to be minimal, compliant, interoperable, and embeddable.
Since OpenVEX aims to be SBOM format agnostic, both CycloneDX and SPDX formats are available for use as input SBOMs in Trivy.
The following steps are required:
1. Generate a SBOM (CycloneDX or SPDX)
2. Create a VEX based on the SBOM generated in step 1
3. Provide the VEX when scanning the SBOM
### Generating the SBOM
You can generate a CycloneDX or SPDX SBOM with Trivy as follows:
```shell
$ trivy image --format spdx-json --output debian11.spdx.json debian:11
```
### Create the VEX
Please see also [the example](https://github.com/openvex/examples).
The product identifiers differ depending on the SBOM format the VEX references.
- SPDX: [Package URL (PURL)](https://github.com/package-url/purl-spec)
- CycloneDX: [BOM-Link](https://cyclonedx.org/capabilities/bomlink/)
```
$ cat <<EOF > trivy.openvex
{
"@context": "https://openvex.dev/ns",
"@id": "https://openvex.dev/docs/public/vex-2e67563e128250cbcb3e98930df948dd053e43271d70dc50cfa22d57e03fe96f",
"author": "Aqua Security",
"timestamp": "2023-01-16T19:07:16.853479631-06:00",
"version": "1",
"statements": [
{
"vulnerability": "CVE-2019-8457",
"products": [
"pkg:deb/debian/libdb5.3@5.3.28+dfsg1-0.8?arch=arm64\u0026distro=debian-11.6"
],
"status": "not_affected",
"justification": "vulnerable_code_not_in_execute_path"
}
]
}
EOF
```
In the above example, PURLs, located in `packages.externalRefs.referenceLocator` are used since the input SBOM format is SPDX.
As for CycloneDX BOM-Link, please reference [the CycloneDX section](#cyclonedx).
### Scan SBOM with VEX
Provide the VEX when scanning the SBOM.
```
$ trivy sbom debian11.spdx.json --vex trivy.openvex
...
2023-04-26T17:56:05.358+0300 INFO Filtered out the detected vulnerability {"VEX format": "OpenVEX", "vulnerability-id": "CVE-2019-8457", "status": "not_affected", "justification": "vulnerable_code_not_in_execute_path"}
debian11.spdx.json (debian 11.6)
================================
Total: 80 (UNKNOWN: 0, LOW: 58, MEDIUM: 6, HIGH: 16, CRITICAL: 0)
```
CVE-2019-8457 is no longer shown as it is filtered out according to the given OpenVEX document.

3
go.mod
View File

@@ -72,12 +72,14 @@ require (
github.com/open-policy-agent/opa v0.45.0
github.com/opencontainers/go-digest v1.0.0
github.com/opencontainers/image-spec v1.1.0-rc2.0.20221020182949-4df8887994e8
github.com/openvex/go-vex v0.2.0
github.com/owenrumney/go-sarif/v2 v2.1.2
github.com/package-url/packageurl-go v0.1.1-0.20220428063043-89078438f170
github.com/samber/lo v1.37.0
github.com/saracen/walker v0.1.3
github.com/secure-systems-lab/go-securesystemslib v0.5.0
github.com/sigstore/rekor v1.1.0
github.com/sirupsen/logrus v1.9.0
github.com/sosedoff/gitkit v0.3.0
github.com/spdx/tools-golang v0.5.0
github.com/spf13/cast v1.5.0
@@ -327,7 +329,6 @@ require (
github.com/sergi/go-diff v1.2.0 // indirect
github.com/shibumi/go-pathspec v1.3.0 // indirect
github.com/shopspring/decimal v1.2.0 // indirect
github.com/sirupsen/logrus v1.9.0 // indirect
github.com/skeema/knownhosts v1.1.0 // indirect
github.com/spf13/afero v1.9.3 // indirect
github.com/spf13/jwalterweatherman v1.1.0 // indirect

4
go.sum
View File

@@ -1427,6 +1427,8 @@ github.com/opencontainers/selinux v1.11.0 h1:+5Zbo97w3Lbmb3PeqQtpmTkMwsW5nRI3YaL
github.com/opencontainers/selinux v1.11.0/go.mod h1:E5dMC3VPuVvVHDYmi78qvhJp8+M586T4DlDRYpFkyec=
github.com/opentracing/opentracing-go v1.2.0 h1:uEJPy/1a5RIPAJ0Ov+OIO8OxWu77jEv+1B0VhjKrZUs=
github.com/opentracing/opentracing-go v1.2.0/go.mod h1:GxEUsuufX4nBwe+T+Wl9TAgYrxe9dPLANfrWvHYVTgc=
github.com/openvex/go-vex v0.2.0 h1:7Q6VzdpZSZzYUyXB1dio/9LCGHp1iL3JldC+hMsbFg0=
github.com/openvex/go-vex v0.2.0/go.mod h1:jYmYbhQAO/0hquryXND/jMVDBcob8/KkVgzUEUAHsFI=
github.com/owenrumney/go-sarif v1.1.1/go.mod h1:dNDiPlF04ESR/6fHlPyq7gHKmrM0sHUvAGjsoh8ZH0U=
github.com/owenrumney/go-sarif/v2 v2.1.2 h1:PMDK7tXShJ9zsB7bfvlpADH5NEw1dfA9xwU8Xtdj73U=
github.com/owenrumney/go-sarif/v2 v2.1.2/go.mod h1:MSqMMx9WqlBSY7pXoOZWgEsVB4FDNfhcaXDA1j6Sr+w=
@@ -1510,7 +1512,7 @@ github.com/rogpeppe/go-internal v1.1.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFR
github.com/rogpeppe/go-internal v1.2.2/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4=
github.com/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4=
github.com/rogpeppe/go-internal v1.8.0/go.mod h1:WmiCO8CzOY8rg0OYDC4/i/2WRWAB6poM+XZ2dLUbcbE=
github.com/rogpeppe/go-internal v1.8.1 h1:geMPLpDpQOgVyCg5z5GoRwLHepNdb71NXb67XFkP+Eg=
github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8=
github.com/rubenv/sql-migrate v1.2.0 h1:fOXMPLMd41sK7Tg75SXDec15k3zg5WNV6SjuDRiNfcU=
github.com/rubenv/sql-migrate v1.2.0/go.mod h1:Z5uVnq7vrIrPmHbVFfR4YLHRZquxeHpckCnRq0P/K9Y=
github.com/russross/blackfriday/v2 v2.0.1/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=

View File

@@ -68,9 +68,9 @@ func TestSBOM(t *testing.T) {
{
Target: "testdata/fixtures/sbom/centos-7-spdx.txt (centos 7.6.1810)",
Vulnerabilities: []types.DetectedVulnerability{
{Ref: "pkg:rpm/centos/bash@4.2.46-31.el7?arch=x86_64&distro=centos-7.6.1810"},
{Ref: "pkg:rpm/centos/openssl-libs@1.0.2k-16.el7?arch=x86_64&epoch=1&distro=centos-7.6.1810"},
{Ref: "pkg:rpm/centos/openssl-libs@1.0.2k-16.el7?arch=x86_64&epoch=1&distro=centos-7.6.1810"},
{PkgRef: "pkg:rpm/centos/bash@4.2.46-31.el7?arch=x86_64&distro=centos-7.6.1810"},
{PkgRef: "pkg:rpm/centos/openssl-libs@1.0.2k-16.el7?arch=x86_64&epoch=1&distro=centos-7.6.1810"},
{PkgRef: "pkg:rpm/centos/openssl-libs@1.0.2k-16.el7?arch=x86_64&epoch=1&distro=centos-7.6.1810"},
},
},
},
@@ -91,9 +91,9 @@ func TestSBOM(t *testing.T) {
{
Target: "testdata/fixtures/sbom/centos-7-spdx.json (centos 7.6.1810)",
Vulnerabilities: []types.DetectedVulnerability{
{Ref: "pkg:rpm/centos/bash@4.2.46-31.el7?arch=x86_64&distro=centos-7.6.1810"},
{Ref: "pkg:rpm/centos/openssl-libs@1.0.2k-16.el7?arch=x86_64&epoch=1&distro=centos-7.6.1810"},
{Ref: "pkg:rpm/centos/openssl-libs@1.0.2k-16.el7?arch=x86_64&epoch=1&distro=centos-7.6.1810"},
{PkgRef: "pkg:rpm/centos/bash@4.2.46-31.el7?arch=x86_64&distro=centos-7.6.1810"},
{PkgRef: "pkg:rpm/centos/openssl-libs@1.0.2k-16.el7?arch=x86_64&epoch=1&distro=centos-7.6.1810"},
{PkgRef: "pkg:rpm/centos/openssl-libs@1.0.2k-16.el7?arch=x86_64&epoch=1&distro=centos-7.6.1810"},
},
},
},
@@ -107,7 +107,13 @@ func TestSBOM(t *testing.T) {
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
osArgs := []string{
"--cache-dir", cacheDir, "sbom", "-q", "--skip-db-update", "--format", tt.args.format,
"--cache-dir",
cacheDir,
"sbom",
"-q",
"--skip-db-update",
"--format",
tt.args.format,
}
// Set up the output file
@@ -154,7 +160,7 @@ func compareSBOMReports(t *testing.T, wantFile, gotFile string, overrideWant typ
for i, result := range overrideWant.Results {
want.Results[i].Target = result.Target
for j, vuln := range result.Vulnerabilities {
want.Results[i].Vulnerabilities[j].Ref = vuln.Ref
want.Results[i].Vulnerabilities[j].PkgRef = vuln.PkgRef
}
}

View File

@@ -87,6 +87,7 @@ nav:
- SBOM: docs/supply-chain/attestation/sbom.md
- Cosign Vulnerability Scan Record: docs/supply-chain/attestation/vuln.md
- SBOM Attestation in Rekor: docs/supply-chain/attestation/rekor.md
- VEX: docs/supply-chain/vex.md
- Compliance:
- Reports: docs/compliance/compliance.md
- Advanced:

View File

@@ -6,18 +6,13 @@ import (
"sort"
"time"
ftypes "github.com/aquasecurity/trivy/pkg/fanal/types"
"github.com/aquasecurity/tml"
"github.com/aquasecurity/trivy/pkg/flag"
"github.com/aquasecurity/trivy/pkg/report"
"github.com/aquasecurity/trivy/pkg/result"
"github.com/aquasecurity/defsec/pkg/scan"
"github.com/aquasecurity/tml"
ftypes "github.com/aquasecurity/trivy/pkg/fanal/types"
"github.com/aquasecurity/trivy/pkg/flag"
"github.com/aquasecurity/trivy/pkg/report"
pkgReport "github.com/aquasecurity/trivy/pkg/report"
"github.com/aquasecurity/trivy/pkg/result"
"github.com/aquasecurity/trivy/pkg/types"
)
@@ -70,16 +65,7 @@ func Write(rep *Report, opt flag.Options, fromCache bool) error {
for _, resultsAtTime := range rep.Results {
for _, res := range resultsAtTime.Results {
resCopy := res
if err := result.Filter(
ctx,
&resCopy,
opt.Severities,
false,
false,
"",
"",
nil,
); err != nil {
if err := result.FilterResult(ctx, &resCopy, result.FilterOption{Severities: opt.Severities}); err != nil {
return err
}
sort.Slice(resCopy.Misconfigurations, func(i, j int) bool {

View File

@@ -271,16 +271,20 @@ func (r *runner) scanArtifact(ctx context.Context, opts flag.Options, initialize
}
func (r *runner) Filter(ctx context.Context, opts flag.Options, report types.Report) (types.Report, error) {
results := report.Results
// Filter results
for i := range results {
err := result.Filter(ctx, &results[i], opts.Severities, opts.IgnoreUnfixed, opts.IncludeNonFailures,
opts.IgnoreFile, opts.IgnorePolicy, opts.IgnoredLicenses)
err := result.Filter(ctx, report, result.FilterOption{
Severities: opts.Severities,
IgnoreUnfixed: opts.IgnoreUnfixed,
IncludeNonFailures: opts.IncludeNonFailures,
IgnoreFile: opts.IgnoreFile,
PolicyFile: opts.IgnorePolicy,
IgnoreLicenses: opts.IgnoredLicenses,
VEXPath: opts.VEXPath,
})
if err != nil {
return types.Report{}, xerrors.Errorf("unable to filter vulnerabilities: %w", err)
}
return types.Report{}, xerrors.Errorf("filtering error: %w", err)
}
return report, nil
}

View File

@@ -38,7 +38,7 @@ func detect(driver Driver, libs []ftypes.Package) ([]types.DetectedVulnerability
for i := range vulns {
vulns[i].Layer = lib.Layer
vulns[i].PkgPath = lib.FilePath
vulns[i].Ref = lib.Ref
vulns[i].PkgRef = lib.Ref
}
vulnerabilities = append(vulnerabilities, vulns...)
}

View File

@@ -91,7 +91,7 @@ func (s *Scanner) Detect(osVer string, _ *ftypes.Repository, pkgs []ftypes.Packa
PkgName: pkg.Name,
InstalledVersion: installed,
FixedVersion: fixedVersion.String(),
Ref: pkg.Ref,
PkgRef: pkg.Ref,
Layer: pkg.Layer,
DataSource: adv.DataSource,
Custom: adv.Custom,

View File

@@ -130,7 +130,7 @@ func (s *Scanner) Detect(osVer string, repo *ftypes.Repository, pkgs []ftypes.Pa
InstalledVersion: utils.FormatVersion(pkg),
FixedVersion: adv.FixedVersion,
Layer: pkg.Layer,
Ref: pkg.Ref,
PkgRef: pkg.Ref,
Custom: adv.Custom,
DataSource: adv.DataSource,
})

View File

@@ -105,7 +105,7 @@ func (s *Scanner) Detect(osVer string, _ *ftypes.Repository, pkgs []ftypes.Packa
PkgName: pkg.Name,
InstalledVersion: installed,
FixedVersion: adv.FixedVersion,
Ref: pkg.Ref,
PkgRef: pkg.Ref,
Layer: pkg.Layer,
Custom: adv.Custom,
DataSource: adv.DataSource,

View File

@@ -82,7 +82,7 @@ func (s *Scanner) Detect(_ string, _ *ftypes.Repository, pkgs []ftypes.Package)
InstalledVersion: installed,
FixedVersion: adv.FixedVersion,
Layer: pkg.Layer,
Ref: pkg.Ref,
PkgRef: pkg.Ref,
Custom: adv.Custom,
DataSource: adv.DataSource,
})

View File

@@ -104,7 +104,7 @@ func (s *Scanner) Detect(osVer string, _ *ftypes.Repository, pkgs []ftypes.Packa
PkgName: pkg.Name,
InstalledVersion: utils.FormatVersion(pkg),
FixedVersion: adv.FixedVersion,
Ref: pkg.Ref,
PkgRef: pkg.Ref,
Layer: pkg.Layer,
Custom: adv.Custom,
DataSource: adv.DataSource,

View File

@@ -52,7 +52,7 @@ func (s *Scanner) Detect(osVer string, _ *ftypes.Repository, pkgs []ftypes.Packa
VulnerabilityID: adv.VulnerabilityID,
PkgName: pkg.Name,
InstalledVersion: utils.FormatVersion(pkg),
Ref: pkg.Ref,
PkgRef: pkg.Ref,
Layer: pkg.Layer,
DataSource: adv.DataSource,
}

View File

@@ -88,7 +88,7 @@ func (s *Scanner) Detect(osVer string, _ *ftypes.Repository, pkgs []ftypes.Packa
PkgID: pkg.ID,
PkgName: pkg.Name,
InstalledVersion: installed,
Ref: pkg.Ref,
PkgRef: pkg.Ref,
Layer: pkg.Layer,
Custom: adv.Custom,
DataSource: adv.DataSource,

View File

@@ -80,7 +80,7 @@ func (s *Scanner) Detect(osVer string, _ *ftypes.Repository, pkgs []ftypes.Packa
PkgID: pkg.ID,
PkgName: pkg.Name,
InstalledVersion: installed,
Ref: pkg.Ref,
PkgRef: pkg.Ref,
Layer: pkg.Layer,
Custom: adv.Custom,
DataSource: adv.DataSource,

View File

@@ -158,7 +158,7 @@ func (s *Scanner) detect(osVer string, pkg ftypes.Package) ([]types.DetectedVuln
PkgID: pkg.ID,
PkgName: pkg.Name,
InstalledVersion: utils.FormatVersion(pkg),
Ref: pkg.Ref,
PkgRef: pkg.Ref,
Layer: pkg.Layer,
SeveritySource: vulnerability.RedHat,
Vulnerability: dbTypes.Vulnerability{

View File

@@ -91,7 +91,7 @@ func (s *Scanner) Detect(osVer string, _ *ftypes.Repository, pkgs []ftypes.Packa
PkgName: pkg.Name,
InstalledVersion: installed,
FixedVersion: fixedVersion.String(),
Ref: pkg.Ref,
PkgRef: pkg.Ref,
Layer: pkg.Layer,
DataSource: adv.DataSource,
Custom: adv.Custom,

View File

@@ -132,7 +132,7 @@ func (s *Scanner) Detect(osVer string, _ *ftypes.Repository, pkgs []ftypes.Packa
PkgID: pkg.ID,
PkgName: pkg.Name,
InstalledVersion: installed,
Ref: pkg.Ref,
PkgRef: pkg.Ref,
Layer: pkg.Layer,
Custom: adv.Custom,
DataSource: adv.DataSource,

View File

@@ -118,7 +118,7 @@ func (s *Scanner) Detect(osVer string, _ *ftypes.Repository, pkgs []ftypes.Packa
PkgName: pkg.Name,
InstalledVersion: utils.FormatVersion(pkg),
FixedVersion: adv.FixedVersion,
Ref: pkg.Ref,
PkgRef: pkg.Ref,
Layer: pkg.Layer,
Custom: adv.Custom,
DataSource: adv.DataSource,

View File

@@ -81,7 +81,7 @@ func (s *Scanner) Detect(_ string, _ *ftypes.Repository, pkgs []ftypes.Package)
InstalledVersion: installed,
FixedVersion: adv.FixedVersion,
Layer: pkg.Layer,
Ref: pkg.Ref,
PkgRef: pkg.Ref,
Custom: adv.Custom,
DataSource: adv.DataSource,
})

View File

@@ -21,22 +21,29 @@ var (
Usage: "deprecated",
Deprecated: true,
}
VEXFlag = Flag{
Name: "vex",
ConfigName: "sbom.vex",
Value: "",
Usage: "[EXPERIMENTAL] file path to VEX",
}
)
type SBOMFlagGroup struct {
ArtifactType *Flag // deprecated
SBOMFormat *Flag // deprecated
VEXPath *Flag
}
type SBOMOptions struct {
ArtifactType string // deprecated
SBOMFormat string // deprecated
VEXPath string
}
func NewSBOMFlagGroup() *SBOMFlagGroup {
return &SBOMFlagGroup{
ArtifactType: &ArtifactTypeFlag,
SBOMFormat: &SBOMFormatFlag,
VEXPath: &VEXFlag,
}
}
@@ -45,7 +52,11 @@ func (f *SBOMFlagGroup) Name() string {
}
func (f *SBOMFlagGroup) Flags() []*Flag {
return []*Flag{f.ArtifactType, f.SBOMFormat}
return []*Flag{
f.ArtifactType,
f.SBOMFormat,
f.VEXPath,
}
}
func (f *SBOMFlagGroup) ToOptions() (SBOMOptions, error) {
@@ -58,5 +69,7 @@ func (f *SBOMFlagGroup) ToOptions() (SBOMOptions, error) {
return SBOMOptions{}, xerrors.New("'--artifact-type' and '--sbom-format' are no longer available")
}
return SBOMOptions{}, nil
return SBOMOptions{
VEXPath: getString(f.VEXPath),
}, nil
}

View File

@@ -1304,8 +1304,8 @@ func easyjson6601e8cdDecodeGithubComAquasecurityTrivyPkgTypes(in *jlexer.Lexer,
out.SeveritySource = types2.SourceID(in.String())
case "PrimaryURL":
out.PrimaryURL = string(in.String())
case "Ref":
out.Ref = string(in.String())
case "PkgRef":
out.PkgRef = string(in.String())
case "DataSource":
if in.IsNull() {
in.Skip()
@@ -1559,15 +1559,15 @@ func easyjson6601e8cdEncodeGithubComAquasecurityTrivyPkgTypes(out *jwriter.Write
}
out.String(string(in.PrimaryURL))
}
if in.Ref != "" {
const prefix string = ",\"Ref\":"
if in.PkgRef != "" {
const prefix string = ",\"PkgRef\":"
if first {
first = false
out.RawString(prefix[1:])
} else {
out.RawString(prefix)
}
out.String(string(in.Ref))
out.String(string(in.PkgRef))
}
if in.DataSource != nil {
const prefix string = ",\"DataSource\":"
@@ -1953,7 +1953,7 @@ func easyjson6601e8cdDecodeGithubComAquasecurityTrivyPkgFanalTypes(in *jlexer.Le
}
easyjson6601e8cdDecodeGithubComAquasecurityTrivyPkgFanalTypes7(in, out.BuildInfo)
}
case "Ref":
case "PkgRef":
out.Ref = string(in.String())
case "Indirect":
out.Indirect = bool(in.Bool())
@@ -2134,7 +2134,7 @@ func easyjson6601e8cdEncodeGithubComAquasecurityTrivyPkgFanalTypes(out *jwriter.
easyjson6601e8cdEncodeGithubComAquasecurityTrivyPkgFanalTypes7(out, *in.BuildInfo)
}
if in.Ref != "" {
const prefix string = ",\"Ref\":"
const prefix string = ",\"PkgRef\":"
if first {
first = false
out.RawString(prefix[1:])

View File

@@ -9,9 +9,8 @@ import (
"strings"
"time"
"github.com/samber/lo"
"github.com/open-policy-agent/opa/rego"
"github.com/samber/lo"
"golang.org/x/exp/maps"
"golang.org/x/exp/slices"
"golang.org/x/xerrors"
@@ -20,6 +19,7 @@ import (
ftypes "github.com/aquasecurity/trivy/pkg/fanal/types"
"github.com/aquasecurity/trivy/pkg/log"
"github.com/aquasecurity/trivy/pkg/types"
"github.com/aquasecurity/trivy/pkg/vex"
)
const (
@@ -27,19 +27,43 @@ const (
DefaultIgnoreFile = ".trivyignore"
)
// Filter filters out the vulnerabilities
func Filter(ctx context.Context, result *types.Result, severities []dbTypes.Severity, ignoreUnfixed, includeNonFailures bool,
ignoreFile, policyFile string, ignoreLicenses []string) error {
ignoredIDs := getIgnoredIDs(ignoreFile)
type FilterOption struct {
Severities []dbTypes.Severity
IgnoreUnfixed bool
IncludeNonFailures bool
IgnoreFile string
PolicyFile string
IgnoreLicenses []string
VEXPath string
}
filteredVulns := filterVulnerabilities(result.Vulnerabilities, severities, ignoreUnfixed, ignoredIDs)
misconfSummary, filteredMisconfs := filterMisconfigurations(result.Misconfigurations, severities, includeNonFailures, ignoredIDs)
result.Secrets = filterSecrets(result.Secrets, severities, ignoredIDs)
result.Licenses = filterLicenses(result.Licenses, severities, ignoreLicenses)
// Filter filters out the report
func Filter(ctx context.Context, report types.Report, opt FilterOption) error {
// Filter out vulnerabilities based on the given VEX document.
if err := filterByVEX(report, opt); err != nil {
return xerrors.Errorf("VEX error: %w", err)
}
if policyFile != "" {
for i := range report.Results {
if err := FilterResult(ctx, &report.Results[i], opt); err != nil {
return xerrors.Errorf("unable to filter vulnerabilities: %w", err)
}
}
return nil
}
// FilterResult filters out the result
func FilterResult(ctx context.Context, result *types.Result, opt FilterOption) error {
ignoredIDs := getIgnoredIDs(opt.IgnoreFile)
filteredVulns := filterVulnerabilities(result.Vulnerabilities, opt.Severities, opt.IgnoreUnfixed, ignoredIDs, opt.VEXPath)
misconfSummary, filteredMisconfs := filterMisconfigurations(result.Misconfigurations, opt.Severities, opt.IncludeNonFailures, ignoredIDs)
result.Secrets = filterSecrets(result.Secrets, opt.Severities, ignoredIDs)
result.Licenses = filterLicenses(result.Licenses, opt.Severities, opt.IgnoreLicenses)
if opt.PolicyFile != "" {
var err error
filteredVulns, filteredMisconfs, err = applyPolicy(ctx, filteredVulns, filteredMisconfs, policyFile)
filteredVulns, filteredMisconfs, err = applyPolicy(ctx, filteredVulns, filteredMisconfs, opt.PolicyFile)
if err != nil {
return xerrors.Errorf("failed to apply the policy: %w", err)
}
@@ -53,9 +77,30 @@ func Filter(ctx context.Context, result *types.Result, severities []dbTypes.Seve
return nil
}
func filterVulnerabilities(vulns []types.DetectedVulnerability, severities []dbTypes.Severity,
ignoreUnfixed bool, ignoredIDs []string) []types.DetectedVulnerability {
// filterByVEX determines whether a detected vulnerability should be filtered out based on the provided VEX document.
// If the VEX document is not nil and the vulnerability is either not affected or fixed according to the VEX statement,
// the vulnerability is filtered out.
func filterByVEX(report types.Report, opt FilterOption) error {
vexDoc, err := vex.New(opt.VEXPath, report)
if err != nil {
return err
} else if vexDoc == nil {
return nil
}
for i, result := range report.Results {
if len(result.Vulnerabilities) == 0 {
continue
}
report.Results[i].Vulnerabilities = vexDoc.Filter(result.Vulnerabilities)
}
return nil
}
func filterVulnerabilities(vulns []types.DetectedVulnerability, severities []dbTypes.Severity, ignoreUnfixed bool,
ignoredIDs []string, vexPath string) []types.DetectedVulnerability {
uniqVulns := make(map[string]types.DetectedVulnerability)
for _, vuln := range vulns {
if vuln.Severity == "" {
vuln.Severity = dbTypes.SeverityUnknown.String()
@@ -136,6 +181,9 @@ func filterSecrets(secrets []ftypes.SecretFinding, severities []dbTypes.Severity
}
func filterLicenses(licenses []types.DetectedLicense, severities []dbTypes.Severity, ignoredLicenses []string) []types.DetectedLicense {
if len(licenses) == 0 {
return nil
}
return lo.Filter(licenses, func(l types.DetectedLicense, _ int) bool {
// Skip the license if it is included in ignored licenses.
if slices.Contains(ignoredLicenses, l.Name) {

View File

@@ -7,19 +7,210 @@ import (
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/aquasecurity/trivy/pkg/result"
dbTypes "github.com/aquasecurity/trivy-db/pkg/types"
ftypes "github.com/aquasecurity/trivy/pkg/fanal/types"
"github.com/aquasecurity/trivy/pkg/result"
"github.com/aquasecurity/trivy/pkg/types"
)
func TestClient_Filter(t *testing.T) {
func TestFilter(t *testing.T) {
type args struct {
report types.Report
severities []dbTypes.Severity
vexPath string
}
tests := []struct {
name string
args args
want types.Report
}{
{
name: "severities",
args: args{
report: types.Report{
Results: []types.Result{
{
Vulnerabilities: []types.DetectedVulnerability{
{
VulnerabilityID: "CVE-2019-0001",
PkgName: "foo",
InstalledVersion: "1.2.3",
FixedVersion: "1.2.4",
Vulnerability: dbTypes.Vulnerability{
Severity: dbTypes.SeverityLow.String(),
},
},
{
VulnerabilityID: "CVE-2019-0002",
PkgName: "bar",
InstalledVersion: "1.2.3",
FixedVersion: "1.2.4",
Vulnerability: dbTypes.Vulnerability{
Severity: dbTypes.SeverityCritical.String(),
},
},
},
Misconfigurations: []types.DetectedMisconfiguration{
{
Type: ftypes.Kubernetes,
ID: "ID100",
Title: "Bad Deployment",
Message: "something bad",
Severity: dbTypes.SeverityCritical.String(),
Status: types.StatusFailure,
},
{
Type: ftypes.Kubernetes,
ID: "ID200",
Title: "Bad Pod",
Message: "something bad",
Severity: dbTypes.SeverityMedium.String(),
Status: types.StatusPassed,
},
},
Secrets: []ftypes.SecretFinding{
{
RuleID: "generic-critical-rule",
Severity: dbTypes.SeverityCritical.String(),
Title: "Critical Secret should pass filter",
StartLine: 1,
EndLine: 2,
Match: "*****",
},
{
RuleID: "generic-low-rule",
Severity: dbTypes.SeverityLow.String(),
Title: "Low Secret should be ignored",
StartLine: 3,
EndLine: 4,
Match: "*****",
},
},
},
},
},
severities: []dbTypes.Severity{
dbTypes.SeverityCritical,
},
},
want: types.Report{
Results: []types.Result{
{
Vulnerabilities: []types.DetectedVulnerability{
{
VulnerabilityID: "CVE-2019-0002",
PkgName: "bar",
InstalledVersion: "1.2.3",
FixedVersion: "1.2.4",
Vulnerability: dbTypes.Vulnerability{
Severity: dbTypes.SeverityCritical.String(),
},
},
},
MisconfSummary: &types.MisconfSummary{
Successes: 0,
Failures: 1,
Exceptions: 0,
},
Misconfigurations: []types.DetectedMisconfiguration{
{
Type: ftypes.Kubernetes,
ID: "ID100",
Title: "Bad Deployment",
Message: "something bad",
Severity: dbTypes.SeverityCritical.String(),
Status: types.StatusFailure,
},
},
Secrets: []ftypes.SecretFinding{
{
RuleID: "generic-critical-rule",
Severity: dbTypes.SeverityCritical.String(),
Title: "Critical Secret should pass filter",
StartLine: 1,
EndLine: 2,
Match: "*****",
},
},
},
},
},
},
{
name: "filter by VEX",
args: args{
report: types.Report{
Results: types.Results{
types.Result{
Vulnerabilities: []types.DetectedVulnerability{
{
VulnerabilityID: "CVE-2019-0001",
PkgName: "foo",
PkgRef: "pkg:golang/github.com/aquasecurity/foo@1.2.3",
InstalledVersion: "1.2.3",
FixedVersion: "1.2.4",
Vulnerability: dbTypes.Vulnerability{
Severity: dbTypes.SeverityLow.String(),
},
},
{
VulnerabilityID: "CVE-2019-0001",
PkgName: "bar",
PkgRef: "pkg:golang/github.com/aquasecurity/bar@1.2.3",
InstalledVersion: "1.2.3",
FixedVersion: "1.2.4",
Vulnerability: dbTypes.Vulnerability{
Severity: dbTypes.SeverityCritical.String(),
},
},
},
},
},
},
severities: []dbTypes.Severity{
dbTypes.SeverityCritical,
dbTypes.SeverityHigh,
dbTypes.SeverityMedium,
dbTypes.SeverityLow,
dbTypes.SeverityUnknown,
},
vexPath: "testdata/openvex.json",
},
want: types.Report{
Results: types.Results{
types.Result{
Vulnerabilities: []types.DetectedVulnerability{
{
VulnerabilityID: "CVE-2019-0001",
PkgName: "bar",
PkgRef: "pkg:golang/github.com/aquasecurity/bar@1.2.3",
InstalledVersion: "1.2.3",
FixedVersion: "1.2.4",
Vulnerability: dbTypes.Vulnerability{
Severity: dbTypes.SeverityCritical.String(),
},
},
},
},
},
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
err := result.Filter(context.Background(), tt.args.report, result.FilterOption{
Severities: tt.args.severities,
VEXPath: tt.args.vexPath,
})
require.NoError(t, err)
assert.Equal(t, tt.want, tt.args.report)
})
}
}
func TestFilterResult(t *testing.T) {
type args struct {
result types.Result
//vulns []types.DetectedVulnerability
//misconfs []types.DetectedMisconfiguration
//secrets []ftypes.SecretFinding
severities []dbTypes.Severity
ignoreUnfixed bool
ignoreFile string
@@ -122,7 +313,11 @@ func TestClient_Filter(t *testing.T) {
},
},
},
severities: []dbTypes.Severity{dbTypes.SeverityCritical, dbTypes.SeverityHigh, dbTypes.SeverityUnknown},
severities: []dbTypes.Severity{
dbTypes.SeverityCritical,
dbTypes.SeverityHigh,
dbTypes.SeverityUnknown,
},
ignoreUnfixed: false,
},
wantVulns: []types.DetectedVulnerability{
@@ -479,7 +674,11 @@ func TestClient_Filter(t *testing.T) {
},
},
},
severities: []dbTypes.Severity{dbTypes.SeverityCritical, dbTypes.SeverityHigh, dbTypes.SeverityUnknown},
severities: []dbTypes.Severity{
dbTypes.SeverityCritical,
dbTypes.SeverityHigh,
dbTypes.SeverityUnknown,
},
ignoreUnfixed: false,
},
wantVulns: []types.DetectedVulnerability{
@@ -607,7 +806,11 @@ func TestClient_Filter(t *testing.T) {
},
},
},
severities: []dbTypes.Severity{dbTypes.SeverityCritical, dbTypes.SeverityHigh, dbTypes.SeverityUnknown},
severities: []dbTypes.Severity{
dbTypes.SeverityCritical,
dbTypes.SeverityHigh,
dbTypes.SeverityUnknown,
},
ignoreUnfixed: false,
},
wantVulns: []types.DetectedVulnerability{
@@ -666,8 +869,13 @@ func TestClient_Filter(t *testing.T) {
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
err := result.Filter(context.Background(), &tt.args.result,
tt.args.severities, tt.args.ignoreUnfixed, false, tt.args.ignoreFile, tt.args.policyFile, tt.args.ignoreLicenses)
err := result.FilterResult(context.Background(), &tt.args.result, result.FilterOption{
Severities: tt.args.severities,
IgnoreUnfixed: tt.args.ignoreUnfixed,
IgnoreFile: tt.args.ignoreFile,
PolicyFile: tt.args.policyFile,
IgnoreLicenses: tt.args.ignoreLicenses,
})
require.NoError(t, err)
assert.Equal(t, tt.wantVulns, tt.args.result.Vulnerabilities)
assert.Equal(t, tt.wantMisconfSummary, tt.args.result.MisconfSummary)

17
pkg/result/testdata/openvex.json vendored Normal file
View File

@@ -0,0 +1,17 @@
{
"@context": "https://openvex.dev/ns",
"author": "Aqua Security",
"role": "Project Release Bot",
"timestamp": "2023-01-16T19:07:16.853479631-06:00",
"version": "1",
"statements": [
{
"vulnerability": "CVE-2019-0001",
"products": [
"pkg:golang/github.com/aquasecurity/foo@1.2.3"
],
"status": "not_affected",
"justification": "vulnerable_code_not_in_execute_path"
}
]
}

View File

@@ -120,7 +120,7 @@ func (e *Marshaler) MarshalVulnerabilities(report types.Report) (*cdx.BOM, error
vulnMap := map[string]cdx.Vulnerability{}
for _, result := range report.Results {
for _, vuln := range result.Vulnerabilities {
ref, err := externalRef(report.CycloneDX.SerialNumber, vuln.Ref)
ref, err := externalRef(report.CycloneDX.SerialNumber, vuln.PkgRef)
if err != nil {
return nil, err
}

View File

@@ -1288,7 +1288,7 @@ func TestMarshaler_MarshalVulnerabilities(t *testing.T) {
VulnerabilityID: "CVE-2018-20623",
PkgName: "binutils",
InstalledVersion: "2.30-93.el8",
Ref: "pkg:rpm/centos/binutils@2.30-93.el8?arch=aarch64&distro=centos-8.3.2011",
PkgRef: "pkg:rpm/centos/binutils@2.30-93.el8?arch=aarch64&distro=centos-8.3.2011",
Layer: ftypes.Layer{
DiffID: "sha256:d871dadfb37b53ef1ca45be04fc527562b91989991a8f545345ae3be0b93f92a",
},
@@ -1481,7 +1481,7 @@ func TestMarshaler_MarshalVulnerabilities(t *testing.T) {
VulnerabilityID: "CVE-2018-20623",
PkgName: "binutils",
InstalledVersion: "2.30-93.el8",
Ref: "pkg:rpm/centos/binutils@2.30-93.el8?arch=aarch64&distro=centos-8.3.2011",
PkgRef: "pkg:rpm/centos/binutils@2.30-93.el8?arch=aarch64&distro=centos-8.3.2011",
Layer: ftypes.Layer{
DiffID: "sha256:d871dadfb37b53ef1ca45be04fc527562b91989991a8f545345ae3be0b93f92a",
},

View File

@@ -3,6 +3,7 @@ package cyclonedx
import (
"bytes"
"errors"
"io"
"sort"
"strconv"
"strings"
@@ -29,14 +30,22 @@ type CycloneDX struct {
components map[string]cdx.Component
}
func DecodeJSON(r io.Reader) (*cdx.BOM, error) {
bom := cdx.NewBOM()
decoder := cdx.NewBOMDecoder(r, cdx.BOMFileFormatJSON)
if err := decoder.Decode(bom); err != nil {
return nil, xerrors.Errorf("CycloneDX decode error: %w", err)
}
return bom, nil
}
func (c *CycloneDX) UnmarshalJSON(b []byte) error {
log.Logger.Debug("Unmarshaling CycloneDX JSON...")
if c.SBOM == nil {
c.SBOM = &types.SBOM{}
}
bom := cdx.NewBOM()
decoder := cdx.NewBOMDecoder(bytes.NewReader(b), cdx.BOMFileFormatJSON)
if err := decoder.Decode(bom); err != nil {
bom, err := DecodeJSON(bytes.NewReader(b))
if err != nil {
return xerrors.Errorf("CycloneDX decode error: %w", err)
}
@@ -45,7 +54,7 @@ func (c *CycloneDX) UnmarshalJSON(b []byte) error {
log.Logger.Warnf("Recommend using Trivy to generate SBOMs")
}
if err := c.parseSBOM(bom); err != nil {
if err = c.parseSBOM(bom); err != nil {
return xerrors.Errorf("failed to parse sbom: %w", err)
}

View File

@@ -40,12 +40,7 @@ const (
var ErrUnknownFormat = xerrors.New("Unknown SBOM format")
func DetectFormat(r io.ReadSeeker) (Format, error) {
// Rewind the SBOM file at the end
defer r.Seek(0, io.SeekStart)
type (
cyclonedx struct {
type cdxHeader struct {
// XML specific field
XMLNS string `json:"-" xml:"xmlns,attr"`
@@ -53,52 +48,95 @@ func DetectFormat(r io.ReadSeeker) (Format, error) {
BOMFormat string `json:"bomFormat" xml:"-"`
}
spdx struct {
type spdxHeader struct {
SpdxID string `json:"SPDXID"`
}
)
// Try CycloneDX JSON
var cdxBom cyclonedx
func IsCycloneDXJSON(r io.ReadSeeker) (bool, error) {
if _, err := r.Seek(0, io.SeekStart); err != nil {
return false, xerrors.Errorf("seek error: %w", err)
}
var cdxBom cdxHeader
if err := json.NewDecoder(r).Decode(&cdxBom); err == nil {
if cdxBom.BOMFormat == "CycloneDX" {
return FormatCycloneDXJSON, nil
return true, nil
}
}
return false, nil
}
func IsCycloneDXXML(r io.ReadSeeker) (bool, error) {
if _, err := r.Seek(0, io.SeekStart); err != nil {
return false, xerrors.Errorf("seek error: %w", err)
}
var cdxBom cdxHeader
if err := xml.NewDecoder(r).Decode(&cdxBom); err == nil {
if strings.HasPrefix(cdxBom.XMLNS, "http://cyclonedx.org") {
return true, nil
}
}
return false, nil
}
func IsSPDXJSON(r io.ReadSeeker) (bool, error) {
if _, err := r.Seek(0, io.SeekStart); err != nil {
return FormatUnknown, xerrors.Errorf("seek error: %w", err)
return false, xerrors.Errorf("seek error: %w", err)
}
var spdxBom spdxHeader
if err := json.NewDecoder(r).Decode(&spdxBom); err == nil {
if strings.HasPrefix(spdxBom.SpdxID, "SPDX") {
return true, nil
}
}
return false, nil
}
func IsSPDXTV(r io.ReadSeeker) (bool, error) {
if _, err := r.Seek(0, io.SeekStart); err != nil {
return false, xerrors.Errorf("seek error: %w", err)
}
if scanner := bufio.NewScanner(r); scanner.Scan() {
if strings.HasPrefix(scanner.Text(), "SPDX") {
return true, nil
}
}
return false, nil
}
func DetectFormat(r io.ReadSeeker) (Format, error) {
// Rewind the SBOM file at the end
defer r.Seek(0, io.SeekStart)
// Try CycloneDX JSON
if ok, err := IsCycloneDXJSON(r); err != nil {
return FormatUnknown, err
} else if ok {
return FormatCycloneDXJSON, nil
}
// Try CycloneDX XML
if err := xml.NewDecoder(r).Decode(&cdxBom); err == nil {
if strings.HasPrefix(cdxBom.XMLNS, "http://cyclonedx.org") {
if ok, err := IsCycloneDXXML(r); err != nil {
return FormatUnknown, err
} else if ok {
return FormatCycloneDXXML, nil
}
}
if _, err := r.Seek(0, io.SeekStart); err != nil {
return FormatUnknown, xerrors.Errorf("seek error: %w", err)
}
// Try SPDX json
var spdxBom spdx
if err := json.NewDecoder(r).Decode(&spdxBom); err == nil {
if strings.HasPrefix(spdxBom.SpdxID, "SPDX") {
if ok, err := IsSPDXJSON(r); err != nil {
return FormatUnknown, err
} else if ok {
return FormatSPDXJSON, nil
}
}
if _, err := r.Seek(0, io.SeekStart); err != nil {
return FormatUnknown, xerrors.Errorf("seek error: %w", err)
}
// Try SPDX tag-value
if scanner := bufio.NewScanner(r); scanner.Scan() {
if strings.HasPrefix(scanner.Text(), "SPDX") {
if ok, err := IsSPDXTV(r); err != nil {
return FormatUnknown, err
} else if ok {
return FormatSPDXTV, nil
}
}
if _, err := r.Seek(0, io.SeekStart); err != nil {
return FormatUnknown, xerrors.Errorf("seek error: %w", err)

View File

@@ -11,13 +11,19 @@ type DetectedVulnerability struct {
VendorIDs []string `json:",omitempty"`
PkgID string `json:",omitempty"` // It is used to construct dependency graph.
PkgName string `json:",omitempty"`
PkgPath string `json:",omitempty"` // It will be filled in the case of language-specific packages such as egg/wheel and gemspec
PkgPath string `json:",omitempty"` // This field is populated in the case of language-specific packages such as egg/wheel and gemspec
InstalledVersion string `json:",omitempty"`
FixedVersion string `json:",omitempty"`
Layer ftypes.Layer `json:",omitempty"`
SeveritySource types.SourceID `json:",omitempty"`
PrimaryURL string `json:",omitempty"`
Ref string `json:",omitempty"`
// PkgRef is populated only when scanning SBOM and contains the reference ID used in the SBOM.
// It could be PURL, UUID, etc.
// e.g.
// - pkg:npm/acme/component@1.0.0
// - b2a46a4b-8367-4bae-9820-95557cfe03a8
PkgRef string `json:",omitempty"`
// DataSource holds where the advisory comes from
DataSource *types.DataSource `json:",omitempty"`

11
pkg/vex/status.go Normal file
View File

@@ -0,0 +1,11 @@
package vex
type Status string
const (
StatusNotAffected Status = "not_affected"
StatusAffected Status = "affected"
StatusFixed Status = "fixed"
StatusUnderInvestigation Status = "under_investigation"
StatusUnknown Status = "unknown"
)

23
pkg/vex/testdata/cyclonedx.json vendored Normal file
View File

@@ -0,0 +1,23 @@
{
"bomFormat": "CycloneDX",
"specVersion": "1.4",
"version": 1,
"vulnerabilities": [
{
"id": "CVE-2018-7489",
"source": {
"name": "NVD",
"url": "https://nvd.nist.gov/vuln/detail/CVE-2019-9997"
},
"analysis": {
"state": "not_affected",
"justification": "code_not_reachable"
},
"affects": [
{
"ref": "urn:cdx:3e671687-395b-41f5-a30f-a58921a69b79/1#jackson-databind-2.8.0"
}
]
}
]
}

17
pkg/vex/testdata/openvex.cdx.json vendored Normal file
View File

@@ -0,0 +1,17 @@
{
"@context": "https://openvex.dev/ns",
"author": "Aqua Security",
"role": "Project Release Bot",
"timestamp": "2023-01-16T19:07:16.853479631-06:00",
"version": "1",
"statements": [
{
"vulnerability": "CVE-2018-7489",
"products": [
"urn:cdx:3e671687-395b-41f5-a30f-a58921a69b79/1#jackson-databind-2.8.0"
],
"status": "not_affected",
"justification": "vulnerable_code_not_in_execute_path"
}
]
}

17
pkg/vex/testdata/openvex.json vendored Normal file
View File

@@ -0,0 +1,17 @@
{
"@context": "https://openvex.dev/ns",
"author": "Aqua Security",
"role": "Project Release Bot",
"timestamp": "2023-01-16T19:07:16.853479631-06:00",
"version": "1",
"statements": [
{
"vulnerability": "CVE-2021-44228",
"products": [
"pkg:maven/org.springframework.boot/spring-boot@2.6.0"
],
"status": "not_affected",
"justification": "vulnerable_code_not_in_execute_path"
}
]
}

1
pkg/vex/testdata/unknown.json vendored Normal file
View File

@@ -0,0 +1 @@
{unknown}

233
pkg/vex/vex.go Normal file
View File

@@ -0,0 +1,233 @@
package vex
import (
"encoding/json"
"io"
"os"
cdx "github.com/CycloneDX/cyclonedx-go"
"github.com/hashicorp/go-multierror"
openvex "github.com/openvex/go-vex/pkg/vex"
"github.com/samber/lo"
"github.com/sirupsen/logrus"
"go.uber.org/zap"
"golang.org/x/exp/slices"
"golang.org/x/xerrors"
ftypes "github.com/aquasecurity/trivy/pkg/fanal/types"
"github.com/aquasecurity/trivy/pkg/log"
"github.com/aquasecurity/trivy/pkg/sbom"
"github.com/aquasecurity/trivy/pkg/sbom/cyclonedx"
"github.com/aquasecurity/trivy/pkg/types"
)
// VEX represents Vulnerability Exploitability eXchange. It abstracts multiple VEX formats.
// Note: This is in the experimental stage and does not yet support many specifications.
// The implementation may change significantly.
type VEX interface {
Filter([]types.DetectedVulnerability) []types.DetectedVulnerability
}
type Statement struct {
VulnerabilityID string
Affects []string
Status Status
Justification string // TODO: define a type
}
type OpenVEX struct {
statements []Statement
logger *zap.SugaredLogger
}
func newOpenVEX(cycloneDX *ftypes.CycloneDX, vex openvex.VEX) VEX {
logger := log.Logger.With(zap.String("VEX format", "OpenVEX"))
openvex.SortStatements(vex.Statements, lo.FromPtr(vex.Timestamp))
// Convert openvex.Statement to Statement
stmts := lo.Map(vex.Statements, func(stmt openvex.Statement, index int) Statement {
return Statement{
// TODO: add subcomponents, etc.
VulnerabilityID: stmt.Vulnerability,
Affects: stmt.Products,
Status: Status(stmt.Status),
Justification: string(stmt.Justification),
}
})
// Reverse sorted statements so that the latest statement can come first.
stmts = lo.Reverse(stmts)
// If the SBOM format referenced by OpenVEX is CycloneDX
if cycloneDX != nil {
return &CycloneDX{
sbom: cycloneDX,
statements: stmts,
logger: logger,
}
}
return &OpenVEX{
statements: stmts,
logger: logger,
}
}
func (v *OpenVEX) Filter(vulns []types.DetectedVulnerability) []types.DetectedVulnerability {
return lo.Filter(vulns, func(vuln types.DetectedVulnerability, _ int) bool {
stmt, ok := lo.Find(v.statements, func(item Statement) bool {
return item.VulnerabilityID == vuln.VulnerabilityID
})
if !ok {
return true
}
return v.affected(vuln, stmt)
})
}
func (v *OpenVEX) affected(vuln types.DetectedVulnerability, stmt Statement) bool {
if slices.Contains(stmt.Affects, vuln.PkgRef) &&
(stmt.Status == StatusNotAffected || stmt.Status == StatusFixed) {
v.logger.Infow("Filtered out the detected vulnerability", zap.String("vulnerability-id", vuln.VulnerabilityID),
zap.String("status", string(stmt.Status)), zap.String("justification", stmt.Justification))
return false
}
return true
}
type CycloneDX struct {
sbom *ftypes.CycloneDX
statements []Statement
logger *zap.SugaredLogger
}
func newCycloneDX(sbom *ftypes.CycloneDX, vex *cdx.BOM) *CycloneDX {
var stmts []Statement
for _, vuln := range lo.FromPtr(vex.Vulnerabilities) {
affects := lo.Map(lo.FromPtr(vuln.Affects), func(item cdx.Affects, index int) string {
return item.Ref
})
analysis := lo.FromPtr(vuln.Analysis)
stmts = append(stmts, Statement{
VulnerabilityID: vuln.ID,
Affects: affects,
Status: cdxStatus(analysis.State),
Justification: string(analysis.Justification),
})
}
return &CycloneDX{
sbom: sbom,
statements: stmts,
logger: log.Logger.With(zap.String("VEX format", "CycloneDX")),
}
}
func (v *CycloneDX) Filter(vulns []types.DetectedVulnerability) []types.DetectedVulnerability {
return lo.Filter(vulns, func(vuln types.DetectedVulnerability, _ int) bool {
stmt, ok := lo.Find(v.statements, func(item Statement) bool {
return item.VulnerabilityID == vuln.VulnerabilityID
})
if !ok {
return true
}
return v.affected(vuln, stmt)
})
}
func (v *CycloneDX) affected(vuln types.DetectedVulnerability, stmt Statement) bool {
for _, affect := range stmt.Affects {
// Affect must be BOM-Link at the moment
link, err := cdx.ParseBOMLink(affect)
if err != nil {
v.logger.Warnw("Unable to parse BOM-Link", zap.String("affect", affect))
continue
}
if v.sbom.SerialNumber != link.SerialNumber() || v.sbom.Version != link.Version() {
v.logger.Warnw("URN doesn't match with SBOM", zap.String("serial number", link.SerialNumber()),
zap.Int("version", link.Version()))
continue
}
if vuln.PkgRef == link.Reference() &&
(stmt.Status == StatusNotAffected || stmt.Status == StatusFixed) {
v.logger.Infow("Filtered out the detected vulnerability", zap.String("vulnerability-id", vuln.VulnerabilityID),
zap.String("status", string(stmt.Status)), zap.String("justification", stmt.Justification))
return false
}
}
return true
}
func cdxStatus(s cdx.ImpactAnalysisState) Status {
switch s {
case cdx.IASResolved, cdx.IASResolvedWithPedigree:
return StatusFixed
case cdx.IASExploitable:
return StatusAffected
case cdx.IASInTriage:
return StatusUnderInvestigation
case cdx.IASFalsePositive, cdx.IASNotAffected:
return StatusNotAffected
}
return StatusUnknown
}
func New(filePath string, report types.Report) (VEX, error) {
if filePath == "" {
return nil, nil
}
f, err := os.Open(filePath)
if err != nil {
return nil, xerrors.Errorf("file open error: %w", err)
}
defer f.Close()
var errs error
// Try CycloneDX JSON
if ok, err := sbom.IsCycloneDXJSON(f); err != nil {
errs = multierror.Append(errs, err)
} else if ok {
return decodeCycloneDXJSON(f, report)
}
// Try OpenVEX
if v, err := decodeOpenVEX(f, report); err != nil {
errs = multierror.Append(errs, err)
} else {
return v, nil
}
return nil, xerrors.Errorf("unable to load VEX: %w", errs)
}
func decodeCycloneDXJSON(r io.ReadSeeker, report types.Report) (VEX, error) {
if _, err := r.Seek(0, io.SeekStart); err != nil {
return nil, xerrors.Errorf("seek error: %w", err)
}
vex, err := cyclonedx.DecodeJSON(r)
if err != nil {
return nil, xerrors.Errorf("json decode error: %w", err)
}
if report.CycloneDX == nil {
return nil, xerrors.New("CycloneDX VEX can be used with CycloneDX SBOM")
}
return newCycloneDX(report.CycloneDX, vex), nil
}
func decodeOpenVEX(r io.ReadSeeker, report types.Report) (VEX, error) {
// openvex/go-vex outputs log messages by default
logrus.SetOutput(io.Discard)
if _, err := r.Seek(0, io.SeekStart); err != nil {
return nil, xerrors.Errorf("seek error: %w", err)
}
var openVEX openvex.VEX
if err := json.NewDecoder(r).Decode(&openVEX); err != nil {
return nil, err
}
if openVEX.Context == "" {
return nil, nil
}
return newOpenVEX(report.CycloneDX, openVEX), nil
}

156
pkg/vex/vex_test.go Normal file
View File

@@ -0,0 +1,156 @@
package vex_test
import (
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
ftypes "github.com/aquasecurity/trivy/pkg/fanal/types"
"github.com/aquasecurity/trivy/pkg/types"
"github.com/aquasecurity/trivy/pkg/vex"
)
func TestVEX_Filter(t *testing.T) {
type fields struct {
filePath string
report types.Report
}
type args struct {
vulns []types.DetectedVulnerability
}
tests := []struct {
name string
fields fields
args args
want []types.DetectedVulnerability
}{
{
name: "OpenVEX",
fields: fields{
filePath: "testdata/openvex.json",
},
args: args{
vulns: []types.DetectedVulnerability{
{
VulnerabilityID: "CVE-2021-44228",
PkgName: "spring-boot",
InstalledVersion: "2.6.0",
PkgRef: "pkg:maven/org.springframework.boot/spring-boot@2.6.0",
},
},
},
want: []types.DetectedVulnerability{},
},
{
name: "CycloneDX SBOM with OpenVEX",
fields: fields{
filePath: "testdata/openvex.cdx.json",
report: types.Report{
CycloneDX: &ftypes.CycloneDX{
SerialNumber: "urn:uuid:3e671687-395b-41f5-a30f-a58921a69b79",
Version: 1,
},
},
},
args: args{
vulns: []types.DetectedVulnerability{
{
VulnerabilityID: "CVE-2018-7489",
PkgName: "jackson-databind",
InstalledVersion: "2.8.0",
PkgRef: "jackson-databind-2.8.0",
},
{
VulnerabilityID: "CVE-2018-7490",
PkgName: "jackson-databind",
InstalledVersion: "2.8.0",
PkgRef: "jackson-databind-2.8.0",
},
},
},
want: []types.DetectedVulnerability{
{
VulnerabilityID: "CVE-2018-7490",
PkgName: "jackson-databind",
InstalledVersion: "2.8.0",
PkgRef: "jackson-databind-2.8.0",
},
},
},
{
name: "CycloneDX SBOM with CycloneDX VEX",
fields: fields{
filePath: "testdata/cyclonedx.json",
report: types.Report{
CycloneDX: &ftypes.CycloneDX{
SerialNumber: "urn:uuid:3e671687-395b-41f5-a30f-a58921a69b79",
Version: 1,
},
},
},
args: args{
vulns: []types.DetectedVulnerability{
{
VulnerabilityID: "CVE-2018-7489",
PkgName: "jackson-databind",
InstalledVersion: "2.8.0",
PkgRef: "jackson-databind-2.8.0",
},
{
VulnerabilityID: "CVE-2018-7490",
PkgName: "jackson-databind",
InstalledVersion: "2.8.0",
PkgRef: "jackson-databind-2.8.0",
},
},
},
want: []types.DetectedVulnerability{
{
VulnerabilityID: "CVE-2018-7490",
PkgName: "jackson-databind",
InstalledVersion: "2.8.0",
PkgRef: "jackson-databind-2.8.0",
},
},
},
{
name: "CycloneDX VEX wrong URN",
fields: fields{
filePath: "testdata/cyclonedx.json",
report: types.Report{
CycloneDX: &ftypes.CycloneDX{
SerialNumber: "urn:uuid:wrong",
Version: 1,
},
},
},
args: args{
vulns: []types.DetectedVulnerability{
{
VulnerabilityID: "CVE-2018-7489",
PkgName: "jackson-databind",
InstalledVersion: "2.8.0",
PkgRef: "jackson-databind-2.8.0",
},
},
},
want: []types.DetectedVulnerability{
{
VulnerabilityID: "CVE-2018-7489",
PkgName: "jackson-databind",
InstalledVersion: "2.8.0",
PkgRef: "jackson-databind-2.8.0",
},
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
v, err := vex.New(tt.fields.filePath, tt.fields.report)
require.NoError(t, err)
assert.Equal(t, tt.want, v.Filter(tt.args.vulns))
})
}
}