github.com/anchore/syft@v1.38.2/syft/format/common/cyclonedxhelpers/to_format_model.go (about)

     1  package cyclonedxhelpers
     2  
     3  import (
     4  	"fmt"
     5  	"slices"
     6  	"strings"
     7  	"time"
     8  
     9  	cyclonedx "github.com/CycloneDX/cyclonedx-go"
    10  	"github.com/google/uuid"
    11  
    12  	stfile "github.com/anchore/stereoscope/pkg/file"
    13  	"github.com/anchore/syft/internal/log"
    14  	"github.com/anchore/syft/syft/artifact"
    15  	"github.com/anchore/syft/syft/cpe"
    16  	"github.com/anchore/syft/syft/file"
    17  	"github.com/anchore/syft/syft/format/internal/cyclonedxutil/helpers"
    18  	"github.com/anchore/syft/syft/linux"
    19  	"github.com/anchore/syft/syft/pkg"
    20  	"github.com/anchore/syft/syft/sbom"
    21  	"github.com/anchore/syft/syft/source"
    22  )
    23  
    24  var cycloneDXValidHash = map[string]cyclonedx.HashAlgorithm{
    25  	"sha1":       cyclonedx.HashAlgoSHA1,
    26  	"md5":        cyclonedx.HashAlgoMD5,
    27  	"sha256":     cyclonedx.HashAlgoSHA256,
    28  	"sha384":     cyclonedx.HashAlgoSHA384,
    29  	"sha512":     cyclonedx.HashAlgoSHA512,
    30  	"blake2b256": cyclonedx.HashAlgoBlake2b_256,
    31  	"blake2b384": cyclonedx.HashAlgoBlake2b_384,
    32  	"blake2b512": cyclonedx.HashAlgoBlake2b_512,
    33  	"blake3":     cyclonedx.HashAlgoBlake3,
    34  }
    35  
    36  func ToFormatModel(s sbom.SBOM) *cyclonedx.BOM {
    37  	cdxBOM := cyclonedx.NewBOM()
    38  
    39  	// NOTE(jonasagx): cycloneDX requires URN uuids (URN returns the RFC 2141 URN form of uuid):
    40  	// https://github.com/CycloneDX/specification/blob/master/schema/bom-1.3-strict.schema.json#L36
    41  	// "pattern": "^urn:uuid:[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$"
    42  	cdxBOM.SerialNumber = uuid.New().URN()
    43  	cdxBOM.Metadata = toBomDescriptor(s.Descriptor.Name, s.Descriptor.Version, s.Source)
    44  
    45  	coordinates, locationSorter := getCoordinates(s)
    46  
    47  	// Packages
    48  	packages := s.Artifacts.Packages.Sorted()
    49  	components := make([]cyclonedx.Component, len(packages))
    50  	for i, p := range packages {
    51  		components[i] = helpers.EncodeComponent(p, s.Source.Supplier, locationSorter)
    52  	}
    53  	components = append(components, toOSComponent(s.Artifacts.LinuxDistribution)...)
    54  
    55  	artifacts := s.Artifacts
    56  
    57  	for _, coordinate := range coordinates {
    58  		var metadata *file.Metadata
    59  		// File Info
    60  		fileMetadata, exists := artifacts.FileMetadata[coordinate]
    61  		// no file metadata then don't include in SBOM
    62  		// the syft config allows for sometimes only capturing files owned by packages
    63  		// so there can be a map miss here where we have less metadata than all coordinates
    64  		if !exists {
    65  			continue
    66  		}
    67  		if fileMetadata.Type == stfile.TypeDirectory ||
    68  			fileMetadata.Type == stfile.TypeSocket ||
    69  			fileMetadata.Type == stfile.TypeSymLink {
    70  			// skip dir, symlinks and sockets for the final bom
    71  			continue
    72  		}
    73  		metadata = &fileMetadata
    74  
    75  		// Digests
    76  		var digests []file.Digest
    77  		if digestsForLocation, exists := artifacts.FileDigests[coordinate]; exists {
    78  			digests = digestsForLocation
    79  		}
    80  
    81  		cdxHashes := digestsToHashes(digests)
    82  		components = append(components, cyclonedx.Component{
    83  			BOMRef: string(coordinate.ID()),
    84  			Type:   cyclonedx.ComponentTypeFile,
    85  			Name:   metadata.Path,
    86  			Hashes: &cdxHashes,
    87  		})
    88  	}
    89  	cdxBOM.Components = &components
    90  
    91  	dependencies := toDependencies(s.Relationships)
    92  	if len(dependencies) > 0 {
    93  		cdxBOM.Dependencies = &dependencies
    94  	}
    95  
    96  	return cdxBOM
    97  }
    98  
    99  func getCoordinates(s sbom.SBOM) ([]file.Coordinates, func(a, b file.Location) int) {
   100  	var layers []string
   101  	if m, ok := s.Source.Metadata.(source.ImageMetadata); ok {
   102  		for _, l := range m.Layers {
   103  			layers = append(layers, l.Digest)
   104  		}
   105  	}
   106  
   107  	coordSorter := file.CoordinatesSorter(layers)
   108  	coordinates := s.AllCoordinates()
   109  
   110  	slices.SortFunc(coordinates, coordSorter)
   111  	return coordinates, file.LocationSorter(layers)
   112  }
   113  
   114  func digestsToHashes(digests []file.Digest) []cyclonedx.Hash {
   115  	var hashes []cyclonedx.Hash
   116  	for _, digest := range digests {
   117  		lookup := strings.ToLower(digest.Algorithm)
   118  		cdxAlgo, exists := cycloneDXValidHash[lookup]
   119  		if !exists {
   120  			continue
   121  		}
   122  		hashes = append(hashes, cyclonedx.Hash{
   123  			Algorithm: cdxAlgo,
   124  			Value:     digest.Value,
   125  		})
   126  	}
   127  	return hashes
   128  }
   129  
   130  func toOSComponent(distro *linux.Release) []cyclonedx.Component {
   131  	if distro == nil {
   132  		return []cyclonedx.Component{}
   133  	}
   134  	eRefs := &[]cyclonedx.ExternalReference{}
   135  	if distro.BugReportURL != "" {
   136  		*eRefs = append(*eRefs, cyclonedx.ExternalReference{
   137  			URL:  distro.BugReportURL,
   138  			Type: cyclonedx.ERTypeIssueTracker,
   139  		})
   140  	}
   141  	if distro.HomeURL != "" {
   142  		*eRefs = append(*eRefs, cyclonedx.ExternalReference{
   143  			URL:  distro.HomeURL,
   144  			Type: cyclonedx.ERTypeWebsite,
   145  		})
   146  	}
   147  	if distro.SupportURL != "" {
   148  		*eRefs = append(*eRefs, cyclonedx.ExternalReference{
   149  			URL:     distro.SupportURL,
   150  			Type:    cyclonedx.ERTypeOther,
   151  			Comment: "support",
   152  		})
   153  	}
   154  	if distro.PrivacyPolicyURL != "" {
   155  		*eRefs = append(*eRefs, cyclonedx.ExternalReference{
   156  			URL:     distro.PrivacyPolicyURL,
   157  			Type:    cyclonedx.ERTypeOther,
   158  			Comment: "privacyPolicy",
   159  		})
   160  	}
   161  	if len(*eRefs) == 0 {
   162  		eRefs = nil
   163  	}
   164  	props := helpers.EncodeProperties(distro, "syft:distro")
   165  	var properties *[]cyclonedx.Property
   166  	if len(props) > 0 {
   167  		properties = &props
   168  	}
   169  	return []cyclonedx.Component{
   170  		{
   171  			BOMRef: toOSBomRef(distro.ID, distro.VersionID),
   172  			Type:   cyclonedx.ComponentTypeOS,
   173  			// is it idiomatic to be using SWID here for specific name and version information?
   174  			SWID: &cyclonedx.SWID{
   175  				TagID:   distro.ID,
   176  				Name:    distro.ID,
   177  				Version: distro.VersionID,
   178  			},
   179  			Description: distro.PrettyName,
   180  			Name:        distro.ID,
   181  			Version:     distro.VersionID,
   182  			// should we add a PURL?
   183  			CPE:                formatCPE(distro.CPEName),
   184  			ExternalReferences: eRefs,
   185  			Properties:         properties,
   186  		},
   187  	}
   188  }
   189  
   190  func toOSBomRef(name string, version string) string {
   191  	if name == "" {
   192  		return "os:unknown"
   193  	}
   194  	if version == "" {
   195  		return fmt.Sprintf("os:%s", name)
   196  	}
   197  	return fmt.Sprintf("os:%s@%s", name, version)
   198  }
   199  
   200  func formatCPE(cpeString string) string {
   201  	c, err := cpe.NewAttributes(cpeString)
   202  	if err != nil {
   203  		log.Debugf("skipping invalid CPE: %s", cpeString)
   204  		return ""
   205  	}
   206  	return c.String()
   207  }
   208  
   209  // NewBomDescriptor returns a new BomDescriptor tailored for the current time and "syft" tool details.
   210  func toBomDescriptor(name, version string, srcMetadata source.Description) *cyclonedx.Metadata {
   211  	return &cyclonedx.Metadata{
   212  		Timestamp: time.Now().Format(time.RFC3339),
   213  		Tools: &cyclonedx.ToolsChoice{
   214  			Components: &[]cyclonedx.Component{
   215  				{
   216  					Type:    cyclonedx.ComponentTypeApplication,
   217  					Author:  "anchore",
   218  					Name:    name,
   219  					Version: version,
   220  				},
   221  			},
   222  		},
   223  		Supplier:   toBomSupplier(srcMetadata),
   224  		Properties: toBomProperties(srcMetadata),
   225  		Component:  toBomDescriptorComponent(srcMetadata),
   226  	}
   227  }
   228  
   229  func toBomSupplier(srcMetadata source.Description) *cyclonedx.OrganizationalEntity {
   230  	if srcMetadata.Supplier != "" {
   231  		return &cyclonedx.OrganizationalEntity{
   232  			Name: srcMetadata.Supplier,
   233  		}
   234  	}
   235  
   236  	return nil
   237  }
   238  
   239  // used to indicate that a relationship listed under the syft artifact package can be represented as a cyclonedx dependency.
   240  // NOTE: CycloneDX provides the ability to describe components and their dependency on other components.
   241  // The dependency graph is capable of representing both direct and transitive relationships.
   242  // If a relationship is either direct or transitive it can be included in this function.
   243  // An example of a relationship to not include would be: OwnershipByFileOverlapRelationship.
   244  func isExpressiblePackageRelationship(ty artifact.RelationshipType) bool {
   245  	switch ty {
   246  	case artifact.DependencyOfRelationship:
   247  		return true
   248  	default:
   249  		return false
   250  	}
   251  }
   252  
   253  func toDependencies(relationships []artifact.Relationship) []cyclonedx.Dependency {
   254  	dependencies := map[string]*cyclonedx.Dependency{}
   255  	for _, r := range relationships {
   256  		exists := isExpressiblePackageRelationship(r.Type)
   257  		if !exists {
   258  			log.Debugf("unable to convert relationship type to CycloneDX JSON, dropping: %#v", r)
   259  			continue
   260  		}
   261  
   262  		// we only capture package-to-package relationships for now
   263  		fromPkg, ok := r.From.(pkg.Package)
   264  		if !ok {
   265  			log.Tracef("unable to convert relationship fromPkg to CycloneDX JSON, dropping: %#v", r)
   266  			continue
   267  		}
   268  
   269  		toPkg, ok := r.To.(pkg.Package)
   270  		if !ok {
   271  			log.Tracef("unable to convert relationship toPkg to CycloneDX JSON, dropping: %#v", r)
   272  			continue
   273  		}
   274  
   275  		toRef := helpers.DeriveBomRef(toPkg)
   276  		dep := dependencies[toRef]
   277  		if dep == nil {
   278  			dep = &cyclonedx.Dependency{
   279  				Ref:          toRef,
   280  				Dependencies: &[]string{},
   281  			}
   282  			dependencies[toRef] = dep
   283  		}
   284  
   285  		fromRef := helpers.DeriveBomRef(fromPkg)
   286  		if !slices.Contains(*dep.Dependencies, fromRef) {
   287  			*dep.Dependencies = append(*dep.Dependencies, fromRef)
   288  		}
   289  	}
   290  
   291  	result := make([]cyclonedx.Dependency, 0, len(dependencies))
   292  	for _, dep := range dependencies {
   293  		slices.Sort(*dep.Dependencies)
   294  		result = append(result, *dep)
   295  	}
   296  
   297  	slices.SortFunc(result, func(a, b cyclonedx.Dependency) int {
   298  		return strings.Compare(a.Ref, b.Ref)
   299  	})
   300  
   301  	return result
   302  }
   303  
   304  func toBomProperties(srcMetadata source.Description) *[]cyclonedx.Property {
   305  	metadata, ok := srcMetadata.Metadata.(source.ImageMetadata)
   306  	if ok {
   307  		props := helpers.EncodeProperties(metadata.Labels, "syft:image:labels")
   308  		// return nil if props is nil to avoid creating a pointer to a nil slice,
   309  		// which results in a null JSON value that does not comply with the CycloneDX schema.
   310  		// https://github.com/anchore/grype/issues/1759
   311  		if props == nil {
   312  			return nil
   313  		}
   314  		return &props
   315  	}
   316  	return nil
   317  }
   318  
   319  func toBomDescriptorComponent(srcMetadata source.Description) *cyclonedx.Component {
   320  	name := srcMetadata.Name
   321  	version := srcMetadata.Version
   322  	switch metadata := srcMetadata.Metadata.(type) {
   323  	case source.ImageMetadata:
   324  		if name == "" {
   325  			name = metadata.UserInput
   326  		}
   327  		if version == "" {
   328  			version = metadata.ManifestDigest
   329  		}
   330  		bomRef, err := artifact.IDByHash(metadata.ID)
   331  		if err != nil {
   332  			log.Debugf("unable to get fingerprint of source image metadata=%s: %+v", metadata.ID, err)
   333  		}
   334  		return &cyclonedx.Component{
   335  			BOMRef:   string(bomRef),
   336  			Type:     cyclonedx.ComponentTypeContainer,
   337  			Name:     name,
   338  			Version:  version,
   339  			Supplier: toBomSupplier(srcMetadata),
   340  		}
   341  	case source.DirectoryMetadata:
   342  		if name == "" {
   343  			name = metadata.Path
   344  		}
   345  		bomRef, err := artifact.IDByHash(metadata.Path)
   346  		if err != nil {
   347  			log.Debugf("unable to get fingerprint of source directory metadata path=%s: %+v", metadata.Path, err)
   348  		}
   349  		return &cyclonedx.Component{
   350  			BOMRef: string(bomRef),
   351  			// TODO: this is lossy... we can't know if this is a file or a directory
   352  			Type:     cyclonedx.ComponentTypeFile,
   353  			Name:     name,
   354  			Version:  version,
   355  			Supplier: toBomSupplier(srcMetadata),
   356  		}
   357  	case source.FileMetadata:
   358  		if name == "" {
   359  			name = metadata.Path
   360  		}
   361  		bomRef, err := artifact.IDByHash(metadata.Path)
   362  		if err != nil {
   363  			log.Debugf("unable to get fingerprint of source file metadata path=%s: %+v", metadata.Path, err)
   364  		}
   365  		return &cyclonedx.Component{
   366  			BOMRef: string(bomRef),
   367  			// TODO: this is lossy... we can't know if this is a file or a directory
   368  			Type:     cyclonedx.ComponentTypeFile,
   369  			Name:     name,
   370  			Version:  version,
   371  			Supplier: toBomSupplier(srcMetadata),
   372  		}
   373  	}
   374  
   375  	return nil
   376  }