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  }