diff --git a/syft/formats/spdx22json/encoder_test.go b/syft/formats/spdx22json/encoder_test.go index 77b0d35de..329685968 100644 --- a/syft/formats/spdx22json/encoder_test.go +++ b/syft/formats/spdx22json/encoder_test.go @@ -5,7 +5,10 @@ import ( "regexp" "testing" + "github.com/anchore/syft/syft/artifact" "github.com/anchore/syft/syft/formats/common/testutils" + "github.com/anchore/syft/syft/sbom" + "github.com/anchore/syft/syft/source" ) var updateSpdxJson = flag.Bool("update-spdx-json", false, "update the *.golden files for spdx-json encoders") @@ -30,6 +33,36 @@ func TestSPDXJSONImageEncoder(t *testing.T) { ) } +func TestSPDXRelationshipOrder(t *testing.T) { + testImage := "image-simple" + s := testutils.ImageInput(t, testImage, testutils.FromSnapshot()) + addRelationships(&s) + testutils.AssertEncoderAgainstGoldenImageSnapshot(t, + Format(), + s, + testImage, + *updateSpdxJson, + spdxJsonRedactor, + ) +} + +func addRelationships(s *sbom.SBOM) { + catalog := s.Artifacts.PackageCatalog.Sorted() + s.Artifacts.FileMetadata = map[source.Coordinates]source.FileMetadata{} + + for _, f := range []string{"/f1", "/f2", "/d1/f3", "/d2/f4", "/z1/f5", "/a1/f6"} { + meta := source.FileMetadata{} + coords := source.Coordinates{RealPath: f} + s.Artifacts.FileMetadata[coords] = meta + + s.Relationships = append(s.Relationships, artifact.Relationship{ + From: catalog[0], + To: coords, + Type: artifact.ContainsRelationship, + }) + } +} + func spdxJsonRedactor(s []byte) []byte { // each SBOM reports the time it was generated, which is not useful during snapshot testing s = regexp.MustCompile(`"created": .*`).ReplaceAll(s, []byte("redacted")) diff --git a/syft/formats/spdx22json/test-fixtures/snapshot/TestSPDXRelationshipOrder.golden b/syft/formats/spdx22json/test-fixtures/snapshot/TestSPDXRelationshipOrder.golden new file mode 100644 index 000000000..445c69d82 --- /dev/null +++ b/syft/formats/spdx22json/test-fixtures/snapshot/TestSPDXRelationshipOrder.golden @@ -0,0 +1,151 @@ +{ + "SPDXID": "SPDXRef-DOCUMENT", + "name": "user-image-input", + "spdxVersion": "SPDX-2.2", + "creationInfo": { + "created": "2022-09-19T18:39:05.841331Z", + "creators": [ + "Organization: Anchore, Inc", + "Tool: syft-v0.42.0-bogus" + ], + "licenseListVersion": "3.18" + }, + "dataLicense": "CC0-1.0", + "documentNamespace": "https://anchore.com/syft/image/user-image-input-6cf0595e-7d69-4990-aef5-8183b52023b9", + "packages": [ + { + "SPDXID": "SPDXRef-2a46171f91c8d4bc", + "name": "package-1", + "licenseConcluded": "MIT", + "downloadLocation": "NOASSERTION", + "externalRefs": [ + { + "referenceCategory": "SECURITY", + "referenceLocator": "cpe:2.3:*:some:package:1:*:*:*:*:*:*:*", + "referenceType": "cpe23Type" + }, + { + "referenceCategory": "PACKAGE_MANAGER", + "referenceLocator": "a-purl-1", + "referenceType": "purl" + } + ], + "filesAnalyzed": false, + "hasFiles": [ + "SPDXRef-5265a4dde3edbf7c", + "SPDXRef-839d99ee67d9d174", + "SPDXRef-9c2f7510199b17f6", + "SPDXRef-c641caa71518099f", + "SPDXRef-c6f5b29dca12661f", + "SPDXRef-f9e49132a4b96ccd" + ], + "licenseDeclared": "MIT", + "sourceInfo": "acquired package info from installed python package manifest file: /somefile-1.txt", + "versionInfo": "1.0.1" + }, + { + "SPDXID": "SPDXRef-ae77680e9b1d087e", + "name": "package-2", + "licenseConcluded": "NONE", + "downloadLocation": "NOASSERTION", + "externalRefs": [ + { + "referenceCategory": "SECURITY", + "referenceLocator": "cpe:2.3:*:some:package:2:*:*:*:*:*:*:*", + "referenceType": "cpe23Type" + }, + { + "referenceCategory": "PACKAGE_MANAGER", + "referenceLocator": "pkg:deb/debian/package-2@2.0.1", + "referenceType": "purl" + } + ], + "filesAnalyzed": false, + "licenseDeclared": "NONE", + "sourceInfo": "acquired package info from DPKG DB: /somefile-2.txt", + "versionInfo": "2.0.1" + } + ], + "files": [ + { + "SPDXID": "SPDXRef-9c2f7510199b17f6", + "licenseConcluded": "NOASSERTION", + "fileName": "/a1/f6", + "fileTypes": [ + "OTHER" + ] + }, + { + "SPDXID": "SPDXRef-c6f5b29dca12661f", + "licenseConcluded": "NOASSERTION", + "fileName": "/d1/f3", + "fileTypes": [ + "OTHER" + ] + }, + { + "SPDXID": "SPDXRef-c641caa71518099f", + "licenseConcluded": "NOASSERTION", + "fileName": "/d2/f4", + "fileTypes": [ + "OTHER" + ] + }, + { + "SPDXID": "SPDXRef-5265a4dde3edbf7c", + "licenseConcluded": "NOASSERTION", + "fileName": "/f1", + "fileTypes": [ + "OTHER" + ] + }, + { + "SPDXID": "SPDXRef-f9e49132a4b96ccd", + "licenseConcluded": "NOASSERTION", + "fileName": "/f2", + "fileTypes": [ + "OTHER" + ] + }, + { + "SPDXID": "SPDXRef-839d99ee67d9d174", + "licenseConcluded": "NOASSERTION", + "fileName": "/z1/f5", + "fileTypes": [ + "OTHER" + ] + } + ], + "relationships": [ + { + "spdxElementId": "SPDXRef-2a46171f91c8d4bc", + "relationshipType": "CONTAINS", + "relatedSpdxElement": "SPDXRef-5265a4dde3edbf7c" + }, + { + "spdxElementId": "SPDXRef-2a46171f91c8d4bc", + "relationshipType": "CONTAINS", + "relatedSpdxElement": "SPDXRef-839d99ee67d9d174" + }, + { + "spdxElementId": "SPDXRef-2a46171f91c8d4bc", + "relationshipType": "CONTAINS", + "relatedSpdxElement": "SPDXRef-9c2f7510199b17f6" + }, + { + "spdxElementId": "SPDXRef-2a46171f91c8d4bc", + "relationshipType": "CONTAINS", + "relatedSpdxElement": "SPDXRef-c641caa71518099f" + }, + { + "spdxElementId": "SPDXRef-2a46171f91c8d4bc", + "relationshipType": "CONTAINS", + "relatedSpdxElement": "SPDXRef-c6f5b29dca12661f" + }, + { + "spdxElementId": "SPDXRef-2a46171f91c8d4bc", + "relationshipType": "CONTAINS", + "relatedSpdxElement": "SPDXRef-f9e49132a4b96ccd" + } + ] +} diff --git a/syft/formats/spdx22json/to_format_model.go b/syft/formats/spdx22json/to_format_model.go index e232014e6..f55aee965 100644 --- a/syft/formats/spdx22json/to_format_model.go +++ b/syft/formats/spdx22json/to_format_model.go @@ -22,6 +22,8 @@ import ( func toFormatModel(s sbom.SBOM) *model.Document { name, namespace := spdxhelpers.DocumentNameAndNamespace(s.Source) + relationships := s.RelationshipsSorted() + return &model.Document{ Element: model.Element{ SPDXID: model.ElementID("DOCUMENT").String(), @@ -39,9 +41,9 @@ func toFormatModel(s sbom.SBOM) *model.Document { }, DataLicense: "CC0-1.0", DocumentNamespace: namespace, - Packages: toPackages(s.Artifacts.PackageCatalog, s.Relationships), + Packages: toPackages(s.Artifacts.PackageCatalog, relationships), Files: toFiles(s), - Relationships: toRelationships(s.Relationships), + Relationships: toRelationships(relationships), } } @@ -113,8 +115,8 @@ func fileIDsForPackage(packageSpdxID string, relationships []artifact.Relationsh } from := model.ElementID(relationship.From.ID()).String() - to := model.ElementID(relationship.To.ID()).String() if from == packageSpdxID { + to := model.ElementID(relationship.To.ID()).String() fileIDs = append(fileIDs, to) } } @@ -125,7 +127,7 @@ func toFiles(s sbom.SBOM) []model.File { results := make([]model.File, 0) artifacts := s.Artifacts - for _, coordinates := range sbom.AllCoordinates(s) { + for _, coordinates := range s.AllCoordinates() { var metadata *source.FileMetadata if metadataForLocation, exists := artifacts.FileMetadata[coordinates]; exists { metadata = &metadataForLocation @@ -160,6 +162,9 @@ func toFiles(s sbom.SBOM) []model.File { // sort by real path then virtual path to ensure the result is stable across multiple runs sort.SliceStable(results, func(i, j int) bool { + if results[i].FileName == results[j].FileName { + return results[i].SPDXID < results[j].SPDXID + } return results[i].FileName < results[j].FileName }) return results diff --git a/syft/formats/syftjson/to_format_model.go b/syft/formats/syftjson/to_format_model.go index 2d5fd84ab..9d4f4bfd4 100644 --- a/syft/formats/syftjson/to_format_model.go +++ b/syft/formats/syftjson/to_format_model.go @@ -93,7 +93,7 @@ func toFile(s sbom.SBOM) []model.File { results := make([]model.File, 0) artifacts := s.Artifacts - for _, coordinates := range sbom.AllCoordinates(s) { + for _, coordinates := range s.AllCoordinates() { var metadata *source.FileMetadata if metadataForLocation, exists := artifacts.FileMetadata[coordinates]; exists { metadata = &metadataForLocation diff --git a/syft/sbom/sbom.go b/syft/sbom/sbom.go index 76daf9d47..0f77567fc 100644 --- a/syft/sbom/sbom.go +++ b/syft/sbom/sbom.go @@ -1,6 +1,8 @@ package sbom import ( + "sort" + "github.com/anchore/syft/syft/artifact" "github.com/anchore/syft/syft/file" "github.com/anchore/syft/syft/linux" @@ -31,21 +33,35 @@ type Descriptor struct { Configuration interface{} } -func AllCoordinates(sbom SBOM) []source.Coordinates { +func (s SBOM) RelationshipsSorted() []artifact.Relationship { + relationships := s.Relationships + sort.SliceStable(relationships, func(i, j int) bool { + if relationships[i].From.ID() == relationships[j].From.ID() { + if relationships[i].To.ID() == relationships[j].To.ID() { + return relationships[i].Type < relationships[j].Type + } + return relationships[i].To.ID() < relationships[j].To.ID() + } + return relationships[i].From.ID() < relationships[j].From.ID() + }) + return relationships +} + +func (s SBOM) AllCoordinates() []source.Coordinates { set := source.NewCoordinateSet() - for coordinates := range sbom.Artifacts.FileMetadata { + for coordinates := range s.Artifacts.FileMetadata { set.Add(coordinates) } - for coordinates := range sbom.Artifacts.FileContents { + for coordinates := range s.Artifacts.FileContents { set.Add(coordinates) } - for coordinates := range sbom.Artifacts.FileClassifications { + for coordinates := range s.Artifacts.FileClassifications { set.Add(coordinates) } - for coordinates := range sbom.Artifacts.FileDigests { + for coordinates := range s.Artifacts.FileDigests { set.Add(coordinates) } - for _, relationship := range sbom.Relationships { + for _, relationship := range s.Relationships { for _, coordinates := range extractCoordinates(relationship) { set.Add(coordinates) }