Repository: chainguard-dev/apko (go)

Issue:
Inconsistent merging of upstream source packages from package SBOM into image SBOM
In https://github.com/chainguard-dev/melange/pull/1474, we updated melange to include SPDX packages in APK SBOMs to represent upstream sources (such as what's found in `git-checkout` and `fetch` pipeline steps).

And in https://github.com/chainguard-dev/apko/pull/1366, we fixed up some of the apko SBOM logic to account for melange's SBOM enhancements in the image SBOMs.

However, now we're seeing instances of **upstream source packages missing from the image SBOM**. For example, the Chainguard Image `cgr.dev/chainguard/jdk ` uses the Wolfi package `ca-certificates-bundle`, which exposes its upstream source in its package SBOM (see SPDX package with ID `SPDXRef-Package-gitlab.alpinelinux.org-alpine-ca-certificates...`). But that SPDX package is **not found** in the jdk image's SBOM.

```sh
cosign download attestation \
  --platform linux/amd64 \
  --predicate-type=https://spdx.dev/Document \
  cgr.dev/chainguard/jdk:latest | \
  jq '.payload | @base64d | fromjson | .predicate' | \
  grep 'SPDXRef-Package-gitlab.alpinelinux.org-alpine-ca-certificates'
```

Even stranger, it looks like **sometimes this problem doesn't happen**. The image `cgr.dev/chainguard/go` similarly uses `ca-certificates-bundle`, and its image SBOM **does have** the upstream source package intact.

```sh
cosign download attestation \
  --platform linux/amd64 \
  --predicate-type=https://spdx.dev/Document \
  cgr.dev/chainguard/go:latest | \
  jq '.payload | @base64d | fromjson | .predicate' | \
  grep 'SPDXRef-Package-gitlab.alpinelinux.org-alpine-ca-certificates'
```

We need to ensure that apko is consistently including all relevant information from the SBOMs of the packages that comprise the image into the image's SBOM.

cc: @dustinkirkland 

Pull request description:
sbom: resolve issues with missing/invalid image SBOM information
Prune a whole load of code out of the processing of APK SBOM's into the Image SBOM; this ensures that the SBOM for the Image is complete and we don't accidentally prune packages with the same name (which is not guaranteed to be unique).

I'm sure there is history here and I'm not 100% sure I understand why a generated SPDXRef was pushed to the Image SBOM document, only to be removed at the end of the APK SBOM processing.  Testing looks good based on comparing current APKO release with this PR.

Add a test case based on unbound to validate.

Fixes #1507

Signed-off-by: James Page <james.page@chainguard.dev>


Patch:
diff --git a/pkg/sbom/generator/spdx/spdx.go b/pkg/sbom/generator/spdx/spdx.go
index f1fe895..1baf2ff 100644
--- a/pkg/sbom/generator/spdx/spdx.go
+++ b/pkg/sbom/generator/spdx/spdx.go
@@ -141,31 +141,8 @@ func (sx *SPDX) Generate(opts *options.Options, path string) error {
 	}
 
 	for _, pkg := range opts.Packages {
-		// add the package
-		p := sx.apkPackage(opts, pkg)
-
-		nonce := "thismakestestspass"
-
-		// Add the image ID to the package ID to avoid clashes.
-		// NB: imagePackage.ID here is actually not important.
-		// The important thing is that p.ID is different from the package ID we
-		// would find in the corresponding APK SBOM. The ProcessInternalApkSBOM
-		// logic does a "JOIN" against the APK SBOM and replaces references to
-		// this ID with references to the APK's SBOM's Package ID, which means
-		// that logic falls over if they match. Removing imagePackage.ID makes
-		// them match, so we have to have _something_. You could replace
-		// imagePackage.ID with a UUID or "" and this would still be correct.
-		if imagePackage != nil {
-			nonce = imagePackage.ID
-		}
-		p.ID = stringToIdentifier(fmt.Sprintf(
-			"SPDXRef-Package-%s-%s-%s", nonce, pkg.Name, pkg.Version,
-		))
-
-		doc.Packages = append(doc.Packages, p)
-
 		// Check to see if the apk contains an sbom describing itself
-		if err := sx.ProcessInternalApkSBOM(opts, doc, &p, pkg); err != nil {
+		if err := sx.ProcessInternalApkSBOM(opts, doc, pkg); err != nil {
 			return fmt.Errorf("parsing internal apk SBOM: %w", err)
 		}
 	}
@@ -189,49 +166,15 @@ func (sx *SPDX) Generate(opts *options.Options, path string) error {
 	return nil
 }
 
-// replacePackage replaces a package with ID originalID with newID
-func replacePackage(doc *Document, originalID, newID string) {
-	// First check if package is described at the top of the SBOM
-	for i := range doc.DocumentDescribes {
-		if doc.DocumentDescribes[i] == originalID {
-			doc.DocumentDescribes[i] = newID
-			break
-		}
-	}
-
-	// Now, look at all relationships and replace
-	for i := range doc.Relationships {
-		if doc.Relationships[i].Element == originalID {
-			doc.Relationships[i].Element = newID
-		}
-		if doc.Relationships[i].Related == originalID {
-			doc.Relationships[i].Related = newID
-		}
-	}
-
-	// Remove the old ID from the package list
-	newPackages := []Package{}
-	replaced := false
-	for _, r := range doc.Packages {
-		if r.ID != originalID {
-			newPackages = append(newPackages, r)
-			replaced = true
-		}
-	}
-	if replaced {
-		doc.Packages = newPackages
-	}
-}
-
 // locateApkSBOM returns the path to the SBOM in the given filesystem, using the
 // given Package's name and version. It returns an empty string if the SBOM is
 // not found.
-func locateApkSBOM(fsys apkfs.FullFS, p *Package) (string, error) {
+func locateApkSBOM(fsys apkfs.FullFS, ipkg *apk.InstalledPackage) (string, error) {
 	re := regexp.MustCompile(`-r\d+$`)
 	for _, s := range []string{
-		fmt.Sprintf("%s/%s-%s.spdx.json", apkSBOMdir, p.Name, p.Version),
-		fmt.Sprintf("%s/%s-%s.spdx.json", apkSBOMdir, p.Name, re.ReplaceAllString(p.Version, "")),
-		fmt.Sprintf("%s/%s.spdx.json", apkSBOMdir, p.Name),
+		fmt.Sprintf("%s/%s-%s.spdx.json", apkSBOMdir, ipkg.Name, ipkg.Version),
+		fmt.Sprintf("%s/%s-%s.spdx.json", apkSBOMdir, ipkg.Name, re.ReplaceAllString(ipkg.Version, "")),
+		fmt.Sprintf("%s/%s.spdx.json", apkSBOMdir, ipkg.Name),
 	} {
 		info, err := fsys.Stat(s)
 		if err != nil {
@@ -249,9 +192,9 @@ func locateApkSBOM(fsys apkfs.FullFS, p *Package) (string, error) {
 	return "", nil
 }
 
-func (sx *SPDX) ProcessInternalApkSBOM(opts *options.Options, doc *Document, p *Package, ipkg *apk.InstalledPackage) error {
+func (sx *SPDX) ProcessInternalApkSBOM(opts *options.Options, doc *Document, ipkg *apk.InstalledPackage) error {
 	// Check if apk installed an SBOM
-	path, err := locateApkSBOM(sx.fs, p)
+	path, err := locateApkSBOM(sx.fs, ipkg)
 	if err != nil {
 		return fmt.Errorf("inspecting FS for internal apk SBOM: %w", err)
 	}
@@ -276,11 +219,6 @@ func (sx *SPDX) ProcessInternalApkSBOM(opts *options.Options, doc *Document, p *
 	// ... searching for a 1st level package
 	targetElementIDs := map[string]struct{}{}
 	for _, pkg := range apkSBOMDoc.Packages {
-		// that matches the name
-		if p.Name != pkg.Name {
-			continue
-		}
-
 		if _, ok := idsDescribedByAPKSBOM[pkg.ID]; !ok {
 			continue
 		}
@@ -306,18 +244,6 @@ func (sx *SPDX) ProcessInternalApkSBOM(opts *options.Options, doc *Document, p *
 		return fmt.Errorf("merging LicensingInfos: %w", err)
 	}
 
-	// TODO: This loop seems very wrong.
-	for id := range targetElementIDs {
-		// Search for a package in the new SBOM describing the same thing
-		for _, pkg := range doc.Packages {
-			// TODO: Think if we need to match version too
-			if pkg.Name == p.Name {
-				replacePackage(doc, pkg.ID, id)
-				break
-			}
-		}
-	}
-
 	return nil
 }
 
@@ -479,53 +405,6 @@ func (sx *SPDX) imagePackage(opts *options.Options) (p *Package) {
 	}
 }
 
-// apkPackage returns a SPDX package describing an apk
-func (sx *SPDX) apkPackage(opts *options.Options, pkg *apk.InstalledPackage) Package {
-	url := pkg.URL
-	if url == "" {
-		url = NOASSERTION
-	}
-	return Package{
-		ID: stringToIdentifier(fmt.Sprintf(
-			"SPDXRef-Package-%s-%s", pkg.Name, pkg.Version,
-		)),
-		Name:             pkg.Name,
-		Version:          pkg.Version,
-		Supplier:         supplier(opts),
-		FilesAnalyzed:    false,
-		LicenseConcluded: pkg.License,
-		Description:      pkg.Description,
-		DownloadLocation: url,
-		Originator:       fmt.Sprintf("Person: %s", pkg.Maintainer),
-		SourceInfo:       "Package info from apk database",
-		// This is APKv2 APKINDEX SHA1 file checksum
-		// https://wiki.alpinelinux.org/wiki/Apk_spec#Package_Checksum_Field
-		// This is the only meaningful and signed checksum
-		// right now. This can be upgrade to SHA256 when
-		// switching to the v3 index format. Whilst SPDX
-		// supports other checksums, there is currently no
-		// other checksum that one can verify in APKINDEX or
-		// query with apk-tools
-		Checksums: []Checksum{
-			{
-				Algorithm: "SHA1",
-				Value:     fmt.Sprintf("%x", pkg.Checksum),
-			},
-		},
-		ExternalRefs: []ExternalRef{
-			{
-				Category: ExtRefPackageManager,
-				Locator: purl.NewPackageURL(
-					"apk", opts.OS.ID, pkg.Name, pkg.Version,
-					purl.QualifiersFromMap(
-						map[string]string{"arch": opts.ImageInfo.Arch.ToAPK()},
-					), "").String(),
-				Type: ExtRefTypePurl,
-			},
-		},
-	}
-}
-
 // LayerPackage returns a package describing the layer
 func (sx *SPDX) layerPackage(opts *options.Options, layer v1.Descriptor) *Package {
 	layerPackageName := hashToString(layer.Digest)


Test patch:
diff --git a/pkg/sbom/generator/spdx/spdx_test.go b/pkg/sbom/generator/spdx/spdx_test.go
index 2de2f82..d26fbec 100644
--- a/pkg/sbom/generator/spdx/spdx_test.go
+++ b/pkg/sbom/generator/spdx/spdx_test.go
@@ -151,6 +151,40 @@ func TestSPDX_Generate(t *testing.T) {
 				},
 			},
 		},
+		{
+			name: "unbound-package-dedupe",
+			opts: &options.Options{
+				ImageInfo: options.ImageInfo{
+					Layers: []v1.Descriptor{{}},
+				},
+				OS: options.OSInfo{
+					Name:    "unknown",
+					ID:      "unknown",
+					Version: "3.0",
+				},
+				FileName: "sbom",
+				Packages: []*apk.InstalledPackage{
+					{
+						Package: apk.Package{
+							Name:    "unbound-libs",
+							Version: "1.23.0-r0",
+						},
+					},
+					{
+						Package: apk.Package{
+							Name:    "unbound",
+							Version: "1.23.0-r0",
+						},
+					},
+					{
+						Package: apk.Package{
+							Name:    "unbound-config",
+							Version: "1.23.0-r0",
+						},
+					},
+				},
+			},
+		},
 	}
 
 	for _, tt := range tests {
diff --git a/pkg/sbom/generator/spdx/testdata/apk_sboms/_generate.sh b/pkg/sbom/generator/spdx/testdata/apk_sboms/_generate.sh
index 31cfd9d..6404b34 100755
--- a/pkg/sbom/generator/spdx/testdata/apk_sboms/_generate.sh
+++ b/pkg/sbom/generator/spdx/testdata/apk_sboms/_generate.sh
@@ -8,6 +8,9 @@ packages=(
   "libattr1-2.5.1-r2"
   "logstash-8-8.15.3-r4"
   "logstash-8-compat-8.15.3-r4"
+  "unbound-1.23.0-r0"
+  "unbound-libs-1.23.0-r0"
+  "unbound-config-1.23.0-r0"
 )
 
 # Base URL for downloading APKs
diff --git a/pkg/sbom/generator/spdx/testdata/apk_sboms/unbound-1.23.0-r0.spdx.json b/pkg/sbom/generator/spdx/testdata/apk_sboms/unbound-1.23.0-r0.spdx.json
new file mode 100644
index 0000000..feb67c3
--- /dev/null
+++ b/pkg/sbom/generator/spdx/testdata/apk_sboms/unbound-1.23.0-r0.spdx.json
@@ -0,0 +1,87 @@
+{
+  "SPDXID": "SPDXRef-DOCUMENT",
+  "name": "apk-unbound-1.23.0-r0",
+  "spdxVersion": "SPDX-2.3",
+  "creationInfo": {
+    "created": "2025-04-24T08:25:21Z",
+    "creators": [
+      "Tool: melange (v0.23.9+dirty)",
+      "Organization: Chainguard, Inc"
+    ],
+    "licenseListVersion": "3.22"
+  },
+  "dataLicense": "CC0-1.0",
+  "documentNamespace": "https://spdx.org/spdxdocs/chainguard/melange/6107945343eac5611a87a2eb5a482b79",
+  "documentDescribes": [
+    "SPDXRef-Package-unbound-1.23.0-r0"
+  ],
+  "packages": [
+    {
+      "SPDXID": "SPDXRef-Package-unbound-1.23.0-r0",
+      "name": "unbound",
+      "versionInfo": "1.23.0-r0",
+      "filesAnalyzed": false,
+      "licenseConcluded": "NOASSERTION",
+      "licenseDeclared": "BSD-3-Clause",
+      "downloadLocation": "NOASSERTION",
+      "originator": "Organization: Wolfi",
+      "supplier": "Organization: Wolfi",
+      "copyrightText": "NOASSERTION",
+      "externalRefs": [
+        {
+          "referenceCategory": "PACKAGE-MANAGER",
+          "referenceLocator": "pkg:apk/wolfi/unbound@1.23.0-r0?arch=x86_64",
+          "referenceType": "purl"
+        }
+      ]
+    },
+    {
+      "SPDXID": "SPDXRef-Package-unbound.yaml-23e8ff8479b39f3f2e97fdca28d814f0c434c39b",
+      "name": "unbound.yaml",
+      "versionInfo": "23e8ff8479b39f3f2e97fdca28d814f0c434c39b",
+      "filesAnalyzed": false,
+      "licenseConcluded": "NOASSERTION",
+      "licenseDeclared": "Apache-2.0",
+      "downloadLocation": "NOASSERTION",
+      "originator": "Organization: Wolfi",
+      "supplier": "Organization: Wolfi",
+      "externalRefs": [
+        {
+          "referenceCategory": "PACKAGE-MANAGER",
+          "referenceLocator": "pkg:github/wolfi-dev/os@23e8ff8479b39f3f2e97fdca28d814f0c434c39b#unbound.yaml",
+          "referenceType": "purl"
+        }
+      ]
+    },
+    {
+      "SPDXID": "SPDXRef-Package-github.com-NLnetLabs-unbound-release-1.23.0-30c13d0351abd2edc3d6dc76365f576c87b9736e-0",
+      "name": "unbound",
+      "versionInfo": "release-1.23.0",
+      "filesAnalyzed": false,
+      "licenseConcluded": "NOASSERTION",
+      "licenseDeclared": "BSD-3-Clause",
+      "downloadLocation": "NOASSERTION",
+      "originator": "Organization: Nlnetlabs",
+      "supplier": "Organization: Nlnetlabs",
+      "externalRefs": [
+        {
+          "referenceCategory": "PACKAGE-MANAGER",
+          "referenceLocator": "pkg:github/nlnetlabs/unbound@release-1.23.0",
+          "referenceType": "purl"
+        }
+      ]
+    }
+  ],
+  "relationships": [
+    {
+      "spdxElementId": "SPDXRef-Package-unbound-1.23.0-r0",
+      "relationshipType": "DESCRIBED_BY",
+      "relatedSpdxElement": "SPDXRef-Package-unbound.yaml-23e8ff8479b39f3f2e97fdca28d814f0c434c39b"
+    },
+    {
+      "spdxElementId": "SPDXRef-Package-unbound-1.23.0-r0",
+      "relationshipType": "GENERATED_FROM",
+      "relatedSpdxElement": "SPDXRef-Package-github.com-NLnetLabs-unbound-release-1.23.0-30c13d0351abd2edc3d6dc76365f576c87b9736e-0"
+    }
+  ]
+}
diff --git a/pkg/sbom/generator/spdx/testdata/apk_sboms/unbound-config-1.23.0-r0.spdx.json b/pkg/sbom/generator/spdx/testdata/apk_sboms/unbound-config-1.23.0-r0.spdx.json
new file mode 100644
index 0000000..e427e53
--- /dev/null
+++ b/pkg/sbom/generator/spdx/testdata/apk_sboms/unbound-config-1.23.0-r0.spdx.json
@@ -0,0 +1,87 @@
+{
+  "SPDXID": "SPDXRef-DOCUMENT",
+  "name": "apk-unbound-config-1.23.0-r0",
+  "spdxVersion": "SPDX-2.3",
+  "creationInfo": {
+    "created": "2025-04-24T08:25:21Z",
+    "creators": [
+      "Tool: melange (v0.23.9+dirty)",
+      "Organization: Chainguard, Inc"
+    ],
+    "licenseListVersion": "3.22"
+  },
+  "dataLicense": "CC0-1.0",
+  "documentNamespace": "https://spdx.org/spdxdocs/chainguard/melange/6107945343eac5611a87a2eb5a482b79",
+  "documentDescribes": [
+    "SPDXRef-Package-unbound-config-1.23.0-r0"
+  ],
+  "packages": [
+    {
+      "SPDXID": "SPDXRef-Package-unbound-config-1.23.0-r0",
+      "name": "unbound-config",
+      "versionInfo": "1.23.0-r0",
+      "filesAnalyzed": false,
+      "licenseConcluded": "NOASSERTION",
+      "licenseDeclared": "BSD-3-Clause",
+      "downloadLocation": "NOASSERTION",
+      "originator": "Organization: Wolfi",
+      "supplier": "Organization: Wolfi",
+      "copyrightText": "NOASSERTION",
+      "externalRefs": [
+        {
+          "referenceCategory": "PACKAGE-MANAGER",
+          "referenceLocator": "pkg:apk/wolfi/unbound-config@1.23.0-r0?arch=x86_64",
+          "referenceType": "purl"
+        }
+      ]
+    },
+    {
+      "SPDXID": "SPDXRef-Package-unbound.yaml-23e8ff8479b39f3f2e97fdca28d814f0c434c39b",
+      "name": "unbound.yaml",
+      "versionInfo": "23e8ff8479b39f3f2e97fdca28d814f0c434c39b",
+      "filesAnalyzed": false,
+      "licenseConcluded": "NOASSERTION",
+      "licenseDeclared": "Apache-2.0",
+      "downloadLocation": "NOASSERTION",
+      "originator": "Organization: Wolfi",
+      "supplier": "Organization: Wolfi",
+      "externalRefs": [
+        {
+          "referenceCategory": "PACKAGE-MANAGER",
+          "referenceLocator": "pkg:github/wolfi-dev/os@23e8ff8479b39f3f2e97fdca28d814f0c434c39b#unbound.yaml",
+          "referenceType": "purl"
+        }
+      ]
+    },
+    {
+      "SPDXID": "SPDXRef-Package-github.com-NLnetLabs-unbound-release-1.23.0-30c13d0351abd2edc3d6dc76365f576c87b9736e-0",
+      "name": "unbound",
+      "versionInfo": "release-1.23.0",
+      "filesAnalyzed": false,
+      "licenseConcluded": "NOASSERTION",
+      "licenseDeclared": "BSD-3-Clause",
+      "downloadLocation": "NOASSERTION",
+      "originator": "Organization: Nlnetlabs",
+      "supplier": "Organization: Nlnetlabs",
+      "externalRefs": [
+        {
+          "referenceCategory": "PACKAGE-MANAGER",
+          "referenceLocator": "pkg:github/nlnetlabs/unbound@release-1.23.0",
+          "referenceType": "purl"
+        }
+      ]
+    }
+  ],
+  "relationships": [
+    {
+      "spdxElementId": "SPDXRef-Package-unbound-config-1.23.0-r0",
+      "relationshipType": "DESCRIBED_BY",
+      "relatedSpdxElement": "SPDXRef-Package-unbound.yaml-23e8ff8479b39f3f2e97fdca28d814f0c434c39b"
+    },
+    {
+      "spdxElementId": "SPDXRef-Package-unbound-config-1.23.0-r0",
+      "relationshipType": "GENERATED_FROM",
+      "relatedSpdxElement": "SPDXRef-Package-github.com-NLnetLabs-unbound-release-1.23.0-30c13d0351abd2edc3d6dc76365f576c87b9736e-0"
+    }
+  ]
+}
diff --git a/pkg/sbom/generator/spdx/testdata/apk_sboms/unbound-libs-1.23.0-r0.spdx.json b/pkg/sbom/generator/spdx/testdata/apk_sboms/unbound-libs-1.23.0-r0.spdx.json
new file mode 100644
index 0000000..625f90d
--- /dev/null
+++ b/pkg/sbom/generator/spdx/testdata/apk_sboms/unbound-libs-1.23.0-r0.spdx.json
@@ -0,0 +1,87 @@
+{
+  "SPDXID": "SPDXRef-DOCUMENT",
+  "name": "apk-unbound-libs-1.23.0-r0",
+  "spdxVersion": "SPDX-2.3",
+  "creationInfo": {
+    "created": "2025-04-24T08:25:21Z",
+    "creators": [
+      "Tool: melange (v0.23.9+dirty)",
+      "Organization: Chainguard, Inc"
+    ],
+    "licenseListVersion": "3.22"
+  },
+  "dataLicense": "CC0-1.0",
+  "documentNamespace": "https://spdx.org/spdxdocs/chainguard/melange/6107945343eac5611a87a2eb5a482b79",
+  "documentDescribes": [
+    "SPDXRef-Package-unbound-libs-1.23.0-r0"
+  ],
+  "packages": [
+    {
+      "SPDXID": "SPDXRef-Package-unbound-libs-1.23.0-r0",
+      "name": "unbound-libs",
+      "versionInfo": "1.23.0-r0",
+      "filesAnalyzed": false,
+      "licenseConcluded": "NOASSERTION",
+      "licenseDeclared": "BSD-3-Clause",
+      "downloadLocation": "NOASSERTION",
+      "originator": "Organization: Wolfi",
+      "supplier": "Organization: Wolfi",
+      "copyrightText": "NOASSERTION",
+      "externalRefs": [
+        {
+          "referenceCategory": "PACKAGE-MANAGER",
+          "referenceLocator": "pkg:apk/wolfi/unbound-libs@1.23.0-r0?arch=x86_64",
+          "referenceType": "purl"
+        }
+      ]
+    },
+    {
+      "SPDXID": "SPDXRef-Package-unbound.yaml-23e8ff8479b39f3f2e97fdca28d814f0c434c39b",
+      "name": "unbound.yaml",
+      "versionInfo": "23e8ff8479b39f3f2e97fdca28d814f0c434c39b",
+      "filesAnalyzed": false,
+      "licenseConcluded": "NOASSERTION",
+      "licenseDeclared": "Apache-2.0",
+      "downloadLocation": "NOASSERTION",
+      "originator": "Organization: Wolfi",
+      "supplier": "Organization: Wolfi",
+      "externalRefs": [
+        {
+          "referenceCategory": "PACKAGE-MANAGER",
+          "referenceLocator": "pkg:github/wolfi-dev/os@23e8ff8479b39f3f2e97fdca28d814f0c434c39b#unbound.yaml",
+          "referenceType": "purl"
+        }
+      ]
+    },
+    {
+      "SPDXID": "SPDXRef-Package-github.com-NLnetLabs-unbound-release-1.23.0-30c13d0351abd2edc3d6dc76365f576c87b9736e-0",
+      "name": "unbound",
+      "versionInfo": "release-1.23.0",
+      "filesAnalyzed": false,
+      "licenseConcluded": "NOASSERTION",
+      "licenseDeclared": "BSD-3-Clause",
+      "downloadLocation": "NOASSERTION",
+      "originator": "Organization: Nlnetlabs",
+      "supplier": "Organization: Nlnetlabs",
+      "externalRefs": [
+        {
+          "referenceCategory": "PACKAGE-MANAGER",
+          "referenceLocator": "pkg:github/nlnetlabs/unbound@release-1.23.0",
+          "referenceType": "purl"
+        }
+      ]
+    }
+  ],
+  "relationships": [
+    {
+      "spdxElementId": "SPDXRef-Package-unbound-libs-1.23.0-r0",
+      "relationshipType": "DESCRIBED_BY",
+      "relatedSpdxElement": "SPDXRef-Package-unbound.yaml-23e8ff8479b39f3f2e97fdca28d814f0c434c39b"
+    },
+    {
+      "spdxElementId": "SPDXRef-Package-unbound-libs-1.23.0-r0",
+      "relationshipType": "GENERATED_FROM",
+      "relatedSpdxElement": "SPDXRef-Package-github.com-NLnetLabs-unbound-release-1.23.0-30c13d0351abd2edc3d6dc76365f576c87b9736e-0"
+    }
+  ]
+}
diff --git a/pkg/sbom/generator/spdx/testdata/expected_image_sboms/unbound-package-dedupe.spdx.json b/pkg/sbom/generator/spdx/testdata/expected_image_sboms/unbound-package-dedupe.spdx.json
new file mode 100644
index 0000000..eeb4474
--- /dev/null
+++ b/pkg/sbom/generator/spdx/testdata/expected_image_sboms/unbound-package-dedupe.spdx.json
@@ -0,0 +1,161 @@
+{
+  "SPDXID": "SPDXRef-DOCUMENT",
+  "name": "sbom",
+  "spdxVersion": "SPDX-2.3",
+  "creationInfo": {
+    "created": "0001-01-01T00:00:00Z",
+    "creators": [
+      "Tool: apko (devel)",
+      "Organization: Chainguard, Inc"
+    ],
+    "licenseListVersion": "3.16"
+  },
+  "dataLicense": "CC0-1.0",
+  "documentNamespace": "https://spdx.org/spdxdocs/apko/",
+  "documentDescribes": [
+    "SPDXRef-Package-"
+  ],
+  "packages": [
+    {
+      "SPDXID": "SPDXRef-Package-",
+      "name": "",
+      "versionInfo": "3.0",
+      "filesAnalyzed": false,
+      "description": "apko operating system layer",
+      "downloadLocation": "NOASSERTION",
+      "supplier": "Organization: unknown",
+      "externalRefs": [
+        {
+          "referenceCategory": "PACKAGE-MANAGER",
+          "referenceLocator": "pkg:oci/image?mediaType=\u0026os=linux",
+          "referenceType": "purl"
+        }
+      ]
+    },
+    {
+      "SPDXID": "SPDXRef-Package-unbound-libs-1.23.0-r0",
+      "name": "unbound-libs",
+      "versionInfo": "1.23.0-r0",
+      "filesAnalyzed": false,
+      "licenseConcluded": "NOASSERTION",
+      "licenseDeclared": "BSD-3-Clause",
+      "downloadLocation": "NOASSERTION",
+      "originator": "Organization: Wolfi",
+      "supplier": "Organization: Wolfi",
+      "copyrightText": "NOASSERTION",
+      "externalRefs": [
+        {
+          "referenceCategory": "PACKAGE-MANAGER",
+          "referenceLocator": "pkg:apk/wolfi/unbound-libs@1.23.0-r0?arch=x86_64",
+          "referenceType": "purl"
+        }
+      ]
+    },
+    {
+      "SPDXID": "SPDXRef-Package-unbound.yaml-23e8ff8479b39f3f2e97fdca28d814f0c434c39b",
+      "name": "unbound.yaml",
+      "versionInfo": "23e8ff8479b39f3f2e97fdca28d814f0c434c39b",
+      "filesAnalyzed": false,
+      "licenseConcluded": "NOASSERTION",
+      "licenseDeclared": "Apache-2.0",
+      "downloadLocation": "NOASSERTION",
+      "originator": "Organization: Wolfi",
+      "supplier": "Organization: Wolfi",
+      "externalRefs": [
+        {
+          "referenceCategory": "PACKAGE-MANAGER",
+          "referenceLocator": "pkg:github/wolfi-dev/os@23e8ff8479b39f3f2e97fdca28d814f0c434c39b#unbound.yaml",
+          "referenceType": "purl"
+        }
+      ]
+    },
+    {
+      "SPDXID": "SPDXRef-Package-github.com-NLnetLabs-unbound-release-1.23.0-30c13d0351abd2edc3d6dc76365f576c87b9736e-0",
+      "name": "unbound",
+      "versionInfo": "release-1.23.0",
+      "filesAnalyzed": false,
+      "licenseConcluded": "NOASSERTION",
+      "licenseDeclared": "BSD-3-Clause",
+      "downloadLocation": "NOASSERTION",
+      "originator": "Organization: Nlnetlabs",
+      "supplier": "Organization: Nlnetlabs",
+      "externalRefs": [
+        {
+          "referenceCategory": "PACKAGE-MANAGER",
+          "referenceLocator": "pkg:github/nlnetlabs/unbound@release-1.23.0",
+          "referenceType": "purl"
+        }
+      ]
+    },
+    {
+      "SPDXID": "SPDXRef-Package-unbound-1.23.0-r0",
+      "name": "unbound",
+      "versionInfo": "1.23.0-r0",
+      "filesAnalyzed": false,
+      "licenseConcluded": "NOASSERTION",
+      "licenseDeclared": "BSD-3-Clause",
+      "downloadLocation": "NOASSERTION",
+      "originator": "Organization: Wolfi",
+      "supplier": "Organization: Wolfi",
+      "copyrightText": "NOASSERTION",
+      "externalRefs": [
+        {
+          "referenceCategory": "PACKAGE-MANAGER",
+          "referenceLocator": "pkg:apk/wolfi/unbound@1.23.0-r0?arch=x86_64",
+          "referenceType": "purl"
+        }
+      ]
+    },
+    {
+      "SPDXID": "SPDXRef-Package-unbound-config-1.23.0-r0",
+      "name": "unbound-config",
+      "versionInfo": "1.23.0-r0",
+      "filesAnalyzed": false,
+      "licenseConcluded": "NOASSERTION",
+      "licenseDeclared": "BSD-3-Clause",
+      "downloadLocation": "NOASSERTION",
+      "originator": "Organization: Wolfi",
+      "supplier": "Organization: Wolfi",
+      "copyrightText": "NOASSERTION",
+      "externalRefs": [
+        {
+          "referenceCategory": "PACKAGE-MANAGER",
+          "referenceLocator": "pkg:apk/wolfi/unbound-config@1.23.0-r0?arch=x86_64",
+          "referenceType": "purl"
+        }
+      ]
+    }
+  ],
+  "relationships": [
+    {
+      "spdxElementId": "SPDXRef-Package-unbound-libs-1.23.0-r0",
+      "relationshipType": "DESCRIBED_BY",
+      "relatedSpdxElement": "SPDXRef-Package-unbound.yaml-23e8ff8479b39f3f2e97fdca28d814f0c434c39b"
+    },
+    {
+      "spdxElementId": "SPDXRef-Package-unbound-libs-1.23.0-r0",
+      "relationshipType": "GENERATED_FROM",
+      "relatedSpdxElement": "SPDXRef-Package-github.com-NLnetLabs-unbound-release-1.23.0-30c13d0351abd2edc3d6dc76365f576c87b9736e-0"
+    },
+    {
+      "spdxElementId": "SPDXRef-Package-unbound-1.23.0-r0",
+      "relationshipType": "DESCRIBED_BY",
+      "relatedSpdxElement": "SPDXRef-Package-unbound.yaml-23e8ff8479b39f3f2e97fdca28d814f0c434c39b"
+    },
+    {
+      "spdxElementId": "SPDXRef-Package-unbound-1.23.0-r0",
+      "relationshipType": "GENERATED_FROM",
+      "relatedSpdxElement": "SPDXRef-Package-github.com-NLnetLabs-unbound-release-1.23.0-30c13d0351abd2edc3d6dc76365f576c87b9736e-0"
+    },
+    {
+      "spdxElementId": "SPDXRef-Package-unbound-config-1.23.0-r0",
+      "relationshipType": "DESCRIBED_BY",
+      "relatedSpdxElement": "SPDXRef-Package-unbound.yaml-23e8ff8479b39f3f2e97fdca28d814f0c434c39b"
+    },
+    {
+      "spdxElementId": "SPDXRef-Package-unbound-config-1.23.0-r0",
+      "relationshipType": "GENERATED_FROM",
+      "relatedSpdxElement": "SPDXRef-Package-github.com-NLnetLabs-unbound-release-1.23.0-30c13d0351abd2edc3d6dc76365f576c87b9736e-0"
+    }
+  ]
+}