From bf8ded3cf8699b020b4268f365f7b9961565e7d8 Mon Sep 17 00:00:00 2001 From: Alex Goodman Date: Tue, 29 Sep 2026 17:37:59 -0400 Subject: [PATCH] build source PURL from metadata and gate it to CycloneDX 1.6+ - the source PURL is built from package metadata instead of editing the binary PURL: only `distro` (plus `epoch` and `rpmmod` for rpm) is carried over, subpath is dropped, and the ref is skipped when the PURL type does not match the metadata ecosystem - one emission rule for all ecosystems: only when the source name or version differs from the binary, so self-sourced rpm and alpm packages no longer get a redundant ref - source RPM names must end in `.src.rpm` or `.nosrc.rpm`, and `nosrc` maps to `arch=nosrc` - `source-distribution` refs are dropped when encoding below spec 1.6, since cyclonedx-go does not downgrade them (CycloneDX/cyclonedx-go#291) Signed-off-by: Alex Goodman --- syft/format/cyclonedxjson/encoder_test.go | 25 +++ syft/format/internal/cyclonedxutil/encoder.go | 27 ++++ .../cyclonedxutil/helpers/component.go | 10 +- .../helpers/external_references.go | 143 +++++++++++------ .../helpers/external_references_test.go | 151 ++++++++++++++++-- 5 files changed, 291 insertions(+), 65 deletions(-) diff --git a/syft/format/cyclonedxjson/encoder_test.go b/syft/format/cyclonedxjson/encoder_test.go index 8b57de976..c3d422cd2 100644 --- a/syft/format/cyclonedxjson/encoder_test.go +++ b/syft/format/cyclonedxjson/encoder_test.go @@ -128,6 +128,31 @@ func TestCycloneDxSourcePackageEncoder(t *testing.T) { ) } +func TestCycloneDxSourcePackageEncoder_SpecVersions(t *testing.T) { + s := testutil.SourcePackagesInput(t, t.TempDir()) + tests := []struct { + version string + want bool + }{ + {version: "1.4", want: false}, + {version: "1.5", want: false}, + {version: "1.6", want: true}, + {version: cyclonedxutil.DefaultVersion, want: true}, + } + for _, tt := range tests { + t.Run(tt.version, func(t *testing.T) { + enc, err := NewFormatEncoderWithConfig(EncoderConfig{Version: tt.version}) + require.NoError(t, err) + + var buf bytes.Buffer + require.NoError(t, enc.Encode(&buf, s)) + + // the source-distribution reference type only exists from spec 1.6 + assert.Equal(t, tt.want, strings.Contains(buf.String(), `"source-distribution"`)) + }) + } +} + func redactor(values ...string) testutil.Redactor { return testutil.NewRedactions(). WithValuesRedacted(values...). diff --git a/syft/format/internal/cyclonedxutil/encoder.go b/syft/format/internal/cyclonedxutil/encoder.go index dc843734c..82305fdb6 100644 --- a/syft/format/internal/cyclonedxutil/encoder.go +++ b/syft/format/internal/cyclonedxutil/encoder.go @@ -2,6 +2,7 @@ package cyclonedxutil import ( "io" + "slices" "github.com/CycloneDX/cyclonedx-go" @@ -29,9 +30,35 @@ func NewEncoder(version string, format cyclonedx.BOMFileFormat, pretty bool) (En func (e Encoder) Encode(writer io.Writer, s sbom.SBOM) error { bom := cyclonedxhelpers.ToFormatModel(s) + if e.version < cyclonedx.SpecVersion1_6 { + // cyclonedx-go does not downgrade this 1.6+ reference type for older spec versions + // (see https://github.com/CycloneDX/cyclonedx-go/issues/291), so drop it rather than emit an invalid document + dropExternalReferenceType(bom.Components, cyclonedx.ERTypeSourceDistribution) + } enc := cyclonedx.NewBOMEncoder(writer, e.format) enc.SetPretty(e.pretty) enc.SetEscapeHTML(false) return enc.EncodeVersion(bom, e.version) } + +func dropExternalReferenceType(components *[]cyclonedx.Component, refType cyclonedx.ExternalReferenceType) { + if components == nil { + return + } + for i := range *components { + c := &(*components)[i] + dropExternalReferenceType(c.Components, refType) + if c.ExternalReferences == nil { + continue + } + refs := slices.DeleteFunc(*c.ExternalReferences, func(r cyclonedx.ExternalReference) bool { + return r.Type == refType + }) + if len(refs) == 0 { + c.ExternalReferences = nil + continue + } + c.ExternalReferences = &refs + } +} diff --git a/syft/format/internal/cyclonedxutil/helpers/component.go b/syft/format/internal/cyclonedxutil/helpers/component.go index dc788f5ba..2e289fc84 100644 --- a/syft/format/internal/cyclonedxutil/helpers/component.go +++ b/syft/format/internal/cyclonedxutil/helpers/component.go @@ -47,14 +47,6 @@ 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, @@ -67,7 +59,7 @@ func EncodeComponent(p pkg.Package, supplier string, locationSorter func(a, b fi Author: encodeAuthor(p), Publisher: encodePublisher(p), Description: encodeDescription(p), - ExternalReferences: externalRefs, + ExternalReferences: encodeExternalReferences(p), 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 2d2f45a56..5f02ea6db 100644 --- a/syft/format/internal/cyclonedxutil/helpers/external_references.go +++ b/syft/format/internal/cyclonedxutil/helpers/external_references.go @@ -80,42 +80,51 @@ func encodeExternalReferences(p pkg.Package) *[]cyclonedx.ExternalReference { } } } - + if srcRef := encodeSourcePackageExternalReference(p); srcRef != nil { + refs = append(refs, *srcRef) + } if len(refs) > 0 { return &refs } return nil } +// encodeSourcePackageExternalReference returns a "source-distribution" reference holding the PURL of the source +// package that an OS package was built from. It is only emitted when the source differs from the binary package +// (by name or version) and when the package PURL is of the type implied by the metadata. 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 { + src := sourcePackageOf(p) + if src == nil { return nil } - qualifiers := pkgPurl.Qualifiers.Map() - delete(qualifiers, pkg.PURLQualifierUpstream) - delete(qualifiers, pkg.PURLQualifierArch) + // the binary PURL is only consulted for the namespace and a small set of qualifiers (e.g. distro), everything + // else describes the binary package and must not be restated as a fact about the source package. + binPurl, err := packageurl.FromString(p.PURL) + if err != nil || binPurl.Type != src.purlType { + return nil + } - if arch != "" { - qualifiers[pkg.PURLQualifierArch] = arch + qualifiers := map[string]string{} + for _, q := range binPurl.Qualifiers { + if src.keepQualifier(q.Key) { + qualifiers[q.Key] = q.Value + } + } + if src.arch != "" { + qualifiers[pkg.PURLQualifierArch] = src.arch } srcPurl := packageurl.NewPackageURL( - pkgPurl.Type, - pkgPurl.Namespace, - name, - version, + src.purlType, + binPurl.Namespace, + src.name, + src.version, packageurl.QualifiersFromMap(qualifiers), - pkgPurl.Subpath, + "", ) return &cyclonedx.ExternalReference{ @@ -124,51 +133,91 @@ func encodeSourcePackageExternalReference(p pkg.Package) *cyclonedx.ExternalRefe } } -func sourceCoordinates(p pkg.Package) (name, version, arch string) { - switch metadata := p.Metadata.(type) { +// sourcePackage is the identity of the source package a binary OS package was built from. +type sourcePackage struct { + purlType string + name string + version string + // arch is the ecosystem's source architecture marker, empty when the ecosystem has no such convention + arch string +} + +func (s sourcePackage) keepQualifier(key string) bool { + switch key { + case pkg.PURLQualifierDistro: + return true + case pkg.PURLQualifierEpoch, pkg.PURLQualifierRpmModularity: + return s.purlType == pkg.RpmPkg.PackageURLType() + } + return false +} + +// sourcePackageOf returns the source package for p, or nil when there is none or it is the same as the binary. +func sourcePackageOf(p pkg.Package) *sourcePackage { + var src sourcePackage + var binName, binVersion string + switch m := p.Metadata.(type) { case pkg.ApkDBEntry: - if metadata.OriginPackage != "" && metadata.OriginPackage != metadata.Package { - return metadata.OriginPackage, metadata.Version, "" - } + // apk has no source arch convention, so the source PURL carries no arch + src = sourcePackage{purlType: pkg.ApkPkg.PackageURLType(), name: m.OriginPackage, version: m.Version} + binName, binVersion = m.Package, m.Version case pkg.DpkgDBEntry: - return dpkgSourceCoordinates(metadata) + src = dpkgSourcePackage(m) + binName, binVersion = m.Package, m.Version case pkg.DpkgArchiveEntry: - return dpkgSourceCoordinates(pkg.DpkgDBEntry(metadata)) + src = dpkgSourcePackage(pkg.DpkgDBEntry(m)) + binName, binVersion = m.Package, m.Version case pkg.RpmDBEntry: - return rpmSourceCoordinates(metadata.SourceRpm) + src = rpmSourcePackage(m.SourceRpm) + binName, binVersion = m.Name, m.Version+"-"+m.Release case pkg.RpmArchive: - return rpmSourceCoordinates(metadata.SourceRpm) + src = rpmSourcePackage(m.SourceRpm) + binName, binVersion = m.Name, m.Version+"-"+m.Release case pkg.AlpmDBEntry: - if metadata.BasePackage != "" { - return metadata.BasePackage, metadata.Version, "" - } + // alpm has no source arch convention, so the source PURL carries no arch + src = sourcePackage{purlType: pkg.AlpmPkg.PackageURLType(), name: m.BasePackage, version: m.Version} + binName, binVersion = m.Package, m.Version + default: + return nil } - return "", "", "" + if src.name == "" || src.version == "" { + return nil + } + if src.name == binName && src.version == binVersion { + return nil + } + return &src } -func dpkgSourceCoordinates(entry pkg.DpkgDBEntry) (name, version, arch string) { - if entry.Source == "" { - return "", "", "" +func dpkgSourcePackage(entry pkg.DpkgDBEntry) sourcePackage { + version := entry.SourceVersion + if version == "" { + version = entry.Version } - if entry.SourceVersion != "" { - return entry.Source, entry.SourceVersion, "source" - } - return entry.Source, entry.Version, "source" + return sourcePackage{purlType: pkg.DebPkg.PackageURLType(), name: entry.Source, version: version, arch: "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, "-") +// rpmSourcePackage parses a source RPM filename of the form --.(no)src.rpm. +func rpmSourcePackage(sourceRpm string) sourcePackage { + var arch string + switch { + case strings.HasSuffix(sourceRpm, ".src.rpm"): + arch = "src" + case strings.HasSuffix(sourceRpm, ".nosrc.rpm"): + arch = "nosrc" + default: + return sourcePackage{} + } + nvr := strings.TrimSuffix(sourceRpm, "."+arch+".rpm") + release := strings.LastIndex(nvr, "-") if release < 0 { - return "", "", "" + return sourcePackage{} } - ver := strings.LastIndex(sourceRpm[:release], "-") + ver := strings.LastIndex(nvr[:release], "-") if ver < 0 { - return "", "", "" + return sourcePackage{} } - return sourceRpm[:ver], sourceRpm[ver+1:], "src" + return sourcePackage{purlType: pkg.RpmPkg.PackageURLType(), name: nvr[:ver], version: nvr[ver+1:], arch: arch} } // supported algorithm in cycloneDX as of 1.4 diff --git a/syft/format/internal/cyclonedxutil/helpers/external_references_test.go b/syft/format/internal/cyclonedxutil/helpers/external_references_test.go index b91d590f0..a73a19117 100644 --- a/syft/format/internal/cyclonedxutil/helpers/external_references_test.go +++ b/syft/format/internal/cyclonedxutil/helpers/external_references_test.go @@ -164,7 +164,7 @@ func Test_encodeSourcePackage(t *testing.T) { Type: pkg.ApkPkg, Metadata: pkg.ApkDBEntry{ Package: "some-package", - OriginPackage: "some-package", + OriginPackage: "some-origin", Version: "1.0.0-r1", Architecture: "x86_64", }, @@ -190,7 +190,6 @@ func Test_encodeSourcePackage(t *testing.T) { }, }, { - // 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", @@ -312,9 +311,7 @@ func Test_encodeSourcePackage(t *testing.T) { 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", - }, + expected: nil, }, { name: "from rpm archive", @@ -355,8 +352,6 @@ func Test_encodeSourcePackage(t *testing.T) { }, }, { - // 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", @@ -370,9 +365,147 @@ func Test_encodeSourcePackage(t *testing.T) { Architecture: "x86_64", }, }, - expected: &cyclonedx.ExternalReference{ - Type: cyclonedx.ERTypeSourceDistribution, URL: "pkg:alpm/arch/some-package@1.0.0?distro=arch-rolling", + expected: nil, + }, + { + name: "from rpm with dashes in the source name", + input: pkg.Package{ + Name: "perl-Foo-Bar-libs", + Type: pkg.RpmPkg, + PURL: "pkg:rpm/redhat/perl-Foo-Bar-libs@1.0-1.el8?arch=x86_64&distro=rhel-8", + Metadata: pkg.RpmDBEntry{ + Name: "perl-Foo-Bar-libs", + Version: "1.0", + Release: "1.el8", + SourceRpm: "perl-Foo-Bar-1.0-1.el8.src.rpm", + }, }, + expected: &cyclonedx.ExternalReference{ + Type: cyclonedx.ERTypeSourceDistribution, URL: "pkg:rpm/redhat/perl-Foo-Bar@1.0-1.el8?arch=src&distro=rhel-8", + }, + }, + { + name: "from rpm nosrc", + input: pkg.Package{ + Name: "some-firmware", + Type: pkg.RpmPkg, + PURL: "pkg:rpm/redhat/some-firmware@1.0-1.el8?arch=noarch&distro=rhel-8", + Metadata: pkg.RpmDBEntry{ + Name: "some-firmware", + Version: "1.0", + Release: "1.el8", + SourceRpm: "firmware-1.0-1.el8.nosrc.rpm", + }, + }, + expected: &cyclonedx.ExternalReference{ + Type: cyclonedx.ERTypeSourceDistribution, URL: "pkg:rpm/redhat/firmware@1.0-1.el8?arch=nosrc&distro=rhel-8", + }, + }, + { + name: "from rpm keeps rpmmod", + input: pkg.Package{ + Name: "nodejs-libs", + Type: pkg.RpmPkg, + PURL: "pkg:rpm/redhat/nodejs-libs@18.0-1.el8?arch=x86_64&distro=rhel-8&rpmmod=nodejs%3A18", + Metadata: pkg.RpmDBEntry{ + Name: "nodejs-libs", + Version: "18.0", + Release: "1.el8", + SourceRpm: "nodejs-18.0-1.el8.src.rpm", + }, + }, + expected: &cyclonedx.ExternalReference{ + Type: cyclonedx.ERTypeSourceDistribution, URL: "pkg:rpm/redhat/nodejs@18.0-1.el8?arch=src&distro=rhel-8&rpmmod=nodejs%3A18", + }, + }, + { + name: "from rpm with malformed source rpm", + input: pkg.Package{ + Name: "gpg-pubkey", + Type: pkg.RpmPkg, + PURL: "pkg:rpm/redhat/gpg-pubkey@1-1?distro=rhel-8", + Metadata: pkg.RpmDBEntry{ + Name: "gpg-pubkey", + Version: "1", + Release: "1", + SourceRpm: "(none)", + }, + }, + expected: nil, + }, + { + name: "from rpm with non-source rpm filename", + input: pkg.Package{ + Name: "bash", + Type: pkg.RpmPkg, + PURL: "pkg:rpm/redhat/bash@5.1-2?arch=x86_64&distro=rhel-8", + Metadata: pkg.RpmDBEntry{ + Name: "bash", + Version: "5.1", + Release: "2", + SourceRpm: "bash-5.1-2.x86_64.rpm", + }, + }, + expected: nil, + }, + { + name: "from rpm with too few dashes", + input: pkg.Package{ + Name: "bash", + Type: pkg.RpmPkg, + PURL: "pkg:rpm/redhat/bash@5.1-2?arch=x86_64&distro=rhel-8", + Metadata: pkg.RpmDBEntry{ + Name: "bash", + Version: "5.1", + Release: "2", + SourceRpm: "bash-5.1.src.rpm", + }, + }, + expected: nil, + }, + { + name: "binary qualifiers and subpath are not carried over", + input: pkg.Package{ + Name: "libssl3", + Type: pkg.DebPkg, + PURL: "pkg:deb/debian/libssl3@3.0.0-1?arch=amd64&distro=debian-12&checksum=sha256:dead&download_url=https://example.com/x.deb&cpes=cpe:2.3:a:x:x:1:*:*:*:*:*:*:*#sub/dir", + Metadata: pkg.DpkgDBEntry{ + Package: "libssl3", + Version: "3.0.0-1", + Source: "openssl", + }, + }, + expected: &cyclonedx.ExternalReference{ + Type: cyclonedx.ERTypeSourceDistribution, URL: "pkg:deb/debian/openssl@3.0.0-1?arch=source&distro=debian-12", + }, + }, + { + name: "PURL type does not match metadata", + input: pkg.Package{ + Name: "x", + Type: pkg.DebPkg, + PURL: "pkg:npm/x@1", + Metadata: pkg.DpkgDBEntry{ + Package: "x", + Version: "1", + Source: "lodash", + }, + }, + expected: nil, + }, + { + name: "invalid PURL", + input: pkg.Package{ + Name: "x", + Type: pkg.DebPkg, + PURL: "not-a-purl", + Metadata: pkg.DpkgDBEntry{ + Package: "x", + Version: "1", + Source: "y", + }, + }, + expected: nil, }, } for _, test := range tests {