From 97359b1c5fe9b8bd25b883c0451ff8ad9895a3ea Mon Sep 17 00:00:00 2001 From: Christopher Phillips <32073428+spiffcs@users.noreply.github.com> Date: Thu, 30 Jan 2025 13:00:44 -0500 Subject: [PATCH] tests: update tests with PR feedback and overhaul file format model Signed-off-by: Christopher Phillips <32073428+spiffcs@users.noreply.github.com> --- .../cyclonedxhelpers/to_format_model.go | 52 ++++-- .../cyclonedxhelpers/to_format_model_test.go | 150 +++++++++++++++++- 2 files changed, 185 insertions(+), 17 deletions(-) diff --git a/syft/format/common/cyclonedxhelpers/to_format_model.go b/syft/format/common/cyclonedxhelpers/to_format_model.go index 95686f1d2f5..ab0a83ed906 100644 --- a/syft/format/common/cyclonedxhelpers/to_format_model.go +++ b/syft/format/common/cyclonedxhelpers/to_format_model.go @@ -2,6 +2,7 @@ package cyclonedxhelpers import ( "fmt" + "os" "slices" "strings" "time" @@ -40,15 +41,23 @@ func ToFormatModel(s sbom.SBOM) *cyclonedx.BOM { // Files artifacts := s.Artifacts coordinates := s.AllCoordinates() - fileComponents := make([]cyclonedx.Component, len(coordinates)) - for i, coordinate := range coordinates { + fileComponents := make([]cyclonedx.Component, 0) + 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.IsDir() || + fileMetadata.Mode() == os.ModeSymlink || + fileMetadata.Mode() == os.ModeSocket { + // skip dir, symlinks and sockets for the final bom + continue + } metadata = &fileMetadata // Digests @@ -57,12 +66,19 @@ func ToFormatModel(s sbom.SBOM) *cyclonedx.BOM { digests = digestsForLocation } - fileComponents[i] = cyclonedx.Component{ + // if cdx doesn't have an algorithm for the SBOM we need to drop the components + // since an empty hash field is not allowed: https://cyclonedx.org/docs/1.6/json/#components_items_hashes_items_alg + cdxHashes, err := digestsToHashes(digests) + if err != nil { + continue + } + + fileComponents = append(fileComponents, cyclonedx.Component{ BOMRef: string(coordinate.ID()), Type: cyclonedx.ComponentTypeFile, Name: metadata.Path, - Hashes: digestsToHashes(digests), - } + Hashes: cdxHashes, + }) } components = append(components, fileComponents...) cdxBOM.Components = &components @@ -75,16 +91,19 @@ func ToFormatModel(s sbom.SBOM) *cyclonedx.BOM { return cdxBOM } -func digestsToHashes(digests []file.Digest) *[]cyclonedx.Hash { - hashes := make([]cyclonedx.Hash, len(digests)) - for i, digest := range digests { - cdxAlgo := toCycloneDXAlgorithm(digest.Algorithm) - hashes[i] = cyclonedx.Hash{ +func digestsToHashes(digests []file.Digest) (*[]cyclonedx.Hash, error) { + hashes := make([]cyclonedx.Hash, 0) + for _, digest := range digests { + cdxAlgo, err := toCycloneDXAlgorithm(digest.Algorithm) + if err != nil { + return nil, err + } + hashes = append(hashes, cyclonedx.Hash{ Algorithm: cdxAlgo, Value: digest.Value, - } + }) } - return &hashes + return &hashes, nil } // supported algorithm in cycloneDX as of 1.4 @@ -92,14 +111,19 @@ func digestsToHashes(digests []file.Digest) *[]cyclonedx.Hash { // "SHA3-256", "SHA3-384", "SHA3-512", "BLAKE2b-256", "BLAKE2b-384", "BLAKE2b-512", "BLAKE3" // syft supported digests: cmd/syft/cli/eventloop/tasks.go // MD5, SHA1, SHA256 -func toCycloneDXAlgorithm(algorithm string) cyclonedx.HashAlgorithm { +func toCycloneDXAlgorithm(algorithm string) (cyclonedx.HashAlgorithm, error) { validMap := map[string]cyclonedx.HashAlgorithm{ "sha1": cyclonedx.HashAlgoSHA1, "md5": cyclonedx.HashAlgoMD5, "sha256": cyclonedx.HashAlgoSHA256, } + lookup := strings.ToLower(algorithm) + cdxAlgo, exists := validMap[lookup] + if !exists { + return "", fmt.Errorf("could not find valid cdx algorithm for %s", lookup) + } - return validMap[strings.ToLower(algorithm)] + return cdxAlgo, nil } func toOSComponent(distro *linux.Release) []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 85d4a3ca7b3..517f8fd436d 100644 --- a/syft/format/common/cyclonedxhelpers/to_format_model_test.go +++ b/syft/format/common/cyclonedxhelpers/to_format_model_test.go @@ -2,7 +2,9 @@ package cyclonedxhelpers import ( "fmt" + "os" "testing" + "time" "github.com/CycloneDX/cyclonedx-go" "github.com/google/go-cmp/cmp" @@ -144,18 +146,116 @@ func Test_relationships(t *testing.T) { } } -func Test_fileComponents(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", + 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", FileInfo: newMockFileInfo(false, false)}, // Embed the mock that always returns IsDir() = true + }, + 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", FileInfo: newMockFileInfo(false, false)}, + }, + 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 are not added to the final output", + sbom: sbom.SBOM{ + Artifacts: sbom.Artifacts{ + FileMetadata: map[file.Coordinates]file.Metadata{ + {RealPath: "/test"}: {Path: "/test", FileInfo: newMockFileInfo(false, false)}, + }, + FileDigests: map[file.Coordinates][]file.Digest{ + {RealPath: "/test"}: { + { + Algorithm: "xxh64", + Value: "xyz12345", + }, + }, + }, + }, + }, + want: []cyclonedx.Component{}, + }, + { + 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: "/test"}: {Path: "/test"}, + {RealPath: "/testdir"}: { + Path: "/testdir", + FileInfo: newMockFileInfo(true, false), + }, + {RealPath: "/testsym"}: { + Path: "/testsym", + FileInfo: newMockFileInfo(false, true), + }, + {RealPath: "/test"}: {Path: "/test", FileInfo: newMockFileInfo(false, false)}, }, FileDigests: map[file.Coordinates][]file.Digest{ {RealPath: "/test"}: { @@ -178,6 +278,21 @@ func Test_fileComponents(t *testing.T) { }, }, }, + { + 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) { @@ -190,6 +305,35 @@ func Test_fileComponents(t *testing.T) { } } +// mockFileInfo is a test struct that simulates fs.FileInfo +type mockFileInfo struct { + isDir bool + isSymlink bool +} + +func newMockFileInfo(isDir, isSym bool) mockFileInfo { + return mockFileInfo{ + isDir, + isSym, + } +} + +// Implement os.FileInfo interface methods +func (m mockFileInfo) Name() string { return "mockDir" } +func (m mockFileInfo) Size() int64 { return 0 } +func (m mockFileInfo) Mode() os.FileMode { + if m.isSymlink { + return os.ModeSymlink + } + if m.isDir { + return os.ModeDir + } + return os.ModeType +} // Mark as directory +func (m mockFileInfo) ModTime() time.Time { return time.Now() } +func (m mockFileInfo) IsDir() bool { return m.isDir } +func (m mockFileInfo) Sys() any { return nil } + func Test_toBomDescriptor(t *testing.T) { type args struct { name string