From 9a9195e5c4db7cdb7a956900dd808f55d955cc3f Mon Sep 17 00:00:00 2001 From: Christopher Angelo Phillips <32073428+spiffcs@users.noreply.github.com> Date: Fri, 31 Jan 2025 15:09:15 -0500 Subject: [PATCH] feat: syft 3435 - add file components to cyclonedx bom output when file metadata is available (#3539) --------- Signed-off-by: Christopher Phillips <32073428+spiffcs@users.noreply.github.com> --- .../cyclonedxhelpers/to_format_model.go | 69 ++++++- .../cyclonedxhelpers/to_format_model_test.go | 174 ++++++++++++++++++ 2 files changed, 242 insertions(+), 1 deletion(-) diff --git a/syft/format/common/cyclonedxhelpers/to_format_model.go b/syft/format/common/cyclonedxhelpers/to_format_model.go index ba93a367e..4f4a8ccbd 100644 --- a/syft/format/common/cyclonedxhelpers/to_format_model.go +++ b/syft/format/common/cyclonedxhelpers/to_format_model.go @@ -6,12 +6,14 @@ import ( "strings" "time" - "github.com/CycloneDX/cyclonedx-go" + cyclonedx "github.com/CycloneDX/cyclonedx-go" "github.com/google/uuid" + stfile "github.com/anchore/stereoscope/pkg/file" "github.com/anchore/syft/internal/log" "github.com/anchore/syft/syft/artifact" "github.com/anchore/syft/syft/cpe" + "github.com/anchore/syft/syft/file" "github.com/anchore/syft/syft/format/internal/cyclonedxutil/helpers" "github.com/anchore/syft/syft/linux" "github.com/anchore/syft/syft/pkg" @@ -19,6 +21,18 @@ import ( "github.com/anchore/syft/syft/source" ) +var cycloneDXValidHash = map[string]cyclonedx.HashAlgorithm{ + "sha1": cyclonedx.HashAlgoSHA1, + "md5": cyclonedx.HashAlgoMD5, + "sha256": cyclonedx.HashAlgoSHA256, + "sha384": cyclonedx.HashAlgoSHA384, + "sha512": cyclonedx.HashAlgoSHA512, + "blake2b256": cyclonedx.HashAlgoBlake2b_256, + "blake2b384": cyclonedx.HashAlgoBlake2b_384, + "blake2b512": cyclonedx.HashAlgoBlake2b_512, + "blake3": cyclonedx.HashAlgoBlake3, +} + func ToFormatModel(s sbom.SBOM) *cyclonedx.BOM { cdxBOM := cyclonedx.NewBOM() @@ -28,12 +42,49 @@ func ToFormatModel(s sbom.SBOM) *cyclonedx.BOM { cdxBOM.SerialNumber = uuid.New().URN() cdxBOM.Metadata = toBomDescriptor(s.Descriptor.Name, s.Descriptor.Version, s.Source) + // Packages packages := s.Artifacts.Packages.Sorted() components := make([]cyclonedx.Component, len(packages)) for i, p := range packages { components[i] = helpers.EncodeComponent(p) } components = append(components, toOSComponent(s.Artifacts.LinuxDistribution)...) + + // Files + artifacts := s.Artifacts + coordinates := s.AllCoordinates() + for _, coordinate := range coordinates { + var metadata *file.Metadata + // File Info + fileMetadata, exists := artifacts.FileMetadata[coordinate] + // no file metadata then don't include in SBOM + // the syft config allows for sometimes only capturing files owned by packages + // so there can be a map miss here where we have less metadata than all coordinates + if !exists { + continue + } + if fileMetadata.Type == stfile.TypeDirectory || + fileMetadata.Type == stfile.TypeSocket || + fileMetadata.Type == stfile.TypeSymLink { + // skip dir, symlinks and sockets for the final bom + continue + } + metadata = &fileMetadata + + // Digests + var digests []file.Digest + if digestsForLocation, exists := artifacts.FileDigests[coordinate]; exists { + digests = digestsForLocation + } + + cdxHashes := digestsToHashes(digests) + components = append(components, cyclonedx.Component{ + BOMRef: string(coordinate.ID()), + Type: cyclonedx.ComponentTypeFile, + Name: metadata.Path, + Hashes: &cdxHashes, + }) + } cdxBOM.Components = &components dependencies := toDependencies(s.Relationships) @@ -44,6 +95,22 @@ func ToFormatModel(s sbom.SBOM) *cyclonedx.BOM { return cdxBOM } +func digestsToHashes(digests []file.Digest) []cyclonedx.Hash { + var hashes []cyclonedx.Hash + for _, digest := range digests { + lookup := strings.ToLower(digest.Algorithm) + cdxAlgo, exists := cycloneDXValidHash[lookup] + if !exists { + continue + } + hashes = append(hashes, cyclonedx.Hash{ + Algorithm: cdxAlgo, + Value: digest.Value, + }) + } + return hashes +} + func toOSComponent(distro *linux.Release) []cyclonedx.Component { if distro == nil { return []cyclonedx.Component{} diff --git a/syft/format/common/cyclonedxhelpers/to_format_model_test.go b/syft/format/common/cyclonedxhelpers/to_format_model_test.go index c3ac1f3b8..74f0ffbf3 100644 --- a/syft/format/common/cyclonedxhelpers/to_format_model_test.go +++ b/syft/format/common/cyclonedxhelpers/to_format_model_test.go @@ -9,7 +9,9 @@ import ( "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + stfile "github.com/anchore/stereoscope/pkg/file" "github.com/anchore/syft/syft/artifact" + "github.com/anchore/syft/syft/file" "github.com/anchore/syft/syft/format/internal/cyclonedxutil/helpers" "github.com/anchore/syft/syft/linux" "github.com/anchore/syft/syft/pkg" @@ -143,6 +145,178 @@ func Test_relationships(t *testing.T) { } } +func Test_FileComponents(t *testing.T) { + p1 := pkg.Package{ + Name: "p1", + } + tests := []struct { + name string + sbom sbom.SBOM + want []cyclonedx.Component + }{ + { + name: "sbom coordinates with file metadata are serialized to cdx along with packages", + sbom: sbom.SBOM{ + Artifacts: sbom.Artifacts{ + Packages: pkg.NewCollection(p1), + FileMetadata: map[file.Coordinates]file.Metadata{ + {RealPath: "/test"}: {Path: "/test", Type: stfile.TypeRegular}, + }, + FileDigests: map[file.Coordinates][]file.Digest{ + {RealPath: "/test"}: { + { + Algorithm: "sha256", + Value: "xyz12345", + }, + }, + }, + }, + }, + want: []cyclonedx.Component{ + { + BOMRef: "2a1fc74ade23e357", + Type: cyclonedx.ComponentTypeLibrary, + Name: "p1", + }, + { + BOMRef: "3f31cb2d98be6c1e", + Name: "/test", + Type: cyclonedx.ComponentTypeFile, + Hashes: &[]cyclonedx.Hash{ + {Algorithm: "SHA-256", Value: "xyz12345"}, + }, + }, + }, + }, + { + name: "sbom coordinates that don't contain metadata are not added to the final output", + sbom: sbom.SBOM{ + Artifacts: sbom.Artifacts{ + FileMetadata: map[file.Coordinates]file.Metadata{ + {RealPath: "/test"}: {Path: "/test", Type: stfile.TypeRegular}, + }, + FileDigests: map[file.Coordinates][]file.Digest{ + {RealPath: "/test"}: { + { + Algorithm: "sha256", + Value: "xyz12345", + }, + }, + {RealPath: "/test-2"}: { + { + Algorithm: "sha256", + Value: "xyz678910", + }, + }, + }, + }, + }, + want: []cyclonedx.Component{ + { + BOMRef: "3f31cb2d98be6c1e", + Name: "/test", + Type: cyclonedx.ComponentTypeFile, + Hashes: &[]cyclonedx.Hash{ + {Algorithm: "SHA-256", Value: "xyz12345"}, + }, + }, + }, + }, + { + name: "sbom coordinates that return hashes not covered by cdx only include valid digests", + sbom: sbom.SBOM{ + Artifacts: sbom.Artifacts{ + FileMetadata: map[file.Coordinates]file.Metadata{ + {RealPath: "/test"}: {Path: "/test", Type: stfile.TypeRegular}, + }, + FileDigests: map[file.Coordinates][]file.Digest{ + {RealPath: "/test"}: { + { + Algorithm: "xxh64", + Value: "xyz12345", + }, + { + Algorithm: "sha256", + Value: "xyz678910", + }, + }, + }, + }, + }, + want: []cyclonedx.Component{ + { + BOMRef: "3f31cb2d98be6c1e", + Name: "/test", + Type: cyclonedx.ComponentTypeFile, + Hashes: &[]cyclonedx.Hash{ + {Algorithm: "SHA-256", Value: "xyz678910"}, + }, + }, + }, + }, + { + name: "sbom coordinates who's metadata is directory or symlink are skipped", + sbom: sbom.SBOM{ + Artifacts: sbom.Artifacts{ + FileMetadata: map[file.Coordinates]file.Metadata{ + {RealPath: "/testdir"}: { + Path: "/testdir", + Type: stfile.TypeDirectory, + }, + {RealPath: "/testsym"}: { + Path: "/testsym", + Type: stfile.TypeSymLink, + }, + {RealPath: "/test"}: {Path: "/test", Type: stfile.TypeRegular}, + }, + FileDigests: map[file.Coordinates][]file.Digest{ + {RealPath: "/test"}: { + { + Algorithm: "sha256", + Value: "xyz12345", + }, + }, + }, + }, + }, + want: []cyclonedx.Component{ + { + BOMRef: "3f31cb2d98be6c1e", + Name: "/test", + Type: cyclonedx.ComponentTypeFile, + Hashes: &[]cyclonedx.Hash{ + {Algorithm: "SHA-256", Value: "xyz12345"}, + }, + }, + }, + }, + { + name: "sbom with no files serialized correctly", + sbom: sbom.SBOM{ + Artifacts: sbom.Artifacts{ + Packages: pkg.NewCollection(p1), + }, + }, + want: []cyclonedx.Component{ + { + BOMRef: "2a1fc74ade23e357", + Type: cyclonedx.ComponentTypeLibrary, + Name: "p1", + }, + }, + }, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + cdx := ToFormatModel(test.sbom) + got := *cdx.Components + if diff := cmp.Diff(test.want, got); diff != "" { + t.Errorf("cdx file components mismatch (-want +got):\n%s", diff) + } + }) + } +} + func Test_toBomDescriptor(t *testing.T) { type args struct { name string