Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions pkg/fanal/types/package.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
5 changes: 5 additions & 0 deletions pkg/sbom/core/bom.go
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
1 change: 1 addition & 0 deletions pkg/sbom/cyclonedx/marshal.go
Original file line number Diff line number Diff line change
Expand Up @@ -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{
Expand Down
184 changes: 184 additions & 0 deletions pkg/sbom/cyclonedx/marshal_test.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package cyclonedx_test

import (
"context"
"testing"
"time"

Expand Down Expand Up @@ -2428,3 +2429,186 @@ 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) {
ctx := clock.With(context.Background(), time.Date(2021, 8, 25, 12, 20, 30, 0, time.UTC))

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: "[email protected]",
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{
"[email protected]",
"[email protected]",
"[email protected]",
"[email protected]",
"[email protected]",
},
},
{
ID: "[email protected]",
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: "[email protected]",
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(ctx, 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/[email protected]" {
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/[email protected]")
assert.Contains(t, actualDeps, "pkg:maven/[email protected]")
}

/*
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:[email protected]",
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:[email protected]",
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:[email protected]",
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")
}
1 change: 1 addition & 0 deletions pkg/sbom/io/encode.go
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
3 changes: 3 additions & 0 deletions pkg/sbom/spdx/unmarshal.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down