From 5a4656540241a4b7183ad755eec269ae4d7dda02 Mon Sep 17 00:00:00 2001 From: Jaime Conde Date: Fri, 14 Nov 2025 09:52:28 +0100 Subject: [PATCH 1/2] fix(cyclonedx): duplicated entries in dependsOn --- pkg/sbom/cyclonedx/marshal.go | 1 + pkg/sbom/cyclonedx/marshal_test.go | 94 ++++++++++++++++++++++++++++++ 2 files changed, 95 insertions(+) diff --git a/pkg/sbom/cyclonedx/marshal.go b/pkg/sbom/cyclonedx/marshal.go index a6c3ba357569..cbd443a7e219 100644 --- a/pkg/sbom/cyclonedx/marshal.go +++ b/pkg/sbom/cyclonedx/marshal.go @@ -175,6 +175,7 @@ func (m *Marshaler) marshalDependencies() *[]cdx.Dependency { d, ok := m.componentIDs[rel.Dependency] return d, ok }) + deps = lo.Uniq(deps) sort.Strings(deps) dependencies = append(dependencies, cdx.Dependency{ diff --git a/pkg/sbom/cyclonedx/marshal_test.go b/pkg/sbom/cyclonedx/marshal_test.go index 11105e07e698..087f32d14b20 100644 --- a/pkg/sbom/cyclonedx/marshal_test.go +++ b/pkg/sbom/cyclonedx/marshal_test.go @@ -2428,3 +2428,97 @@ func TestMarshaler_Licenses(t *testing.T) { }) } } + +/* +TestMarshaler_DuplicateDependencies verifies that duplicate entries in the DependsOn slice +are properly deduplicated when marshaling to CycloneDX format. This ensures compliance with +the CycloneDX specification requirement that dependsOn arrays must contain unique values. +See: https://github.com/CycloneDX/specification/blob/b1675deb462444fede77a8508dd4d1aca6d1704b/schema/bom-1.4.schema.json#L911 +*/ +func TestMarshaler_DuplicateDependencies(t *testing.T) { + clock.SetFakeTime(t) + + inputReport := types.Report{ + SchemaVersion: report.SchemaVersion, + ArtifactName: "test-image", + ArtifactType: ftypes.TypeContainerImage, + Results: types.Results{ + { + Target: "test", + Class: types.ClassLangPkg, + Type: ftypes.Jar, + Packages: []ftypes.Package{ + { + ID: "pkg-a@1.0.0", + Name: "pkg-a", + Version: "1.0.0", + Identifier: ftypes.PkgIdentifier{ + UID: "A", + PURL: &packageurl.PackageURL{ + Type: packageurl.TypeMaven, + Name: "pkg-a", + Version: "1.0.0", + }, + }, + DependsOn: []string{ + "pkg-b@1.0.0", + "pkg-b@1.0.0", + "pkg-c@1.0.0", + "pkg-b@1.0.0", + "pkg-c@1.0.0", + }, + }, + { + ID: "pkg-b@1.0.0", + Name: "pkg-b", + Version: "1.0.0", + Identifier: ftypes.PkgIdentifier{ + UID: "B", + PURL: &packageurl.PackageURL{ + Type: packageurl.TypeMaven, + Name: "pkg-b", + Version: "1.0.0", + }, + }, + }, + { + ID: "pkg-c@1.0.0", + Name: "pkg-c", + Version: "1.0.0", + Identifier: ftypes.PkgIdentifier{ + UID: "C", + PURL: &packageurl.PackageURL{ + Type: packageurl.TypeMaven, + Name: "pkg-c", + Version: "1.0.0", + }, + }, + }, + }, + }, + }, + } + + marshaler := cyclonedx.NewMarshaler("dev") + bom, err := marshaler.MarshalReport(clock.NewContext(), inputReport) + require.NoError(t, err) + + require.NotNil(t, bom.Dependencies) + deps := *bom.Dependencies + + var pkgADeps *cdx.Dependency + for i := range deps { + if deps[i].Ref == "pkg:maven/pkg-a@1.0.0" { + pkgADeps = &deps[i] + break + } + } + + require.NotNil(t, pkgADeps, "pkg-a dependency not found") + require.NotNil(t, pkgADeps.Dependencies, "pkg-a dependencies is nil") + + actualDeps := *pkgADeps.Dependencies + assert.Len(t, actualDeps, 2, "expected 2 unique dependencies, got %d", len(actualDeps)) + assert.Contains(t, actualDeps, "pkg:maven/pkg-b@1.0.0") + assert.Contains(t, actualDeps, "pkg:maven/pkg-c@1.0.0") +} From a507a705c3242baeaf26bcbb47af9419195da95c Mon Sep 17 00:00:00 2001 From: Jaime Conde Date: Fri, 14 Nov 2025 12:11:33 +0100 Subject: [PATCH 2/2] fix(sbom): preserve SPDXID to maintain uniqueness for duplicate PURLs --- pkg/fanal/types/package.go | 1 + pkg/sbom/core/bom.go | 5 ++ pkg/sbom/cyclonedx/marshal_test.go | 94 +++++++++++++++++++++++++++++- pkg/sbom/io/encode.go | 1 + pkg/sbom/spdx/unmarshal.go | 3 + 5 files changed, 102 insertions(+), 2 deletions(-) diff --git a/pkg/fanal/types/package.go b/pkg/fanal/types/package.go index ce474f2858e8..d78e59b45f78 100644 --- a/pkg/fanal/types/package.go +++ b/pkg/fanal/types/package.go @@ -78,6 +78,7 @@ type PkgIdentifier struct { UID string `json:",omitempty"` // Calculated by the package struct PURL *packageurl.PackageURL `json:"-"` BOMRef string `json:",omitempty"` // For CycloneDX + SPDXID string `json:",omitempty"` // For SPDX } // MarshalJSON customizes the JSON encoding of PkgIdentifier. diff --git a/pkg/sbom/core/bom.go b/pkg/sbom/core/bom.go index 6003dfddd061..08bbd5e10df7 100644 --- a/pkg/sbom/core/bom.go +++ b/pkg/sbom/core/bom.go @@ -361,10 +361,15 @@ func (b *BOM) Parents() map[uuid.UUID][]uuid.UUID { // bomRef returns BOMRef for CycloneDX // When multiple lock files have the same dependency with the same name and version, PURL in the BOM can conflict. // In that case, PURL cannot be used as a unique identifier, and UUIDv4 be used for BOMRef. +// For SPDX files, SPDXID is used to maintain uniqueness when packages have the same PURL. func (b *BOM) bomRef(c *Component) string { if c.PkgIdentifier.BOMRef != "" { return c.PkgIdentifier.BOMRef } + // Use SPDXID if available (from SPDX files) to maintain uniqueness + if c.PkgIdentifier.SPDXID != "" { + return c.PkgIdentifier.SPDXID + } // Return the UUID of the component if the PURL is not present. if c.PkgIdentifier.PURL == nil { return c.id.String() diff --git a/pkg/sbom/cyclonedx/marshal_test.go b/pkg/sbom/cyclonedx/marshal_test.go index 087f32d14b20..ffabf1ebb5ca 100644 --- a/pkg/sbom/cyclonedx/marshal_test.go +++ b/pkg/sbom/cyclonedx/marshal_test.go @@ -1,6 +1,7 @@ package cyclonedx_test import ( + "context" "testing" "time" @@ -2436,7 +2437,7 @@ the CycloneDX specification requirement that dependsOn arrays must contain uniqu See: https://github.com/CycloneDX/specification/blob/b1675deb462444fede77a8508dd4d1aca6d1704b/schema/bom-1.4.schema.json#L911 */ func TestMarshaler_DuplicateDependencies(t *testing.T) { - clock.SetFakeTime(t) + ctx := clock.With(context.Background(), time.Date(2021, 8, 25, 12, 20, 30, 0, time.UTC)) inputReport := types.Report{ SchemaVersion: report.SchemaVersion, @@ -2500,7 +2501,7 @@ func TestMarshaler_DuplicateDependencies(t *testing.T) { } marshaler := cyclonedx.NewMarshaler("dev") - bom, err := marshaler.MarshalReport(clock.NewContext(), inputReport) + bom, err := marshaler.MarshalReport(ctx, inputReport) require.NoError(t, err) require.NotNil(t, bom.Dependencies) @@ -2522,3 +2523,92 @@ func TestMarshaler_DuplicateDependencies(t *testing.T) { assert.Contains(t, actualDeps, "pkg:maven/pkg-b@1.0.0") assert.Contains(t, actualDeps, "pkg:maven/pkg-c@1.0.0") } + +/* +TestMarshaler_SPDXIDUniqueness verifies that packages with the same PURL but different SPDXIDs +are treated as unique packages when converting from SPDX to CycloneDX format. This ensures that +SPDXID is preserved and used as a unique identifier when multiple packages share the same PURL. +*/ +func TestMarshaler_SPDXIDUniqueness(t *testing.T) { + ctx := clock.With(context.Background(), time.Date(2021, 8, 25, 12, 20, 30, 0, time.UTC)) + + inputReport := types.Report{ + SchemaVersion: report.SchemaVersion, + ArtifactName: "test-spdx", + ArtifactType: ftypes.TypeContainerImage, + Results: types.Results{ + { + Target: "test", + Class: types.ClassLangPkg, + Type: ftypes.Jar, + Packages: []ftypes.Package{ + { + ID: "org.postgresql:pljava@1.6.6", + Name: "org.postgresql:pljava", + Version: "1.6.6", + Identifier: ftypes.PkgIdentifier{ + UID: "A", + PURL: &packageurl.PackageURL{ + Type: packageurl.TypeMaven, + Namespace: "org.postgresql", + Name: "pljava", + Version: "1.6.6", + }, + SPDXID: "SPDXRef-Package-81b064a6dd4b165f", + }, + }, + { + ID: "org.postgresql:pljava@1.6.6", + Name: "org.postgresql:pljava", + Version: "1.6.6", + Identifier: ftypes.PkgIdentifier{ + UID: "B", + PURL: &packageurl.PackageURL{ + Type: packageurl.TypeMaven, + Namespace: "org.postgresql", + Name: "pljava", + Version: "1.6.6", + }, + SPDXID: "SPDXRef-Package-200e4c8a9fedcdb5", + }, + }, + { + ID: "org.postgresql:pljava@1.6.6", + Name: "org.postgresql:pljava", + Version: "1.6.6", + Identifier: ftypes.PkgIdentifier{ + UID: "C", + PURL: &packageurl.PackageURL{ + Type: packageurl.TypeMaven, + Namespace: "org.postgresql", + Name: "pljava", + Version: "1.6.6", + }, + SPDXID: "SPDXRef-Package-c30a860d16f62e1b", + }, + }, + }, + }, + }, + } + + marshaler := cyclonedx.NewMarshaler("dev") + bom, err := marshaler.MarshalReport(ctx, inputReport) + require.NoError(t, err) + + require.NotNil(t, bom.Components) + components := *bom.Components + + // Verify that all three packages are present with their unique SPDXIDs + assert.Len(t, components, 3, "expected 3 unique components with different SPDXIDs") + + // Verify each component has the correct SPDXID as its BOMRef + bomRefs := make([]string, len(components)) + for i, c := range components { + bomRefs[i] = c.BOMRef + } + + assert.Contains(t, bomRefs, "SPDXRef-Package-81b064a6dd4b165f") + assert.Contains(t, bomRefs, "SPDXRef-Package-200e4c8a9fedcdb5") + assert.Contains(t, bomRefs, "SPDXRef-Package-c30a860d16f62e1b") +} diff --git a/pkg/sbom/io/encode.go b/pkg/sbom/io/encode.go index a26cd2fbd856..425298a4c620 100644 --- a/pkg/sbom/io/encode.go +++ b/pkg/sbom/io/encode.go @@ -479,6 +479,7 @@ func (*Encoder) component(result types.Result, pkg ftypes.Package) *core.Compone UID: pkg.Identifier.UID, PURL: pkg.Identifier.PURL, BOMRef: pkg.Identifier.BOMRef, + SPDXID: pkg.Identifier.SPDXID, }, Supplier: pkg.Maintainer, Licenses: pkg.Licenses, diff --git a/pkg/sbom/spdx/unmarshal.go b/pkg/sbom/spdx/unmarshal.go index 1766bdea3042..a0fa29475650 100644 --- a/pkg/sbom/spdx/unmarshal.go +++ b/pkg/sbom/spdx/unmarshal.go @@ -167,6 +167,9 @@ func (s *SPDX) parsePackage(spdxPkg spdx.Package) (*core.Component, error) { Version: spdxPkg.PackageVersion, } + // Preserve SPDXID to maintain uniqueness for packages with same PURL + component.PkgIdentifier.SPDXID = string(spdxPkg.PackageSPDXIdentifier) + // PURL if component.PkgIdentifier.PURL, err = s.parseExternalReferences(spdxPkg.PackageExternalReferences); err != nil { return nil, xerrors.Errorf("external references error: %w", err)