From 168722d88099f31430b5b79f932ab51d97301bfe Mon Sep 17 00:00:00 2001
From: swadaptive <268603322+swadaptive@users.noreply.github.com>
Date: Thu, 29 Oct 2026 21:42:00 +0000
Subject: [PATCH] feat(cyclonedx): output source PURL as external reference
(#5246)
* Add tests for source packages in external references
Signed-off-by: swadaptive <268603322+swadaptive@users.noreply.github.com>
* Source distribution external reference utilities and test coverage
Signed-off-by: swadaptive <268603322+swadaptive@users.noreply.github.com>
* Add source-distribution external reference to components
Signed-off-by: swadaptive <268603322+swadaptive@users.noreply.github.com>
* Golden test fixtures
Signed-off-by: swadaptive <268603322+swadaptive@users.noreply.github.com>
* Adjust arch qualifier for source PURLs
Signed-off-by: swadaptive <268603322+swadaptive@users.noreply.github.com>
---------
Signed-off-by: swadaptive <268603322+swadaptive@users.noreply.github.com>
---
syft/format/cyclonedxjson/encoder_test.go | 14 +
.../TestCycloneDxSourcePackageEncoder.golden | 287 ++++++++++++++++++
syft/format/cyclonedxxml/encoder_test.go | 14 +
.../TestCycloneDxSourcePackageEncoder.golden | 131 ++++++++
.../cyclonedxutil/helpers/component.go | 10 +-
.../helpers/external_references.go | 86 ++++++
.../helpers/external_references_test.go | 238 +++++++++++++++
.../internal/testutil/source_package_input.go | 146 +++++++++
8 files changed, 925 insertions(+), 1 deletion(-)
create mode 100644 syft/format/cyclonedxjson/testdata/snapshot/TestCycloneDxSourcePackageEncoder.golden
create mode 100644 syft/format/cyclonedxxml/testdata/snapshot/TestCycloneDxSourcePackageEncoder.golden
create mode 100644 syft/format/internal/testutil/source_package_input.go
diff --git a/syft/format/cyclonedxjson/encoder_test.go b/syft/format/cyclonedxjson/encoder_test.go
index 00ece4a8a..8b57de976 100644
--- a/syft/format/cyclonedxjson/encoder_test.go
+++ b/syft/format/cyclonedxjson/encoder_test.go
@@ -114,6 +114,20 @@ func TestCycloneDxImageEncoder(t *testing.T) {
)
}
+func TestCycloneDxSourcePackageEncoder(t *testing.T) {
+ dir := t.TempDir()
+ testutil.AssertEncoderAgainstGoldenSnapshot(t,
+ testutil.EncoderSnapshotTestConfig{
+ Subject: testutil.SourcePackagesInput(t, dir),
+ Format: getEncoder(t),
+ UpdateSnapshot: *updateSnapshot,
+ PersistRedactionsInSnapshot: true,
+ IsJSON: true,
+ Redactor: redactor(dir),
+ },
+ )
+}
+
func redactor(values ...string) testutil.Redactor {
return testutil.NewRedactions().
WithValuesRedacted(values...).
diff --git a/syft/format/cyclonedxjson/testdata/snapshot/TestCycloneDxSourcePackageEncoder.golden b/syft/format/cyclonedxjson/testdata/snapshot/TestCycloneDxSourcePackageEncoder.golden
new file mode 100644
index 000000000..6995d4eae
--- /dev/null
+++ b/syft/format/cyclonedxjson/testdata/snapshot/TestCycloneDxSourcePackageEncoder.golden
@@ -0,0 +1,287 @@
+{
+ "$schema": "http://cyclonedx.org/schema/bom-1.7.schema.json",
+ "bomFormat": "CycloneDX",
+ "specVersion": "1.7",
+ "serialNumber": "urn:uuid:redacted",
+ "version": 1,
+ "metadata": {
+ "timestamp": "timestamp:redacted",
+ "tools": {
+ "components": [
+ {
+ "type": "application",
+ "author": "anchore",
+ "name": "syft",
+ "version": "v0.42.0-bogus"
+ }
+ ]
+ },
+ "component": {
+ "bom-ref": "redacted",
+ "type": "file",
+ "name": "some/path"
+ }
+ },
+ "components": [
+ {
+ "bom-ref": "pkg:deb/debian/attr@1%3A2.4.47-2%2Bb1?arch=amd64&distro=debian-12&package-id=5cc0e8979d7f5115&upstream=attr%401%3A2.4.47-2",
+ "type": "library",
+ "name": "attr",
+ "version": "1:2.4.47-2+b1",
+ "purl": "pkg:deb/debian/attr@1%3A2.4.47-2%2Bb1?arch=amd64&distro=debian-12&upstream=attr%401%3A2.4.47-2",
+ "externalReferences": [
+ {
+ "url": "pkg:deb/debian/attr@1%3A2.4.47-2?arch=source&distro=debian-12",
+ "type": "source-distribution"
+ }
+ ],
+ "properties": [
+ {
+ "name": "syft:package:foundBy",
+ "value": "dpkg-db-cataloger"
+ },
+ {
+ "name": "syft:package:type",
+ "value": "deb"
+ },
+ {
+ "name": "syft:package:metadataType",
+ "value": "dpkg-db-entry"
+ },
+ {
+ "name": "syft:location:0:path",
+ "value": "/var/lib/dpkg/status"
+ },
+ {
+ "name": "syft:metadata:installedSize",
+ "value": "0"
+ },
+ {
+ "name": "syft:metadata:source",
+ "value": "attr"
+ },
+ {
+ "name": "syft:metadata:sourceVersion",
+ "value": "1:2.4.47-2"
+ }
+ ]
+ },
+ {
+ "bom-ref": "pkg:rpm/centos/bind-export-libs@9.11.13-3.el8?arch=x86_64&distro=centos-8&epoch=32&package-id=6008fca05ca7b418&upstream=bind-9.11.13-3.el8.src.rpm",
+ "type": "library",
+ "name": "bind-export-libs",
+ "version": "32:9.11.13-3.el8",
+ "purl": "pkg:rpm/centos/bind-export-libs@9.11.13-3.el8?arch=x86_64&distro=centos-8&epoch=32&upstream=bind-9.11.13-3.el8.src.rpm",
+ "externalReferences": [
+ {
+ "url": "pkg:rpm/centos/bind@9.11.13-3.el8?arch=src&distro=centos-8&epoch=32",
+ "type": "source-distribution"
+ }
+ ],
+ "properties": [
+ {
+ "name": "syft:package:foundBy",
+ "value": "rpm-db-cataloger"
+ },
+ {
+ "name": "syft:package:type",
+ "value": "rpm"
+ },
+ {
+ "name": "syft:package:metadataType",
+ "value": "rpm-db-entry"
+ },
+ {
+ "name": "syft:location:0:path",
+ "value": "/var/lib/rpm/Packages"
+ },
+ {
+ "name": "syft:metadata:epoch",
+ "value": "32"
+ },
+ {
+ "name": "syft:metadata:release",
+ "value": "3.el8"
+ },
+ {
+ "name": "syft:metadata:size",
+ "value": "0"
+ },
+ {
+ "name": "syft:metadata:sourceRpm",
+ "value": "bind-9.11.13-3.el8.src.rpm"
+ }
+ ]
+ },
+ {
+ "bom-ref": "pkg:apk/alpine/busybox@1.35.0-r17?arch=x86_64&distro=alpine-3.16.3&package-id=aa74af1190de1c40",
+ "type": "library",
+ "name": "busybox",
+ "version": "1.35.0-r17",
+ "purl": "pkg:apk/alpine/busybox@1.35.0-r17?arch=x86_64&distro=alpine-3.16.3",
+ "properties": [
+ {
+ "name": "syft:package:foundBy",
+ "value": "apk-db-cataloger"
+ },
+ {
+ "name": "syft:package:type",
+ "value": "apk"
+ },
+ {
+ "name": "syft:package:metadataType",
+ "value": "apk-db-entry"
+ },
+ {
+ "name": "syft:location:0:path",
+ "value": "/lib/apk/db/installed"
+ },
+ {
+ "name": "syft:metadata:installedSize",
+ "value": "0"
+ },
+ {
+ "name": "syft:metadata:originPackage",
+ "value": "busybox"
+ },
+ {
+ "name": "syft:metadata:size",
+ "value": "0"
+ }
+ ]
+ },
+ {
+ "bom-ref": "pkg:alpm/arch/gcc-libs@13.2.1-3?arch=x86_64&distro=arch-rolling&package-id=58a2fcf749a9a3cc&upstream=gcc",
+ "type": "library",
+ "name": "gcc-libs",
+ "version": "13.2.1-3",
+ "purl": "pkg:alpm/arch/gcc-libs@13.2.1-3?arch=x86_64&distro=arch-rolling&upstream=gcc",
+ "externalReferences": [
+ {
+ "url": "pkg:alpm/arch/gcc@13.2.1-3?distro=arch-rolling",
+ "type": "source-distribution"
+ }
+ ],
+ "properties": [
+ {
+ "name": "syft:package:foundBy",
+ "value": "alpm-db-cataloger"
+ },
+ {
+ "name": "syft:package:type",
+ "value": "alpm"
+ },
+ {
+ "name": "syft:package:metadataType",
+ "value": "alpm-db-entry"
+ },
+ {
+ "name": "syft:location:0:path",
+ "value": "/var/lib/pacman/local/gcc-libs-13.2.1-3/desc"
+ },
+ {
+ "name": "syft:metadata:architecture",
+ "value": "x86_64"
+ },
+ {
+ "name": "syft:metadata:basepackage",
+ "value": "gcc"
+ },
+ {
+ "name": "syft:metadata:package",
+ "value": "gcc-libs"
+ },
+ {
+ "name": "syft:metadata:size",
+ "value": "0"
+ },
+ {
+ "name": "syft:metadata:version",
+ "value": "13.2.1-3"
+ }
+ ]
+ },
+ {
+ "bom-ref": "pkg:apk/alpine/libc-utils@0.7.2-r3?arch=x86_64&distro=alpine-3.16.3&package-id=16a8c7b19fde6f4f&upstream=libc-dev",
+ "type": "library",
+ "name": "libc-utils",
+ "version": "0.7.2-r3",
+ "purl": "pkg:apk/alpine/libc-utils@0.7.2-r3?arch=x86_64&distro=alpine-3.16.3&upstream=libc-dev",
+ "externalReferences": [
+ {
+ "url": "pkg:apk/alpine/libc-dev@0.7.2-r3?distro=alpine-3.16.3",
+ "type": "source-distribution"
+ }
+ ],
+ "properties": [
+ {
+ "name": "syft:package:foundBy",
+ "value": "apk-db-cataloger"
+ },
+ {
+ "name": "syft:package:type",
+ "value": "apk"
+ },
+ {
+ "name": "syft:package:metadataType",
+ "value": "apk-db-entry"
+ },
+ {
+ "name": "syft:location:0:path",
+ "value": "/lib/apk/db/installed"
+ },
+ {
+ "name": "syft:metadata:installedSize",
+ "value": "0"
+ },
+ {
+ "name": "syft:metadata:originPackage",
+ "value": "libc-dev"
+ },
+ {
+ "name": "syft:metadata:size",
+ "value": "0"
+ }
+ ]
+ },
+ {
+ "bom-ref": "pkg:deb/debian/libpam-runtime@1.5.2-6%2Bdeb12u1?arch=all&distro=debian-12&package-id=7b4ddc97df57aec4&upstream=pam",
+ "type": "library",
+ "name": "libpam-runtime",
+ "version": "1.5.2-6+deb12u1",
+ "purl": "pkg:deb/debian/libpam-runtime@1.5.2-6%2Bdeb12u1?arch=all&distro=debian-12&upstream=pam",
+ "externalReferences": [
+ {
+ "url": "pkg:deb/debian/pam@1.5.2-6%2Bdeb12u1?arch=source&distro=debian-12",
+ "type": "source-distribution"
+ }
+ ],
+ "properties": [
+ {
+ "name": "syft:package:foundBy",
+ "value": "dpkg-db-cataloger"
+ },
+ {
+ "name": "syft:package:type",
+ "value": "deb"
+ },
+ {
+ "name": "syft:package:metadataType",
+ "value": "dpkg-db-entry"
+ },
+ {
+ "name": "syft:location:0:path",
+ "value": "/var/lib/dpkg/status"
+ },
+ {
+ "name": "syft:metadata:installedSize",
+ "value": "0"
+ },
+ {
+ "name": "syft:metadata:source",
+ "value": "pam"
+ }
+ ]
+ }
+ ]
+}
diff --git a/syft/format/cyclonedxxml/encoder_test.go b/syft/format/cyclonedxxml/encoder_test.go
index 5f25bec76..c38bd1363 100644
--- a/syft/format/cyclonedxxml/encoder_test.go
+++ b/syft/format/cyclonedxxml/encoder_test.go
@@ -87,6 +87,20 @@ func TestCycloneDxImageEncoder(t *testing.T) {
)
}
+func TestCycloneDxSourcePackageEncoder(t *testing.T) {
+ dir := t.TempDir()
+ testutil.AssertEncoderAgainstGoldenSnapshot(t,
+ testutil.EncoderSnapshotTestConfig{
+ Subject: testutil.SourcePackagesInput(t, dir),
+ Format: getEncoder(t),
+ UpdateSnapshot: *updateSnapshot,
+ PersistRedactionsInSnapshot: true,
+ IsJSON: false,
+ Redactor: redactor(dir),
+ },
+ )
+}
+
func redactor(values ...string) testutil.Redactor {
return testutil.NewRedactions().
WithValuesRedacted(values...).
diff --git a/syft/format/cyclonedxxml/testdata/snapshot/TestCycloneDxSourcePackageEncoder.golden b/syft/format/cyclonedxxml/testdata/snapshot/TestCycloneDxSourcePackageEncoder.golden
new file mode 100644
index 000000000..830062cdb
--- /dev/null
+++ b/syft/format/cyclonedxxml/testdata/snapshot/TestCycloneDxSourcePackageEncoder.golden
@@ -0,0 +1,131 @@
+
+
+
+ redacted
+
+
+
+ anchore
+ syft
+ v0.42.0-bogus
+
+
+
+
+ some/path
+
+
+
+
+ attr
+ 1:2.4.47-2+b1
+ pkg:deb/debian/attr@1%3A2.4.47-2%2Bb1?arch=amd64&distro=debian-12&upstream=attr%401%3A2.4.47-2
+
+
+ pkg:deb/debian/attr@1%3A2.4.47-2?arch=source&distro=debian-12
+
+
+
+ dpkg-db-cataloger
+ deb
+ dpkg-db-entry
+ /var/lib/dpkg/status
+ 0
+ attr
+ 1:2.4.47-2
+
+
+
+ bind-export-libs
+ 32:9.11.13-3.el8
+ pkg:rpm/centos/bind-export-libs@9.11.13-3.el8?arch=x86_64&distro=centos-8&epoch=32&upstream=bind-9.11.13-3.el8.src.rpm
+
+
+ pkg:rpm/centos/bind@9.11.13-3.el8?arch=src&distro=centos-8&epoch=32
+
+
+
+ rpm-db-cataloger
+ rpm
+ rpm-db-entry
+ /var/lib/rpm/Packages
+ 32
+ 3.el8
+ 0
+ bind-9.11.13-3.el8.src.rpm
+
+
+
+ busybox
+ 1.35.0-r17
+ pkg:apk/alpine/busybox@1.35.0-r17?arch=x86_64&distro=alpine-3.16.3
+
+ apk-db-cataloger
+ apk
+ apk-db-entry
+ /lib/apk/db/installed
+ 0
+ busybox
+ 0
+
+
+
+ gcc-libs
+ 13.2.1-3
+ pkg:alpm/arch/gcc-libs@13.2.1-3?arch=x86_64&distro=arch-rolling&upstream=gcc
+
+
+ pkg:alpm/arch/gcc@13.2.1-3?distro=arch-rolling
+
+
+
+ alpm-db-cataloger
+ alpm
+ alpm-db-entry
+ /var/lib/pacman/local/gcc-libs-13.2.1-3/desc
+ x86_64
+ gcc
+ gcc-libs
+ 0
+ 13.2.1-3
+
+
+
+ libc-utils
+ 0.7.2-r3
+ pkg:apk/alpine/libc-utils@0.7.2-r3?arch=x86_64&distro=alpine-3.16.3&upstream=libc-dev
+
+
+ pkg:apk/alpine/libc-dev@0.7.2-r3?distro=alpine-3.16.3
+
+
+
+ apk-db-cataloger
+ apk
+ apk-db-entry
+ /lib/apk/db/installed
+ 0
+ libc-dev
+ 0
+
+
+
+ libpam-runtime
+ 1.5.2-6+deb12u1
+ pkg:deb/debian/libpam-runtime@1.5.2-6%2Bdeb12u1?arch=all&distro=debian-12&upstream=pam
+
+
+ pkg:deb/debian/pam@1.5.2-6%2Bdeb12u1?arch=source&distro=debian-12
+
+
+
+ dpkg-db-cataloger
+ deb
+ dpkg-db-entry
+ /var/lib/dpkg/status
+ 0
+ pam
+
+
+
+
\ No newline at end of file
diff --git a/syft/format/internal/cyclonedxutil/helpers/component.go b/syft/format/internal/cyclonedxutil/helpers/component.go
index 2e289fc84..dc788f5ba 100644
--- a/syft/format/internal/cyclonedxutil/helpers/component.go
+++ b/syft/format/internal/cyclonedxutil/helpers/component.go
@@ -47,6 +47,14 @@ func EncodeComponent(p pkg.Package, supplier string, locationSorter func(a, b fi
componentType = cyclonedx.ComponentTypeMachineLearningModel
}
+ externalRefs := encodeExternalReferences(p)
+ if srcRef := encodeSourcePackageExternalReference(p); srcRef != nil {
+ if externalRefs == nil {
+ externalRefs = &[]cyclonedx.ExternalReference{}
+ }
+ *externalRefs = append(*externalRefs, *srcRef)
+ }
+
return cyclonedx.Component{
Type: componentType,
Name: p.Name,
@@ -59,7 +67,7 @@ func EncodeComponent(p pkg.Package, supplier string, locationSorter func(a, b fi
Author: encodeAuthor(p),
Publisher: encodePublisher(p),
Description: encodeDescription(p),
- ExternalReferences: encodeExternalReferences(p),
+ ExternalReferences: externalRefs,
Properties: properties,
BOMRef: DeriveBomRef(p),
}
diff --git a/syft/format/internal/cyclonedxutil/helpers/external_references.go b/syft/format/internal/cyclonedxutil/helpers/external_references.go
index 4f7762142..2d2f45a56 100644
--- a/syft/format/internal/cyclonedxutil/helpers/external_references.go
+++ b/syft/format/internal/cyclonedxutil/helpers/external_references.go
@@ -7,6 +7,7 @@ import (
"github.com/CycloneDX/cyclonedx-go"
+ "github.com/anchore/packageurl-go"
"github.com/anchore/syft/internal/file"
syftFile "github.com/anchore/syft/syft/file"
"github.com/anchore/syft/syft/pkg"
@@ -79,12 +80,97 @@ func encodeExternalReferences(p pkg.Package) *[]cyclonedx.ExternalReference {
}
}
}
+
if len(refs) > 0 {
return &refs
}
return nil
}
+func encodeSourcePackageExternalReference(p pkg.Package) *cyclonedx.ExternalReference {
+ name, version, arch := sourceCoordinates(p)
+ if name == "" || version == "" {
+ return nil
+ }
+ if p.PURL == "" {
+ return nil
+ }
+
+ pkgPurl, err := packageurl.FromString(p.PURL)
+ if err != nil {
+ return nil
+ }
+
+ qualifiers := pkgPurl.Qualifiers.Map()
+ delete(qualifiers, pkg.PURLQualifierUpstream)
+ delete(qualifiers, pkg.PURLQualifierArch)
+
+ if arch != "" {
+ qualifiers[pkg.PURLQualifierArch] = arch
+ }
+
+ srcPurl := packageurl.NewPackageURL(
+ pkgPurl.Type,
+ pkgPurl.Namespace,
+ name,
+ version,
+ packageurl.QualifiersFromMap(qualifiers),
+ pkgPurl.Subpath,
+ )
+
+ return &cyclonedx.ExternalReference{
+ Type: cyclonedx.ERTypeSourceDistribution,
+ URL: srcPurl.ToString(),
+ }
+}
+
+func sourceCoordinates(p pkg.Package) (name, version, arch string) {
+ switch metadata := p.Metadata.(type) {
+ case pkg.ApkDBEntry:
+ if metadata.OriginPackage != "" && metadata.OriginPackage != metadata.Package {
+ return metadata.OriginPackage, metadata.Version, ""
+ }
+ case pkg.DpkgDBEntry:
+ return dpkgSourceCoordinates(metadata)
+ case pkg.DpkgArchiveEntry:
+ return dpkgSourceCoordinates(pkg.DpkgDBEntry(metadata))
+ case pkg.RpmDBEntry:
+ return rpmSourceCoordinates(metadata.SourceRpm)
+ case pkg.RpmArchive:
+ return rpmSourceCoordinates(metadata.SourceRpm)
+ case pkg.AlpmDBEntry:
+ if metadata.BasePackage != "" {
+ return metadata.BasePackage, metadata.Version, ""
+ }
+ }
+ return "", "", ""
+}
+
+func dpkgSourceCoordinates(entry pkg.DpkgDBEntry) (name, version, arch string) {
+ if entry.Source == "" {
+ return "", "", ""
+ }
+ if entry.SourceVersion != "" {
+ return entry.Source, entry.SourceVersion, "source"
+ }
+ return entry.Source, entry.Version, "source"
+}
+
+func rpmSourceCoordinates(sourceRpm string) (name, version, arch string) {
+ // --.src.rpm
+ sourceRpm = strings.TrimSuffix(sourceRpm, ".rpm")
+ sourceRpm = strings.TrimSuffix(strings.TrimSuffix(sourceRpm, ".src"), ".nosrc")
+ release := strings.LastIndex(sourceRpm, "-")
+ if release < 0 {
+ return "", "", ""
+ }
+ ver := strings.LastIndex(sourceRpm[:release], "-")
+ if ver < 0 {
+ return "", "", ""
+ }
+ return sourceRpm[:ver], sourceRpm[ver+1:], "src"
+}
+
// supported algorithm in cycloneDX as of 1.4
// "MD5", "SHA-1", "SHA-256", "SHA-384", "SHA-512",
// "SHA3-256", "SHA3-384", "SHA3-512", "BLAKE2b-256", "BLAKE2b-384", "BLAKE2b-512", "BLAKE3"
diff --git a/syft/format/internal/cyclonedxutil/helpers/external_references_test.go b/syft/format/internal/cyclonedxutil/helpers/external_references_test.go
index 6da98024e..b91d590f0 100644
--- a/syft/format/internal/cyclonedxutil/helpers/external_references_test.go
+++ b/syft/format/internal/cyclonedxutil/helpers/external_references_test.go
@@ -144,6 +144,244 @@ func Test_encodeExternalReferences(t *testing.T) {
}
}
+func Test_encodeSourcePackage(t *testing.T) {
+ rpmEpoch := 32
+ tests := []struct {
+ name string
+ input pkg.Package
+ expected *cyclonedx.ExternalReference
+ }{
+ {
+ name: "no metadata",
+ input: pkg.Package{},
+ expected: nil,
+ },
+ {
+ name: "no PURL",
+ input: pkg.Package{
+ Name: "some-package",
+ Version: "1.0.0-r1",
+ Type: pkg.ApkPkg,
+ Metadata: pkg.ApkDBEntry{
+ Package: "some-package",
+ OriginPackage: "some-package",
+ Version: "1.0.0-r1",
+ Architecture: "x86_64",
+ },
+ },
+ expected: nil,
+ },
+ {
+ name: "from apk",
+ input: pkg.Package{
+ Name: "libc-utils",
+ Version: "0.7.2-r3",
+ Type: pkg.ApkPkg,
+ PURL: "pkg:apk/alpine/libc-utils@0.7.2-r3?arch=x86_64&distro=alpine-3.16.3&upstream=libc-dev",
+ Metadata: pkg.ApkDBEntry{
+ Package: "libc-utils",
+ OriginPackage: "libc-dev",
+ Version: "0.7.2-r3",
+ Architecture: "x86_64",
+ },
+ },
+ expected: &cyclonedx.ExternalReference{
+ Type: cyclonedx.ERTypeSourceDistribution, URL: "pkg:apk/alpine/libc-dev@0.7.2-r3?distro=alpine-3.16.3",
+ },
+ },
+ {
+ // Matching existing implementation where upstream qualifier is omitted when OriginPackage = Package for Alpine
+ name: "from apk with source = bin",
+ input: pkg.Package{
+ Name: "some-package",
+ Version: "1.0.0-r1",
+ Type: pkg.ApkPkg,
+ PURL: "pkg:apk/alpine/some-package@1.0.0-r1?arch=x86_64&distro=alpine-3.16.3",
+ Metadata: pkg.ApkDBEntry{
+ Package: "some-package",
+ OriginPackage: "some-package",
+ Version: "1.0.0-r1",
+ Architecture: "x86_64",
+ },
+ },
+ expected: nil,
+ },
+ {
+ name: "from deb",
+ input: pkg.Package{
+ Name: "libpam-runtime",
+ Version: "1.5.2-6+deb12u1",
+ Type: pkg.DebPkg,
+ PURL: "pkg:deb/debian/libpam-runtime@1.5.2-6%2Bdeb12u1?arch=all&distro=debian-12&upstream=pam",
+ Metadata: pkg.DpkgDBEntry{
+ Package: "libpam-runtime",
+ Version: "1.5.2-6+deb12u1",
+ Source: "pam",
+ Architecture: "all",
+ },
+ },
+ expected: &cyclonedx.ExternalReference{
+ Type: cyclonedx.ERTypeSourceDistribution, URL: "pkg:deb/debian/pam@1.5.2-6%2Bdeb12u1?arch=source&distro=debian-12",
+ },
+ },
+ {
+ name: "from deb with source version",
+ input: pkg.Package{
+ Name: "attr",
+ Version: "1:2.4.47-2+b1",
+ Type: pkg.DebPkg,
+ PURL: "pkg:deb/debian/attr@1%3A2.4.47-2%2Bb1?arch=amd64&distro=debian-12&upstream=attr%401%3A2.4.47-2",
+ Metadata: pkg.DpkgDBEntry{
+ Package: "attr",
+ Version: "1:2.4.47-2+b1",
+ Source: "attr",
+ SourceVersion: "1:2.4.47-2",
+ Architecture: "amd64",
+ },
+ },
+ expected: &cyclonedx.ExternalReference{
+ Type: cyclonedx.ERTypeSourceDistribution, URL: "pkg:deb/debian/attr@1%3A2.4.47-2?arch=source&distro=debian-12",
+ },
+ },
+ {
+ name: "from deb with empty source",
+ input: pkg.Package{
+ Name: "some-package",
+ Version: "1.0.0",
+ Type: pkg.DebPkg,
+ PURL: "pkg:deb/debian/some-package@1.0.0?arch=all&distro=debian-12",
+ Metadata: pkg.DpkgDBEntry{
+ Package: "some-package",
+ Version: "1.0.0",
+ Architecture: "all",
+ },
+ },
+ expected: nil,
+ },
+ {
+ name: "from deb archive",
+ input: pkg.Package{
+ Name: "libpam-runtime",
+ Version: "1.5.2-6+deb12u1",
+ Type: pkg.DebPkg,
+ PURL: "pkg:deb/libpam-runtime@1.5.2-6%2Bdeb12u1?arch=all&upstream=pam",
+ Metadata: pkg.DpkgArchiveEntry{
+ Package: "libpam-runtime",
+ Version: "1.5.2-6+deb12u1",
+ Source: "pam",
+ Architecture: "all",
+ },
+ },
+ expected: &cyclonedx.ExternalReference{
+ Type: cyclonedx.ERTypeSourceDistribution, URL: "pkg:deb/pam@1.5.2-6%2Bdeb12u1?arch=source",
+ },
+ },
+ {
+ name: "from rpm",
+ input: pkg.Package{
+ Name: "bind-export-libs",
+ Version: "32:9.11.13-3.el8",
+ Type: pkg.RpmPkg,
+ PURL: "pkg:rpm/centos/bind-export-libs@9.11.13-3.el8?arch=x86_64&distro=centos-8&epoch=32&upstream=bind-9.11.13-3.el8.src.rpm",
+ Metadata: pkg.RpmDBEntry{
+ Name: "bind-export-libs",
+ Version: "9.11.13",
+ Release: "3.el8",
+ Epoch: &rpmEpoch,
+ Arch: "x86_64",
+ SourceRpm: "bind-9.11.13-3.el8.src.rpm",
+ },
+ },
+ expected: &cyclonedx.ExternalReference{
+ Type: cyclonedx.ERTypeSourceDistribution, URL: "pkg:rpm/centos/bind@9.11.13-3.el8?arch=src&distro=centos-8&epoch=32",
+ },
+ },
+ {
+ name: "from rpm with source = bin",
+ input: pkg.Package{
+ Name: "some-package",
+ Version: "32:1.0.0-1.el8",
+ Type: pkg.RpmPkg,
+ PURL: "pkg:rpm/centos/some-package@1.0.0-1.el8?arch=x86_64&distro=centos-8&epoch=32&upstream=some-package-1.0.0-1.el8.src.rpm",
+ Metadata: pkg.RpmDBEntry{
+ Name: "some-package",
+ Version: "1.0.0",
+ Release: "1.el8",
+ Epoch: &rpmEpoch,
+ Arch: "x86_64",
+ SourceRpm: "some-package-1.0.0-1.el8.src.rpm",
+ },
+ },
+ expected: &cyclonedx.ExternalReference{
+ Type: cyclonedx.ERTypeSourceDistribution, URL: "pkg:rpm/centos/some-package@1.0.0-1.el8?arch=src&distro=centos-8&epoch=32",
+ },
+ },
+ {
+ name: "from rpm archive",
+ input: pkg.Package{
+ Name: "bind-export-libs",
+ Version: "32:9.11.13-3.el8",
+ Type: pkg.RpmPkg,
+ PURL: "pkg:rpm/bind-export-libs@9.11.13-3.el8?arch=x86_64&epoch=32&upstream=bind-9.11.13-3.el8.src.rpm",
+ Metadata: pkg.RpmArchive{
+ Name: "bind-export-libs",
+ Version: "9.11.13",
+ Release: "3.el8",
+ Epoch: &rpmEpoch,
+ Arch: "x86_64",
+ SourceRpm: "bind-9.11.13-3.el8.src.rpm",
+ },
+ },
+ expected: &cyclonedx.ExternalReference{
+ Type: cyclonedx.ERTypeSourceDistribution, URL: "pkg:rpm/bind@9.11.13-3.el8?arch=src&epoch=32",
+ },
+ },
+ {
+ name: "from alpm",
+ input: pkg.Package{
+ Name: "gcc-libs",
+ Version: "13.2.1-3",
+ Type: pkg.AlpmPkg,
+ PURL: "pkg:alpm/arch/gcc-libs@13.2.1-3?arch=x86_64&distro=arch-rolling&upstream=gcc",
+ Metadata: pkg.AlpmDBEntry{
+ Package: "gcc-libs",
+ Version: "13.2.1-3",
+ BasePackage: "gcc",
+ Architecture: "x86_64",
+ },
+ },
+ expected: &cyclonedx.ExternalReference{
+ Type: cyclonedx.ERTypeSourceDistribution, URL: "pkg:alpm/arch/gcc@13.2.1-3?distro=arch-rolling",
+ },
+ },
+ {
+ // Matching existing implementation where upstream qualifier is *not* omitted when BasePackage == Package for Alpm
+ // This differs from the Alpine case where it is omitted.
+ name: "from alpm with source = bin",
+ input: pkg.Package{
+ Name: "some-package",
+ Version: "1.0.0",
+ Type: pkg.AlpmPkg,
+ PURL: "pkg:alpm/arch/some-package@1.0.0?arch=x86_64&distro=arch-rolling&upstream=some-package",
+ Metadata: pkg.AlpmDBEntry{
+ Package: "some-package",
+ Version: "1.0.0",
+ BasePackage: "some-package",
+ Architecture: "x86_64",
+ },
+ },
+ expected: &cyclonedx.ExternalReference{
+ Type: cyclonedx.ERTypeSourceDistribution, URL: "pkg:alpm/arch/some-package@1.0.0?distro=arch-rolling",
+ },
+ },
+ }
+ for _, test := range tests {
+ t.Run(test.name, func(t *testing.T) {
+ assert.Equal(t, test.expected, encodeSourcePackageExternalReference(test.input))
+ })
+ }
+}
+
func Test_isValidExternalRef(t *testing.T) {
tests := []struct {
name string
diff --git a/syft/format/internal/testutil/source_package_input.go b/syft/format/internal/testutil/source_package_input.go
new file mode 100644
index 000000000..afc1bdc7e
--- /dev/null
+++ b/syft/format/internal/testutil/source_package_input.go
@@ -0,0 +1,146 @@
+package testutil
+
+import (
+ "os"
+ "path/filepath"
+ "testing"
+
+ "github.com/stretchr/testify/require"
+
+ "github.com/anchore/syft/syft/file"
+ "github.com/anchore/syft/syft/pkg"
+ "github.com/anchore/syft/syft/sbom"
+ "github.com/anchore/syft/syft/source/directorysource"
+)
+
+func SourcePackagesInput(t testing.TB, dir string) sbom.SBOM {
+ path := filepath.Join(dir, "some", "path")
+ require.NoError(t, os.MkdirAll(path, 0755))
+
+ src, err := directorysource.New(
+ directorysource.Config{
+ Path: path,
+ Base: dir,
+ },
+ )
+ require.NoError(t, err)
+
+ return sbom.SBOM{
+ Artifacts: sbom.Artifacts{
+ Packages: newSourcePackageCatalog(),
+ },
+ Source: src.Describe(),
+ Descriptor: sbom.Descriptor{
+ Name: "syft",
+ Version: "v0.42.0-bogus",
+ },
+ }
+}
+
+//nolint:funlen
+func newSourcePackageCatalog() *pkg.Collection {
+ epoch := 32
+
+ catalog := pkg.NewCollection()
+
+ // apk: the origin differs from the package name, and shares its version
+ catalog.Add(pkg.Package{
+ Name: "libc-utils",
+ Version: "0.7.2-r3",
+ Type: pkg.ApkPkg,
+ FoundBy: "apk-db-cataloger",
+ Locations: file.NewLocationSet(file.NewLocation("/lib/apk/db/installed")),
+ PURL: "pkg:apk/alpine/libc-utils@0.7.2-r3?arch=x86_64&distro=alpine-3.16.3&upstream=libc-dev",
+ Metadata: pkg.ApkDBEntry{
+ Package: "libc-utils",
+ OriginPackage: "libc-dev",
+ Version: "0.7.2-r3",
+ Architecture: "x86_64",
+ },
+ })
+
+ // apk: negative control -- the package is its own origin, so no source reference is expected
+ catalog.Add(pkg.Package{
+ Name: "busybox",
+ Version: "1.35.0-r17",
+ Type: pkg.ApkPkg,
+ FoundBy: "apk-db-cataloger",
+ Locations: file.NewLocationSet(file.NewLocation("/lib/apk/db/installed")),
+ PURL: "pkg:apk/alpine/busybox@1.35.0-r17?arch=x86_64&distro=alpine-3.16.3",
+ Metadata: pkg.ApkDBEntry{
+ Package: "busybox",
+ OriginPackage: "busybox",
+ Version: "1.35.0-r17",
+ Architecture: "x86_64",
+ },
+ })
+
+ // deb: source name differs, and the source version is implied by the binary version
+ catalog.Add(pkg.Package{
+ Name: "libpam-runtime",
+ Version: "1.5.2-6+deb12u1",
+ Type: pkg.DebPkg,
+ FoundBy: "dpkg-db-cataloger",
+ Locations: file.NewLocationSet(file.NewLocation("/var/lib/dpkg/status")),
+ PURL: "pkg:deb/debian/libpam-runtime@1.5.2-6%2Bdeb12u1?arch=all&distro=debian-12&upstream=pam",
+ Metadata: pkg.DpkgDBEntry{
+ Package: "libpam-runtime",
+ Version: "1.5.2-6+deb12u1",
+ Source: "pam",
+ Architecture: "all",
+ },
+ })
+
+ // deb: the source version differs from the binary version
+ catalog.Add(pkg.Package{
+ Name: "attr",
+ Version: "1:2.4.47-2+b1",
+ Type: pkg.DebPkg,
+ FoundBy: "dpkg-db-cataloger",
+ Locations: file.NewLocationSet(file.NewLocation("/var/lib/dpkg/status")),
+ PURL: "pkg:deb/debian/attr@1%3A2.4.47-2%2Bb1?arch=amd64&distro=debian-12&upstream=attr%401%3A2.4.47-2",
+ Metadata: pkg.DpkgDBEntry{
+ Package: "attr",
+ Version: "1:2.4.47-2+b1",
+ Source: "attr",
+ SourceVersion: "1:2.4.47-2",
+ Architecture: "amd64",
+ },
+ })
+
+ // rpm: name and version both come out of the source RPM filename, and the epoch is carried over
+ catalog.Add(pkg.Package{
+ Name: "bind-export-libs",
+ Version: "32:9.11.13-3.el8",
+ Type: pkg.RpmPkg,
+ FoundBy: "rpm-db-cataloger",
+ Locations: file.NewLocationSet(file.NewLocation("/var/lib/rpm/Packages")),
+ PURL: "pkg:rpm/centos/bind-export-libs@9.11.13-3.el8?arch=x86_64&distro=centos-8&epoch=32&upstream=bind-9.11.13-3.el8.src.rpm",
+ Metadata: pkg.RpmDBEntry{
+ Name: "bind-export-libs",
+ Version: "9.11.13",
+ Release: "3.el8",
+ Epoch: &epoch,
+ Arch: "x86_64",
+ SourceRpm: "bind-9.11.13-3.el8.src.rpm",
+ },
+ })
+
+ // alpm: the base package differs from the package name, and shares its version
+ catalog.Add(pkg.Package{
+ Name: "gcc-libs",
+ Version: "13.2.1-3",
+ Type: pkg.AlpmPkg,
+ FoundBy: "alpm-db-cataloger",
+ Locations: file.NewLocationSet(file.NewLocation("/var/lib/pacman/local/gcc-libs-13.2.1-3/desc")),
+ PURL: "pkg:alpm/arch/gcc-libs@13.2.1-3?arch=x86_64&distro=arch-rolling&upstream=gcc",
+ Metadata: pkg.AlpmDBEntry{
+ Package: "gcc-libs",
+ Version: "13.2.1-3",
+ BasePackage: "gcc",
+ Architecture: "x86_64",
+ },
+ })
+
+ return catalog
+}