swift: emit canonical purls without .git suffix or repeated name (#4785)

* swift: emit canonical purls without .git suffix or repeated name

Reported in anchore/syft#3961: the purl syft emits for Swift packages
parsed out of Package.resolved keeps the repository URLs .git suffix
and then re-appends the package name, producing

    pkg:swift/github.com/apple/swift-nio-ssl.git/swift-nio-ssl@2.0.0

NVD / Grype cannot match that purl against the known
swift-nio-ssl@2.0.0 CVE (GHSA-frg3-gpcx-968f), so every Swift SBOM
produced by syft silently loses vulnerability coverage. cdxgen and
the wider purl ecosystem use the shorter form

    pkg:swift/github.com/apple/swift-nio-ssl@2.0.0

which Grype does match.

Replace the ad-hoc strings.Replace with swiftNamespaceFromSourceURL,
which trims the common URL schemes, strips the ".git" suffix, and
drops a trailing /<name> segment so the namespace is only the
organisation path (e.g. github.com/apple). Existing test expectations
are updated to the new purl shape.

Fixes #3961

Signed-off-by: Sai Asish Y <say.apm35@gmail.com>
Signed-off-by: Alex Goodman <wagoodman@users.noreply.github.com>

* Revert unrelated consul binary classifier changes

The swift purl commit (ff5ffc37) accidentally bundled removal of the
consul GitDescribe and NUL-wrapped version matchers plus the 1.12.9 and
1.7.14 fixtures/test cases. That drops version detection for consul
binaries not carrying the CONSUL_VERSION string. Restore the binary
cataloger to its pre-commit state so this branch is swift-only.

Signed-off-by: Alex Goodman <wagoodman@users.noreply.github.com>

* swift: strip repeated name segment case-insensitively

Package.resolved identities are lowercased while the repo URL path may be
mixed-case (e.g. github.com/Apple/Swift-NIO vs identity swift-nio). The
case-sensitive TrimSuffix left the repo segment in the namespace for such
repos, reintroducing the duplicated-name purl. Compare the trailing segment
with EqualFold and add unit coverage for swiftPackageManagerPackageURL.

Signed-off-by: Alex Goodman <wagoodman@users.noreply.github.com>

---------

Signed-off-by: Sai Asish Y <say.apm35@gmail.com>
Signed-off-by: Alex Goodman <wagoodman@users.noreply.github.com>
Co-authored-by: Alex Goodman <wagoodman@users.noreply.github.com>
This commit is contained in:
Sai Asish Y 2026-07-08 09:55:13 -07:00 committed by GitHub
parent 002a326c41
commit e8abab2e77
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
3 changed files with 93 additions and 8 deletions

View File

@ -62,10 +62,44 @@ func swiftPackageManagerPackageURL(name, version, sourceURL string) string {
return packageurl.NewPackageURL(
packageurl.TypeSwift,
strings.Replace(sourceURL, "https://", "", 1),
swiftNamespaceFromSourceURL(sourceURL, name),
name,
version,
qualifiers,
"",
).ToString()
}
// swiftNamespaceFromSourceURL derives the purl namespace (e.g.
// "github.com/apple") from a Swift Package.resolved source URL like
// "https://github.com/apple/swift-nio-ssl.git".
//
// Two bits of cleanup are needed compared with the previous
// strings.Replace(sourceURL, "https://", "", 1) behaviour:
//
// 1. Strip the ".git" suffix. Swift Package.resolved always carries the
// repository URL with a trailing ".git", but the purl spec uses the
// plain web path.
// 2. Drop the trailing /<name> segment. The previous code left the repo
// name in the namespace and then appended the package name again, so
// the emitted purl looked like
// pkg:swift/github.com/apple/swift-nio-ssl.git/swift-nio-ssl@2.0.0
// which Grype cannot match against NVD. The expected form is
// pkg:swift/github.com/apple/swift-nio-ssl@2.0.0
// which is also what cdxgen emits. See anchore/syft#3961.
func swiftNamespaceFromSourceURL(sourceURL, name string) string {
ns := sourceURL
for _, prefix := range []string{"https://", "http://", "git+ssh://", "ssh://"} {
ns = strings.TrimPrefix(ns, prefix)
}
ns = strings.TrimSuffix(ns, ".git")
// drop a trailing "/<name>" segment. Package.resolved identities are lowercased
// while the repo path may be mixed-case (e.g. github.com/Apple/Swift-NIO vs
// identity swift-nio), so compare case-insensitively.
if name != "" {
if idx := strings.LastIndex(ns, "/"); idx >= 0 && strings.EqualFold(ns[idx+1:], name) {
ns = ns[:idx]
}
}
return ns
}

View File

@ -6,6 +6,57 @@ import (
"github.com/stretchr/testify/assert"
)
func Test_swiftPackageManagerPackageURL(t *testing.T) {
tests := []struct {
name string
pkgName string
version string
sourceURL string
want string
}{
{
name: "strips .git suffix and repeated name segment",
pkgName: "swift-nio-ssl",
version: "2.0.0",
sourceURL: "https://github.com/apple/swift-nio-ssl.git",
want: "pkg:swift/github.com/apple/swift-nio-ssl@2.0.0",
},
{
name: "no .git suffix",
pkgName: "swift-numerics",
version: "1.0.2",
sourceURL: "https://github.com/apple/swift-numerics",
want: "pkg:swift/github.com/apple/swift-numerics@1.0.2",
},
{
name: "mixed-case repo path with lowercased identity",
pkgName: "swift-nio",
version: "2.0.0",
sourceURL: "https://github.com/Apple/Swift-NIO.git",
want: "pkg:swift/github.com/Apple/swift-nio@2.0.0",
},
{
name: "ssh scheme",
pkgName: "swift-nio",
version: "2.0.0",
sourceURL: "git+ssh://github.com/apple/swift-nio.git",
want: "pkg:swift/github.com/apple/swift-nio@2.0.0",
},
{
name: "trailing segment differs from name is preserved",
pkgName: "nio",
version: "2.0.0",
sourceURL: "https://github.com/apple/swift-nio.git",
want: "pkg:swift/github.com/apple/swift-nio/nio@2.0.0",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
assert.Equal(t, tt.want, swiftPackageManagerPackageURL(tt.pkgName, tt.version, tt.sourceURL))
})
}
}
func Test_cocoaPodsPackageURL(t *testing.T) {
type args struct {
name string

View File

@ -20,7 +20,7 @@ func TestParsePackageResolved(t *testing.T) {
{
Name: "swift-algorithms",
Version: "1.0.0",
PURL: "pkg:swift/github.com/apple/swift-algorithms.git/swift-algorithms@1.0.0",
PURL: "pkg:swift/github.com/apple/swift-algorithms@1.0.0",
Locations: locations,
Language: pkg.Swift,
Type: pkg.SwiftPkg,
@ -31,7 +31,7 @@ func TestParsePackageResolved(t *testing.T) {
{
Name: "swift-async-algorithms",
Version: "0.1.0",
PURL: "pkg:swift/github.com/apple/swift-async-algorithms.git/swift-async-algorithms@0.1.0",
PURL: "pkg:swift/github.com/apple/swift-async-algorithms@0.1.0",
Locations: locations,
Language: pkg.Swift,
Type: pkg.SwiftPkg,
@ -42,7 +42,7 @@ func TestParsePackageResolved(t *testing.T) {
{
Name: "swift-atomics",
Version: "1.1.0",
PURL: "pkg:swift/github.com/apple/swift-atomics.git/swift-atomics@1.1.0",
PURL: "pkg:swift/github.com/apple/swift-atomics@1.1.0",
Locations: locations,
Language: pkg.Swift,
Type: pkg.SwiftPkg,
@ -53,7 +53,7 @@ func TestParsePackageResolved(t *testing.T) {
{
Name: "swift-collections",
Version: "1.0.4",
PURL: "pkg:swift/github.com/apple/swift-collections.git/swift-collections@1.0.4",
PURL: "pkg:swift/github.com/apple/swift-collections@1.0.4",
Locations: locations,
Language: pkg.Swift,
Type: pkg.SwiftPkg,
@ -64,7 +64,7 @@ func TestParsePackageResolved(t *testing.T) {
{
Name: "swift-numerics",
Version: "1.0.2",
PURL: "pkg:swift/github.com/apple/swift-numerics/swift-numerics@1.0.2",
PURL: "pkg:swift/github.com/apple/swift-numerics@1.0.2",
Locations: locations,
Language: pkg.Swift,
Type: pkg.SwiftPkg,
@ -87,7 +87,7 @@ func TestParsePackageResolvedV3(t *testing.T) {
{
Name: "swift-mmio",
Version: "",
PURL: "pkg:swift/github.com/apple/swift-mmio/swift-mmio",
PURL: "pkg:swift/github.com/apple/swift-mmio",
Locations: locations,
Language: pkg.Swift,
Type: pkg.SwiftPkg,
@ -98,7 +98,7 @@ func TestParsePackageResolvedV3(t *testing.T) {
{
Name: "swift-syntax",
Version: "509.1.1",
PURL: "pkg:swift/github.com/apple/swift-syntax.git/swift-syntax@509.1.1",
PURL: "pkg:swift/github.com/apple/swift-syntax@509.1.1",
Locations: locations,
Language: pkg.Swift,
Type: pkg.SwiftPkg,