Skip to content

Commit 7a14808

Browse files
feat(cyclonedx): add vulnerabilities (#1832)
Co-authored-by: knqyf263 <knqyf263@gmail.com>
1 parent df80fd3 commit 7a14808

File tree

15 files changed

+792
-113
lines changed

15 files changed

+792
-113
lines changed

docs/advanced/sbom/cyclonedx.md

Lines changed: 61 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ $ trivy image --format cyclonedx --output result.json alpine:3.15
1616
$ cat result.json | jq .
1717
{
1818
"bomFormat": "CycloneDX",
19-
"specVersion": "1.3",
19+
"specVersion": "1.4",
2020
"serialNumber": "urn:uuid:2be5773d-7cd3-4b4b-90a5-e165474ddace",
2121
"version": 1,
2222
"metadata": {
@@ -163,13 +163,70 @@ $ cat result.json | jq .
163163
"3da6a469-964d-4b4e-b67d-e94ec7c88d37"
164164
]
165165
}
166+
],
167+
"vulnerabilities": [
168+
{
169+
"id": "CVE-2021-42386",
170+
"source": {
171+
"name": "alpine",
172+
"url": "https://secdb.alpinelinux.org/"
173+
},
174+
"ratings": [
175+
{
176+
"source": {
177+
"name": "nvd"
178+
},
179+
"score": 7.2,
180+
"severity": "high",
181+
"method": "CVSSv31",
182+
"vector": "CVSS:3.1/AV:N/AC:L/PR:H/UI:N/S:U/C:H/I:H/A:H"
183+
},
184+
{
185+
"source": {
186+
"name": "nvd"
187+
},
188+
"score": 6.5,
189+
"severity": "medium",
190+
"method": "CVSSv2",
191+
"vector": "AV:N/AC:L/Au:S/C:P/I:P/A:P"
192+
},
193+
{
194+
"source": {
195+
"name": "redhat"
196+
},
197+
"score": 6.6,
198+
"severity": "medium",
199+
"method": "CVSSv31",
200+
"vector": "CVSS:3.1/AV:N/AC:H/PR:H/UI:N/S:U/C:H/I:H/A:H"
201+
}
202+
],
203+
"cwes": [
204+
416
205+
],
206+
"description": "A use-after-free in Busybox's awk applet leads to denial of service and possibly code execution when processing a crafted awk pattern in the nvalloc function",
207+
"advisories": [
208+
{
209+
"url": "https://access.redhat.com/security/cve/CVE-2021-42386"
210+
},
211+
{
212+
"url": "https://cve.mitre.org/cgi-bin/cvename.cgi?name=CVE-2021-42386"
213+
}
214+
],
215+
"published": "2021-11-15 21:15:00 +0000 UTC",
216+
"updated": "2022-01-04 17:14:00 +0000 UTC",
217+
"affects": [
218+
{
219+
"ref": "pkg:apk/alpine/busybox@1.33.1-r3?distro=3.14.2"
220+
},
221+
{
222+
"ref": "pkg:apk/alpine/ssl_client@1.33.1-r3?distro=3.14.2"
223+
}
224+
]
225+
}
166226
]
167227
}
168228
169229
```
170230
</details>
171231

172-
!!! caution
173-
It doesn't support vulnerabilities yet, but installed packages.
174-
175232
[cyclonedx]: https://cyclonedx.org/

pkg/report/cyclonedx/cyclonedx.go

Lines changed: 200 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2,16 +2,23 @@ package cyclonedx
22

33
import (
44
"io"
5+
"sort"
56
"strconv"
7+
"strings"
68
"time"
79

810
cdx "github.com/CycloneDX/cyclonedx-go"
911
"github.com/google/uuid"
12+
"golang.org/x/exp/maps"
1013
"golang.org/x/xerrors"
1114
"k8s.io/utils/clock"
1215

1316
ftypes "github.com/aquasecurity/fanal/types"
17+
dtypes "github.com/aquasecurity/trivy-db/pkg/types"
18+
"github.com/aquasecurity/trivy-db/pkg/vulnsrc/vulnerability"
19+
"github.com/aquasecurity/trivy/pkg/log"
1420
"github.com/aquasecurity/trivy/pkg/purl"
21+
"github.com/aquasecurity/trivy/pkg/scanner/utils"
1522
"github.com/aquasecurity/trivy/pkg/types"
1623
)
1724

@@ -127,25 +134,30 @@ func (cw *Writer) convertToBom(r types.Report, version string) (*cdx.BOM, error)
127134
Component: metadataComponent,
128135
}
129136

130-
bom.Components, bom.Dependencies, err = cw.parseComponents(r, bom.Metadata.Component.BOMRef)
137+
bom.Components, bom.Dependencies, bom.Vulnerabilities, err = cw.parseComponents(r, bom.Metadata.Component.BOMRef)
131138
if err != nil {
132139
return nil, xerrors.Errorf("failed to parse components: %w", err)
133140
}
134141

135142
return bom, nil
136143
}
137144

138-
func (cw *Writer) parseComponents(r types.Report, bomRef string) (*[]cdx.Component, *[]cdx.Dependency, error) {
145+
func (cw *Writer) parseComponents(r types.Report, bomRef string) (*[]cdx.Component, *[]cdx.Dependency, *[]cdx.Vulnerability, error) {
139146
var components []cdx.Component
140147
var dependencies []cdx.Dependency
141148
var metadataDependencies []cdx.Dependency
142149
libraryUniqMap := map[string]struct{}{}
150+
vulnMap := map[string]cdx.Vulnerability{}
143151
for _, result := range r.Results {
144152
var componentDependencies []cdx.Dependency
153+
bomRefMap := map[string]string{}
145154
for _, pkg := range result.Packages {
146155
pkgComponent, err := cw.pkgToComponent(result.Type, r.Metadata, pkg)
147156
if err != nil {
148-
return nil, nil, xerrors.Errorf("failed to parse pkg: %w", err)
157+
return nil, nil, nil, xerrors.Errorf("failed to parse pkg: %w", err)
158+
}
159+
if _, ok := bomRefMap[pkg.Name+utils.FormatVersion(pkg)+pkg.FilePath]; !ok {
160+
bomRefMap[pkg.Name+utils.FormatVersion(pkg)+pkg.FilePath] = pkgComponent.BOMRef
149161
}
150162

151163
// When multiple lock files have the same dependency with the same name and version,
@@ -171,6 +183,20 @@ func (cw *Writer) parseComponents(r types.Report, bomRef string) (*[]cdx.Compone
171183

172184
componentDependencies = append(componentDependencies, cdx.Dependency{Ref: pkgComponent.BOMRef})
173185
}
186+
for _, vuln := range result.Vulnerabilities {
187+
// Take a bom-ref
188+
ref := bomRefMap[vuln.PkgName+vuln.InstalledVersion+vuln.PkgPath]
189+
if v, ok := vulnMap[vuln.VulnerabilityID]; ok {
190+
// If a vulnerability depends on multiple packages,
191+
// it will be commonised into a single vulnerability.
192+
// Vulnerability component (CVE-2020-26247)
193+
// -> Library component (nokogiri /srv/app1/vendor/bundle/ruby/3.0.0/specifications/nokogiri-1.10.0.gemspec)
194+
// -> Library component (nokogiri /srv/app2/vendor/bundle/ruby/3.0.0/specifications/nokogiri-1.10.0.gemspec)
195+
*v.Affects = append(*v.Affects, affects(ref, vuln.InstalledVersion))
196+
} else {
197+
vulnMap[vuln.VulnerabilityID] = cw.vulnerability(vuln, ref)
198+
}
199+
}
174200

175201
if result.Type == ftypes.NodePkg || result.Type == ftypes.PythonPkg || result.Type == ftypes.GoBinary ||
176202
result.Type == ftypes.GemSpec || result.Type == ftypes.Jar {
@@ -215,11 +241,36 @@ func (cw *Writer) parseComponents(r types.Report, bomRef string) (*[]cdx.Compone
215241
metadataDependencies = append(metadataDependencies, cdx.Dependency{Ref: resultComponent.BOMRef})
216242
}
217243
}
244+
vulns := maps.Values(vulnMap)
245+
sort.Slice(vulns, func(i, j int) bool {
246+
return vulns[i].ID > vulns[j].ID
247+
})
218248

219249
dependencies = append(dependencies,
220250
cdx.Dependency{Ref: bomRef, Dependencies: &metadataDependencies},
221251
)
222-
return &components, &dependencies, nil
252+
return &components, &dependencies, &vulns, nil
253+
}
254+
255+
func (cw *Writer) vulnerability(vuln types.DetectedVulnerability, bomRef string) cdx.Vulnerability {
256+
v := cdx.Vulnerability{
257+
ID: vuln.VulnerabilityID,
258+
Source: source(vuln.DataSource),
259+
Ratings: ratings(vuln),
260+
CWEs: cwes(vuln.CweIDs),
261+
Description: vuln.Description,
262+
Advisories: advisories(vuln.References),
263+
}
264+
if vuln.PublishedDate != nil {
265+
v.Published = vuln.PublishedDate.String()
266+
}
267+
if vuln.LastModifiedDate != nil {
268+
v.Updated = vuln.LastModifiedDate.String()
269+
}
270+
271+
v.Affects = &[]cdx.Affects{affects(bomRef, vuln.InstalledVersion)}
272+
273+
return v
223274
}
224275

225276
func (cw *Writer) pkgToComponent(t string, meta types.Metadata, pkg ftypes.Package) (cdx.Component, error) {
@@ -363,3 +414,148 @@ func property(key, value string) cdx.Property {
363414
Value: value,
364415
}
365416
}
417+
418+
func advisories(refs []string) *[]cdx.Advisory {
419+
var advs []cdx.Advisory
420+
for _, ref := range refs {
421+
advs = append(advs, cdx.Advisory{
422+
URL: ref,
423+
})
424+
}
425+
return &advs
426+
}
427+
428+
func cwes(cweIDs []string) *[]int {
429+
var ret []int
430+
for _, cweID := range cweIDs {
431+
number, err := strconv.Atoi(strings.TrimPrefix(strings.ToLower(cweID), "cwe-"))
432+
if err != nil {
433+
log.Logger.Debugf("cwe id parse error: %s", err)
434+
continue
435+
}
436+
ret = append(ret, number)
437+
}
438+
return &ret
439+
}
440+
441+
func ratings(vulnerability types.DetectedVulnerability) *[]cdx.VulnerabilityRating {
442+
var rates []cdx.VulnerabilityRating
443+
for sourceID, severity := range vulnerability.VendorSeverity {
444+
// When the vendor also provides CVSS score/vector
445+
if cvss, ok := vulnerability.CVSS[sourceID]; ok {
446+
if cvss.V2Score != 0 || cvss.V2Vector != "" {
447+
rates = append(rates, ratingV2(sourceID, severity, cvss))
448+
}
449+
if cvss.V3Score != 0 || cvss.V3Vector != "" {
450+
rates = append(rates, ratingV3(sourceID, severity, cvss))
451+
}
452+
} else { // When the vendor provides only severity
453+
rate := cdx.VulnerabilityRating{
454+
Source: &cdx.Source{
455+
Name: string(sourceID),
456+
},
457+
Severity: toCDXSeverity(severity),
458+
}
459+
rates = append(rates, rate)
460+
}
461+
}
462+
463+
// For consistency
464+
sort.Slice(rates, func(i, j int) bool {
465+
if rates[i].Source.Name != rates[j].Source.Name {
466+
return rates[i].Source.Name < rates[j].Source.Name
467+
}
468+
if rates[i].Method != rates[j].Method {
469+
return rates[i].Method < rates[j].Method
470+
}
471+
return rates[i].Score < rates[j].Score
472+
})
473+
return &rates
474+
}
475+
476+
func ratingV2(sourceID dtypes.SourceID, severity dtypes.Severity, cvss dtypes.CVSS) cdx.VulnerabilityRating {
477+
cdxSeverity := toCDXSeverity(severity)
478+
479+
// Trivy keeps only CVSSv3 severity for NVD.
480+
// The CVSSv2 severity must be calculated according to CVSSv2 score.
481+
if sourceID == vulnerability.NVD {
482+
cdxSeverity = nvdSeverityV2(cvss.V2Score)
483+
}
484+
return cdx.VulnerabilityRating{
485+
Source: &cdx.Source{
486+
Name: string(sourceID),
487+
},
488+
Score: cvss.V2Score,
489+
Method: cdx.ScoringMethodCVSSv2,
490+
Severity: cdxSeverity,
491+
Vector: cvss.V2Vector,
492+
}
493+
}
494+
495+
func nvdSeverityV2(score float64) cdx.Severity {
496+
// cf. https://nvd.nist.gov/vuln-metrics/cvss
497+
switch {
498+
case score < 4.0:
499+
return cdx.SeverityInfo
500+
case 4.0 <= score && score < 7.0:
501+
return cdx.SeverityMedium
502+
case 7.0 <= score:
503+
return cdx.SeverityHigh
504+
}
505+
return cdx.SeverityUnknown
506+
}
507+
508+
func ratingV3(sourceID dtypes.SourceID, severity dtypes.Severity, cvss dtypes.CVSS) cdx.VulnerabilityRating {
509+
rate := cdx.VulnerabilityRating{
510+
Source: &cdx.Source{
511+
Name: string(sourceID),
512+
},
513+
Score: cvss.V3Score,
514+
Method: cdx.ScoringMethodCVSSv3,
515+
Severity: toCDXSeverity(severity),
516+
Vector: cvss.V3Vector,
517+
}
518+
if strings.HasPrefix(cvss.V3Vector, "CVSS:3.1") {
519+
rate.Method = cdx.ScoringMethodCVSSv31
520+
}
521+
return rate
522+
}
523+
524+
func toCDXSeverity(s dtypes.Severity) cdx.Severity {
525+
switch s {
526+
case dtypes.SeverityLow:
527+
return cdx.SeverityLow
528+
case dtypes.SeverityMedium:
529+
return cdx.SeverityMedium
530+
case dtypes.SeverityHigh:
531+
return cdx.SeverityHigh
532+
case dtypes.SeverityCritical:
533+
return cdx.SeverityCritical
534+
default:
535+
return cdx.SeverityUnknown
536+
}
537+
}
538+
539+
func source(source *dtypes.DataSource) *cdx.Source {
540+
if source == nil {
541+
return nil
542+
}
543+
544+
return &cdx.Source{
545+
Name: string(source.ID),
546+
URL: source.URL,
547+
}
548+
}
549+
550+
func affects(ref, version string) cdx.Affects {
551+
return cdx.Affects{
552+
Ref: ref,
553+
Range: &[]cdx.AffectedVersions{
554+
{
555+
Version: version,
556+
Status: cdx.VulnerabilityStatusAffected,
557+
// "AffectedVersions.Range" is not included, because it does not exist in DetectedVulnerability.
558+
},
559+
},
560+
}
561+
}

0 commit comments

Comments
 (0)