github.com/devseccon/trivy@v0.47.1-0.20231123133102-bd902a0bd996/pkg/sbom/cyclonedx/core/cyclonedx.go (about) 1 package core 2 3 import ( 4 "fmt" 5 "sort" 6 "strconv" 7 "strings" 8 9 cdx "github.com/CycloneDX/cyclonedx-go" 10 "github.com/samber/lo" 11 "golang.org/x/exp/slices" 12 13 dtypes "github.com/aquasecurity/trivy-db/pkg/types" 14 "github.com/aquasecurity/trivy-db/pkg/vulnsrc/vulnerability" 15 "github.com/devseccon/trivy/pkg/clock" 16 "github.com/devseccon/trivy/pkg/digest" 17 "github.com/devseccon/trivy/pkg/log" 18 "github.com/devseccon/trivy/pkg/purl" 19 "github.com/devseccon/trivy/pkg/types" 20 "github.com/devseccon/trivy/pkg/uuid" 21 ) 22 23 const ( 24 ToolVendor = "aquasecurity" 25 ToolName = "trivy" 26 Namespace = ToolVendor + ":" + ToolName + ":" 27 28 // https://json-schema.org/understanding-json-schema/reference/string.html#dates-and-times 29 timeLayout = "2006-01-02T15:04:05+00:00" 30 ) 31 32 type CycloneDX struct { 33 appVersion string 34 } 35 36 type Component struct { 37 Type cdx.ComponentType 38 Name string 39 Group string 40 Version string 41 PackageURL *purl.PackageURL 42 Licenses []string 43 Hashes []digest.Digest 44 Supplier string 45 Properties []Property 46 47 Components []*Component 48 Vulnerabilities []types.DetectedVulnerability 49 } 50 51 type Property struct { 52 Name string 53 Value string 54 Namespace string 55 } 56 57 func NewCycloneDX(version string) *CycloneDX { 58 return &CycloneDX{ 59 appVersion: version, 60 } 61 } 62 63 func (c *CycloneDX) Marshal(root *Component) *cdx.BOM { 64 bom := cdx.NewBOM() 65 bom.SerialNumber = uuid.New().URN() 66 bom.Metadata = c.Metadata() 67 68 components := make(map[string]*cdx.Component) 69 dependencies := make(map[string]*[]string) 70 vulnerabilities := make(map[string]*cdx.Vulnerability) 71 bom.Metadata.Component = c.MarshalComponent(root, components, dependencies, vulnerabilities) 72 73 // Remove metadata component 74 delete(components, bom.Metadata.Component.BOMRef) 75 76 bom.Components = c.Components(components) 77 bom.Dependencies = c.Dependencies(dependencies) 78 bom.Vulnerabilities = c.Vulnerabilities(vulnerabilities) 79 80 return bom 81 } 82 83 func (c *CycloneDX) MarshalComponent(component *Component, components map[string]*cdx.Component, 84 deps map[string]*[]string, vulns map[string]*cdx.Vulnerability) *cdx.Component { 85 bomRef := c.BOMRef(component) 86 87 // When multiple lock files have the same dependency with the same name and version, 88 // "BOM-Ref" (PURL technically) of "Library" components may conflict. 89 // In that case, only one "Library" component will be added and 90 // some "Application" components will refer to the same component. 91 // e.g. 92 // Application component (/app1/package-lock.json) 93 // | 94 // | Application component (/app2/package-lock.json) 95 // | | 96 // └----┴----> Library component (npm package, express-4.17.3) 97 // 98 if v, ok := components[bomRef]; ok { 99 return v 100 } 101 102 cdxComponent := &cdx.Component{ 103 BOMRef: bomRef, 104 Type: component.Type, 105 Name: component.Name, 106 Group: component.Group, 107 Version: component.Version, 108 PackageURL: c.PackageURL(component.PackageURL), 109 Supplier: c.Supplier(component.Supplier), 110 Hashes: c.Hashes(component.Hashes), 111 Licenses: c.Licenses(component.Licenses), 112 Properties: lo.ToPtr(c.Properties(component.Properties)), 113 } 114 components[cdxComponent.BOMRef] = cdxComponent 115 116 for _, v := range component.Vulnerabilities { 117 // If the same vulnerability affects multiple packages, those packages will be aggregated into one vulnerability. 118 // Vulnerability component (CVE-2020-26247) 119 // -> Library component (nokogiri /srv/app1/vendor/bundle/ruby/3.0.0/specifications/nokogiri-1.10.0.gemspec) 120 // -> Library component (nokogiri /srv/app2/vendor/bundle/ruby/3.0.0/specifications/nokogiri-1.10.0.gemspec) 121 if vuln, ok := vulns[v.VulnerabilityID]; ok { 122 *vuln.Affects = append(*vuln.Affects, cdxAffects(bomRef, v.InstalledVersion)) 123 if v.FixedVersion != "" { 124 // new recommendation 125 rec := fmt.Sprintf("Upgrade %s to version %s", v.PkgName, v.FixedVersion) 126 // previous recommendations 127 recs := strings.Split(vuln.Recommendation, "; ") 128 if !slices.Contains(recs, rec) { 129 recs = append(recs, rec) 130 slices.Sort(recs) 131 vuln.Recommendation = strings.Join(recs, "; ") 132 } 133 } 134 } else { 135 vulns[v.VulnerabilityID] = c.marshalVulnerability(cdxComponent.BOMRef, v) 136 } 137 } 138 139 dependencies := make([]string, 0) // nolint:gocritic // Components that do not have their own dependencies must be declared as empty elements 140 for _, child := range component.Components { 141 childComponent := c.MarshalComponent(child, components, deps, vulns) 142 dependencies = append(dependencies, childComponent.BOMRef) 143 } 144 sort.Strings(dependencies) 145 146 deps[cdxComponent.BOMRef] = &dependencies 147 148 return cdxComponent 149 } 150 151 func (c *CycloneDX) marshalVulnerability(bomRef string, vuln types.DetectedVulnerability) *cdx.Vulnerability { 152 v := &cdx.Vulnerability{ 153 ID: vuln.VulnerabilityID, 154 Source: cdxSource(vuln.DataSource), 155 Ratings: cdxRatings(vuln), 156 CWEs: cwes(vuln.CweIDs), 157 Description: vuln.Description, 158 Advisories: cdxAdvisories(append([]string{vuln.PrimaryURL}, vuln.References...)), 159 } 160 if vuln.FixedVersion != "" { 161 v.Recommendation = fmt.Sprintf("Upgrade %s to version %s", vuln.PkgName, vuln.FixedVersion) 162 } 163 if vuln.PublishedDate != nil { 164 v.Published = vuln.PublishedDate.Format(timeLayout) 165 } 166 if vuln.LastModifiedDate != nil { 167 v.Updated = vuln.LastModifiedDate.Format(timeLayout) 168 } 169 170 v.Affects = &[]cdx.Affects{cdxAffects(bomRef, vuln.InstalledVersion)} 171 172 return v 173 } 174 175 func (c *CycloneDX) BOMRef(component *Component) string { 176 // PURL takes precedence over UUID 177 if component.PackageURL == nil { 178 return uuid.New().String() 179 } 180 return component.PackageURL.BOMRef() 181 } 182 183 func (c *CycloneDX) Metadata() *cdx.Metadata { 184 return &cdx.Metadata{ 185 Timestamp: clock.Now().UTC().Format(timeLayout), 186 Tools: &[]cdx.Tool{ 187 { 188 Vendor: ToolVendor, 189 Name: ToolName, 190 Version: c.appVersion, 191 }, 192 }, 193 } 194 } 195 196 func (c *CycloneDX) Components(uniq map[string]*cdx.Component) *[]cdx.Component { 197 // Convert components from map to slice and sort by BOM-Ref 198 components := lo.MapToSlice(uniq, func(_ string, value *cdx.Component) cdx.Component { 199 return *value 200 }) 201 sort.Slice(components, func(i, j int) bool { 202 return components[i].BOMRef < components[j].BOMRef 203 }) 204 return &components 205 } 206 207 func (c *CycloneDX) Dependencies(uniq map[string]*[]string) *[]cdx.Dependency { 208 // Convert dependencies from map to slice and sort by BOM-Ref 209 dependencies := lo.MapToSlice(uniq, func(bomRef string, value *[]string) cdx.Dependency { 210 return cdx.Dependency{ 211 Ref: bomRef, 212 Dependencies: value, 213 } 214 }) 215 sort.Slice(dependencies, func(i, j int) bool { 216 return dependencies[i].Ref < dependencies[j].Ref 217 }) 218 return &dependencies 219 } 220 221 func (c *CycloneDX) Vulnerabilities(uniq map[string]*cdx.Vulnerability) *[]cdx.Vulnerability { 222 vulns := lo.MapToSlice(uniq, func(bomRef string, value *cdx.Vulnerability) cdx.Vulnerability { 223 sort.Slice(*value.Affects, func(i, j int) bool { 224 return (*value.Affects)[i].Ref < (*value.Affects)[j].Ref 225 }) 226 return *value 227 }) 228 sort.Slice(vulns, func(i, j int) bool { 229 return vulns[i].BOMRef < vulns[j].BOMRef 230 }) 231 return &vulns 232 } 233 234 func (c *CycloneDX) PackageURL(p *purl.PackageURL) string { 235 if p == nil { 236 return "" 237 } 238 return p.String() 239 } 240 241 func (c *CycloneDX) Supplier(supplier string) *cdx.OrganizationalEntity { 242 if supplier == "" { 243 return nil 244 } 245 return &cdx.OrganizationalEntity{ 246 Name: supplier, 247 } 248 } 249 250 func (c *CycloneDX) Hashes(hashes []digest.Digest) *[]cdx.Hash { 251 if len(hashes) == 0 { 252 return nil 253 } 254 var cdxHashes []cdx.Hash 255 for _, hash := range hashes { 256 var alg cdx.HashAlgorithm 257 switch hash.Algorithm() { 258 case digest.SHA1: 259 alg = cdx.HashAlgoSHA1 260 case digest.SHA256: 261 alg = cdx.HashAlgoSHA256 262 case digest.MD5: 263 alg = cdx.HashAlgoMD5 264 default: 265 log.Logger.Debugf("Unable to convert %q algorithm to CycloneDX format", hash.Algorithm()) 266 continue 267 } 268 269 cdxHashes = append(cdxHashes, cdx.Hash{ 270 Algorithm: alg, 271 Value: hash.Encoded(), 272 }) 273 } 274 return &cdxHashes 275 } 276 277 func (c *CycloneDX) Licenses(licenses []string) *cdx.Licenses { 278 if len(licenses) == 0 { 279 return nil 280 } 281 choices := lo.Map(licenses, func(license string, i int) cdx.LicenseChoice { 282 return cdx.LicenseChoice{ 283 License: &cdx.License{ 284 Name: license, 285 }, 286 } 287 }) 288 return lo.ToPtr(cdx.Licenses(choices)) 289 } 290 291 func (c *CycloneDX) Properties(properties []Property) []cdx.Property { 292 cdxProps := make([]cdx.Property, 0, len(properties)) 293 for _, property := range properties { 294 namespace := Namespace 295 if len(property.Namespace) > 0 { 296 namespace = property.Namespace 297 } 298 cdxProps = append(cdxProps, 299 cdx.Property{ 300 Name: namespace + property.Name, 301 Value: property.Value, 302 }) 303 } 304 sort.Slice(cdxProps, func(i, j int) bool { 305 return cdxProps[i].Name < cdxProps[j].Name 306 }) 307 return cdxProps 308 } 309 310 func IsTrivySBOM(c *cdx.BOM) bool { 311 if c == nil || c.Metadata == nil || c.Metadata.Tools == nil { 312 return false 313 } 314 315 for _, tool := range *c.Metadata.Tools { 316 if tool.Vendor == ToolVendor && tool.Name == ToolName { 317 return true 318 } 319 } 320 return false 321 } 322 323 func LookupProperty(properties *[]cdx.Property, key string) string { 324 for _, p := range lo.FromPtr(properties) { 325 if p.Name == Namespace+key { 326 return p.Value 327 } 328 } 329 return "" 330 } 331 332 func UnmarshalProperties(properties *[]cdx.Property) map[string]string { 333 props := make(map[string]string) 334 for _, prop := range lo.FromPtr(properties) { 335 if !strings.HasPrefix(prop.Name, Namespace) { 336 continue 337 } 338 props[strings.TrimPrefix(prop.Name, Namespace)] = prop.Value 339 } 340 return props 341 } 342 343 func cdxAdvisories(refs []string) *[]cdx.Advisory { 344 refs = lo.Uniq(refs) 345 advs := lo.FilterMap(refs, func(ref string, _ int) (cdx.Advisory, bool) { 346 return cdx.Advisory{URL: ref}, ref != "" 347 }) 348 349 // cyclonedx converts link to empty `[]cdx.Advisory` to `null` 350 // `bom-1.5.schema.json` doesn't support this - `Invalid type. Expected: array, given: null` 351 // we need to explicitly set `nil` for empty `refs` slice 352 if len(advs) == 0 { 353 return nil 354 } 355 356 return &advs 357 } 358 359 func cwes(cweIDs []string) *[]int { 360 // to skip cdx.Vulnerability.CWEs when generating json 361 // we should return 'clear' nil without 'type' interface part 362 if cweIDs == nil { 363 return nil 364 } 365 var ret []int 366 for _, cweID := range cweIDs { 367 number, err := strconv.Atoi(strings.TrimPrefix(strings.ToLower(cweID), "cwe-")) 368 if err != nil { 369 log.Logger.Debugf("cwe id parse error: %s", err) 370 continue 371 } 372 ret = append(ret, number) 373 } 374 return &ret 375 } 376 377 func cdxRatings(vuln types.DetectedVulnerability) *[]cdx.VulnerabilityRating { 378 rates := make([]cdx.VulnerabilityRating, 0) // nolint:gocritic // To export an empty array in JSON 379 for sourceID, severity := range vuln.VendorSeverity { 380 // When the vendor also provides CVSS score/vector 381 if cvss, ok := vuln.CVSS[sourceID]; ok { 382 if cvss.V2Score != 0 || cvss.V2Vector != "" { 383 rates = append(rates, cdxRatingV2(sourceID, severity, cvss)) 384 } 385 if cvss.V3Score != 0 || cvss.V3Vector != "" { 386 rates = append(rates, cdxRatingV3(sourceID, severity, cvss)) 387 } 388 } else { // When the vendor provides only severity 389 rate := cdx.VulnerabilityRating{ 390 Source: &cdx.Source{ 391 Name: string(sourceID), 392 }, 393 Severity: toCDXSeverity(severity), 394 } 395 rates = append(rates, rate) 396 } 397 } 398 399 // For consistency 400 sort.Slice(rates, func(i, j int) bool { 401 if rates[i].Source.Name != rates[j].Source.Name { 402 return rates[i].Source.Name < rates[j].Source.Name 403 } 404 if rates[i].Method != rates[j].Method { 405 return rates[i].Method < rates[j].Method 406 } 407 if rates[i].Score != nil && rates[j].Score != nil { 408 return *rates[i].Score < *rates[j].Score 409 } 410 return rates[i].Vector < rates[j].Vector 411 }) 412 return &rates 413 } 414 415 func cdxRatingV2(sourceID dtypes.SourceID, severity dtypes.Severity, cvss dtypes.CVSS) cdx.VulnerabilityRating { 416 cdxSeverity := toCDXSeverity(severity) 417 418 // Trivy keeps only CVSSv3 severity for NVD. 419 // The CVSSv2 severity must be calculated according to CVSSv2 score. 420 if sourceID == vulnerability.NVD { 421 cdxSeverity = nvdSeverityV2(cvss.V2Score) 422 } 423 return cdx.VulnerabilityRating{ 424 Source: &cdx.Source{ 425 Name: string(sourceID), 426 }, 427 Score: &cvss.V2Score, 428 Method: cdx.ScoringMethodCVSSv2, 429 Severity: cdxSeverity, 430 Vector: cvss.V2Vector, 431 } 432 } 433 434 func cdxRatingV3(sourceID dtypes.SourceID, severity dtypes.Severity, cvss dtypes.CVSS) cdx.VulnerabilityRating { 435 rate := cdx.VulnerabilityRating{ 436 Source: &cdx.Source{ 437 Name: string(sourceID), 438 }, 439 Score: &cvss.V3Score, 440 Method: cdx.ScoringMethodCVSSv3, 441 Severity: toCDXSeverity(severity), 442 Vector: cvss.V3Vector, 443 } 444 if strings.HasPrefix(cvss.V3Vector, "CVSS:3.1") { 445 rate.Method = cdx.ScoringMethodCVSSv31 446 } 447 return rate 448 } 449 450 func nvdSeverityV2(score float64) cdx.Severity { 451 // cf. https://nvd.nist.gov/vuln-metrics/cvss 452 switch { 453 case score < 4.0: 454 return cdx.SeverityInfo 455 case 4.0 <= score && score < 7.0: 456 return cdx.SeverityMedium 457 case 7.0 <= score: 458 return cdx.SeverityHigh 459 } 460 return cdx.SeverityUnknown 461 } 462 463 func toCDXSeverity(s dtypes.Severity) cdx.Severity { 464 switch s { 465 case dtypes.SeverityLow: 466 return cdx.SeverityLow 467 case dtypes.SeverityMedium: 468 return cdx.SeverityMedium 469 case dtypes.SeverityHigh: 470 return cdx.SeverityHigh 471 case dtypes.SeverityCritical: 472 return cdx.SeverityCritical 473 default: 474 return cdx.SeverityUnknown 475 } 476 } 477 478 func cdxSource(source *dtypes.DataSource) *cdx.Source { 479 if source == nil { 480 return nil 481 } 482 483 return &cdx.Source{ 484 Name: string(source.ID), 485 URL: source.URL, 486 } 487 } 488 489 func cdxAffects(ref, version string) cdx.Affects { 490 return cdx.Affects{ 491 Ref: ref, 492 Range: &[]cdx.AffectedVersions{ 493 { 494 Version: version, 495 Status: cdx.VulnerabilityStatusAffected, 496 // "AffectedVersions.Range" is not included, because it does not exist in DetectedVulnerability. 497 }, 498 }, 499 } 500 }