Improve package URL support (#754)

* rename npm metadata struct

Signed-off-by: Alex Goodman <alex.goodman@anchore.com>

* improve os package URLs

Signed-off-by: Alex Goodman <alex.goodman@anchore.com>

* improve language package URLs

Signed-off-by: Alex Goodman <alex.goodman@anchore.com>

* wire up composer pURL method

Signed-off-by: Alex Goodman <alex.goodman@anchore.com>
This commit is contained in:
Alex Goodman 2022-01-19 17:30:29 -05:00 committed by GitHub
parent c350bd55f6
commit 1350d6c5bf
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
18 changed files with 501 additions and 296 deletions

View File

@ -3,15 +3,18 @@ package pkg
import ( import (
"sort" "sort"
"github.com/anchore/syft/syft/file"
"github.com/anchore/packageurl-go" "github.com/anchore/packageurl-go"
"github.com/anchore/syft/syft/file"
"github.com/anchore/syft/syft/linux"
"github.com/scylladb/go-set/strset" "github.com/scylladb/go-set/strset"
) )
const ApkDBGlob = "**/lib/apk/db/installed" const ApkDBGlob = "**/lib/apk/db/installed"
var _ FileOwner = (*ApkMetadata)(nil) var (
_ FileOwner = (*ApkMetadata)(nil)
_ urlIdentifier = (*ApkMetadata)(nil)
)
// ApkMetadata represents all captured data for a Alpine DB package entry. // ApkMetadata represents all captured data for a Alpine DB package entry.
// See the following sources for more information: // See the following sources for more information:
@ -45,22 +48,22 @@ type ApkFileRecord struct {
} }
// PackageURL returns the PURL for the specific Alpine package (see https://github.com/package-url/purl-spec) // PackageURL returns the PURL for the specific Alpine package (see https://github.com/package-url/purl-spec)
func (m ApkMetadata) PackageURL() string { func (m ApkMetadata) PackageURL(distro *linux.Release) string {
pURL := packageurl.NewPackageURL( return packageurl.NewPackageURL(
// note: this is currently a candidate and not technically within spec // note: this is currently a candidate and not technically within spec
// see https://github.com/package-url/purl-spec#other-candidate-types-to-define // see https://github.com/package-url/purl-spec#other-candidate-types-to-define
"alpine", "alpine",
"", "",
m.Package, m.Package,
m.Version, m.Version,
packageurl.Qualifiers{ purlQualifiers(
{ map[string]string{
Key: "arch", purlArchQualifier: m.Architecture,
Value: m.Architecture,
}, },
}, distro,
"") ),
return pURL.ToString() "",
).ToString()
} }
func (m ApkMetadata) OwnedFiles() (result []string) { func (m ApkMetadata) OwnedFiles() (result []string) {

View File

@ -1,6 +1,7 @@
package pkg package pkg
import ( import (
"github.com/anchore/syft/syft/linux"
"strings" "strings"
"testing" "testing"
@ -11,16 +12,35 @@ import (
func TestApkMetadata_pURL(t *testing.T) { func TestApkMetadata_pURL(t *testing.T) {
tests := []struct { tests := []struct {
name string
metadata ApkMetadata metadata ApkMetadata
distro linux.Release
expected string expected string
}{ }{
{ {
name: "gocase",
metadata: ApkMetadata{ metadata: ApkMetadata{
Package: "p", Package: "p",
Version: "v", Version: "v",
Architecture: "a", Architecture: "a",
}, },
expected: "pkg:alpine/p@v?arch=a", distro: linux.Release{
ID: "alpine",
VersionID: "3.4.6",
},
expected: "pkg:alpine/p@v?arch=a&distro=alpine-3.4.6",
},
{
name: "missing architecure",
metadata: ApkMetadata{
Package: "p",
Version: "v",
},
distro: linux.Release{
ID: "alpine",
VersionID: "3.4.6",
},
expected: "pkg:alpine/p@v?distro=alpine-3.4.6",
}, },
// verify #351 // verify #351
{ {
@ -29,7 +49,11 @@ func TestApkMetadata_pURL(t *testing.T) {
Version: "v84", Version: "v84",
Architecture: "am86", Architecture: "am86",
}, },
expected: "pkg:alpine/g++@v84?arch=am86", distro: linux.Release{
ID: "alpine",
VersionID: "3.4.6",
},
expected: "pkg:alpine/g++@v84?arch=am86&distro=alpine-3.4.6",
}, },
{ {
metadata: ApkMetadata{ metadata: ApkMetadata{
@ -37,13 +61,17 @@ func TestApkMetadata_pURL(t *testing.T) {
Version: "v84", Version: "v84",
Architecture: "am86", Architecture: "am86",
}, },
expected: "pkg:alpine/g%20plus%20plus@v84?arch=am86", distro: linux.Release{
ID: "alpine",
VersionID: "3.15.0",
},
expected: "pkg:alpine/g%20plus%20plus@v84?arch=am86&distro=alpine-3.15.0",
}, },
} }
for _, test := range tests { for _, test := range tests {
t.Run(test.expected, func(t *testing.T) { t.Run(test.name, func(t *testing.T) {
actual := test.metadata.PackageURL() actual := test.metadata.PackageURL(&test.distro)
if actual != test.expected { if actual != test.expected {
dmp := diffmatchpatch.New() dmp := diffmatchpatch.New()
diffs := dmp.DiffMain(test.expected, actual, true) diffs := dmp.DiffMain(test.expected, actual, true)

View File

@ -68,7 +68,7 @@ func Catalog(resolver source.FileResolver, release *linux.Release, catalogers ..
p.CPEs = cpe.Generate(p) p.CPEs = cpe.Generate(p)
// generate PURL (note: this is excluded from package ID, so is safe to mutate) // generate PURL (note: this is excluded from package ID, so is safe to mutate)
p.PURL = generatePackageURL(p, release) p.PURL = pkg.URL(p, release)
// create file-to-package relationships for files owned by the package // create file-to-package relationships for files owned by the package
owningRelationships, err := packageFileOwnershipRelationships(p, resolver) owningRelationships, err := packageFileOwnershipRelationships(p, resolver)

View File

@ -1,49 +0,0 @@
package cataloger
import (
"regexp"
"strings"
"github.com/anchore/packageurl-go"
"github.com/anchore/syft/syft/linux"
"github.com/anchore/syft/syft/pkg"
)
// generatePackageURL returns a package-URL representation of the given package (see https://github.com/package-url/purl-spec)
func generatePackageURL(p pkg.Package, release *linux.Release) string {
// default to pURLs on the metadata
if p.Metadata != nil {
if i, ok := p.Metadata.(interface{ PackageURL() string }); ok {
return i.PackageURL()
} else if i, ok := p.Metadata.(interface{ PackageURL(*linux.Release) string }); ok {
return i.PackageURL(release)
}
}
var purlType = p.Type.PackageURLType()
var name = p.Name
var namespace = ""
switch {
case purlType == "":
// there is no purl type, don't attempt to craft a purl
// TODO: should this be a "generic" purl type instead?
return ""
case p.Type == pkg.GoModulePkg:
re := regexp.MustCompile(`(/)[^/]*$`)
fields := re.Split(p.Name, -1)
namespace = fields[0]
name = strings.TrimPrefix(p.Name, namespace+"/")
}
// generate a purl from the package data
pURL := packageurl.NewPackageURL(
purlType,
namespace,
name,
p.Version,
nil,
"")
return pURL.ToString()
}

View File

@ -1,166 +0,0 @@
package cataloger
import (
"testing"
"github.com/anchore/syft/syft/linux"
"github.com/anchore/syft/syft/pkg"
"github.com/sergi/go-diff/diffmatchpatch"
)
func TestPackageURL(t *testing.T) {
tests := []struct {
name string
pkg pkg.Package
distro *linux.Release
expected string
}{
{
name: "golang",
pkg: pkg.Package{
Name: "github.com/anchore/syft",
Version: "v0.1.0",
Type: pkg.GoModulePkg,
},
expected: "pkg:golang/github.com/anchore/syft@v0.1.0",
},
{
name: "pip with vcs url",
pkg: pkg.Package{
Name: "bad-name",
Version: "bad-v0.1.0",
Type: pkg.PythonPkg,
Metadata: pkg.PythonPackageMetadata{
Name: "name",
Version: "v0.1.0",
DirectURLOrigin: &pkg.PythonDirectURLOriginInfo{
VCS: "git",
URL: "https://github.com/test/test.git",
CommitID: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
},
},
},
expected: "pkg:pypi/name@v0.1.0?vcs_url=git+https:%2F%2Fgithub.com%2Ftest%2Ftest.git@aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
},
{
name: "pip without vcs url",
pkg: pkg.Package{
Name: "bad-name",
Version: "bad-v0.1.0",
Type: pkg.PythonPkg,
Metadata: pkg.PythonPackageMetadata{
Name: "name",
Version: "v0.1.0",
},
},
expected: "pkg:pypi/name@v0.1.0",
},
{
name: "gem",
pkg: pkg.Package{
Name: "name",
Version: "v0.1.0",
Type: pkg.GemPkg,
},
expected: "pkg:gem/name@v0.1.0",
},
{
name: "npm",
pkg: pkg.Package{
Name: "name",
Version: "v0.1.0",
Type: pkg.NpmPkg,
},
expected: "pkg:npm/name@v0.1.0",
},
{
name: "deb with arch",
distro: &linux.Release{
ID: "ubuntu",
},
pkg: pkg.Package{
Name: "bad-name",
Version: "bad-v0.1.0",
Type: pkg.DebPkg,
Metadata: pkg.DpkgMetadata{
Package: "name",
Version: "v0.1.0",
Architecture: "amd64",
},
},
expected: "pkg:deb/ubuntu/name@v0.1.0?arch=amd64",
},
{
name: "deb with epoch",
distro: &linux.Release{
ID: "centos",
},
pkg: pkg.Package{
Name: "bad-name",
Version: "bad-v0.1.0",
Type: pkg.RpmPkg,
Metadata: pkg.RpmdbMetadata{
Name: "name",
Version: "0.1.0",
Epoch: intRef(2),
Arch: "amd64",
Release: "3",
},
},
expected: "pkg:rpm/centos/name@0.1.0-3?arch=amd64&epoch=2",
},
{
name: "deb with nil epoch",
distro: &linux.Release{
ID: "centos",
},
pkg: pkg.Package{
Name: "bad-name",
Version: "bad-v0.1.0",
Type: pkg.RpmPkg,
Metadata: pkg.RpmdbMetadata{
Name: "name",
Version: "0.1.0",
Epoch: nil,
Arch: "amd64",
Release: "3",
},
},
expected: "pkg:rpm/centos/name@0.1.0-3?arch=amd64",
},
{
name: "deb with unknown distro",
distro: nil,
pkg: pkg.Package{
Name: "name",
Version: "v0.1.0",
Type: pkg.DebPkg,
},
expected: "pkg:deb/name@v0.1.0",
},
{
name: "cargo",
pkg: pkg.Package{
Name: "name",
Version: "v0.1.0",
Type: pkg.RustPkg,
},
expected: "pkg:cargo/name@v0.1.0",
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
actual := generatePackageURL(test.pkg, test.distro)
if actual != test.expected {
dmp := diffmatchpatch.New()
diffs := dmp.DiffMain(test.expected, actual, true)
t.Errorf("diff: %s", dmp.DiffPrettyText(diffs))
}
})
}
}
func intRef(i int) *int {
return &i
}

View File

@ -12,7 +12,10 @@ import (
const DpkgDBGlob = "**/var/lib/dpkg/{status,status.d/**}" const DpkgDBGlob = "**/var/lib/dpkg/{status,status.d/**}"
var _ FileOwner = (*DpkgMetadata)(nil) var (
_ FileOwner = (*DpkgMetadata)(nil)
_ urlIdentifier = (*DpkgMetadata)(nil)
)
// DpkgMetadata represents all captured data for a Debian package DB entry; available fields are described // DpkgMetadata represents all captured data for a Debian package DB entry; available fields are described
// at http://manpages.ubuntu.com/manpages/xenial/man1/dpkg-query.1.html in the --showformat section. // at http://manpages.ubuntu.com/manpages/xenial/man1/dpkg-query.1.html in the --showformat section.
@ -36,24 +39,25 @@ type DpkgFileRecord struct {
// PackageURL returns the PURL for the specific Debian package (see https://github.com/package-url/purl-spec) // PackageURL returns the PURL for the specific Debian package (see https://github.com/package-url/purl-spec)
func (m DpkgMetadata) PackageURL(distro *linux.Release) string { func (m DpkgMetadata) PackageURL(distro *linux.Release) string {
if distro == nil { var namespace string
return "" if distro != nil {
namespace = distro.ID
} }
pURL := packageurl.NewPackageURL( return packageurl.NewPackageURL(
// TODO: replace with `packageurl.TypeDebian` upon merge of https://github.com/package-url/packageurl-go/pull/21 // TODO: replace with `packageurl.TypeDebian` upon merge of https://github.com/package-url/packageurl-go/pull/21
// TODO: or, since we're now using an Anchore fork of this module, we could do this sooner. // TODO: or, since we're now using an Anchore fork of this module, we could do this sooner.
"deb", "deb",
distro.ID, namespace,
m.Package, m.Package,
m.Version, m.Version,
packageurl.Qualifiers{ purlQualifiers(
{ map[string]string{
Key: "arch", purlArchQualifier: m.Architecture,
Value: m.Architecture,
}, },
}, distro,
"") ),
return pURL.ToString() "",
).ToString()
} }
func (m DpkgMetadata) OwnedFiles() (result []string) { func (m DpkgMetadata) OwnedFiles() (result []string) {

View File

@ -12,25 +12,29 @@ import (
func TestDpkgMetadata_pURL(t *testing.T) { func TestDpkgMetadata_pURL(t *testing.T) {
tests := []struct { tests := []struct {
distro linux.Release name string
distro *linux.Release
metadata DpkgMetadata metadata DpkgMetadata
expected string expected string
}{ }{
{ {
distro: linux.Release{ name: "go case",
ID: "debian", distro: &linux.Release{
ID: "debian",
VersionID: "11",
}, },
metadata: DpkgMetadata{ metadata: DpkgMetadata{
Package: "p", Package: "p",
Source: "s", Source: "s",
Version: "v", Version: "v",
Architecture: "a",
}, },
expected: "pkg:deb/debian/p@v?arch=a", expected: "pkg:deb/debian/p@v?distro=debian-11",
}, },
{ {
distro: linux.Release{ name: "with arch info",
ID: "ubuntu", distro: &linux.Release{
ID: "ubuntu",
VersionID: "16.04",
}, },
metadata: DpkgMetadata{ metadata: DpkgMetadata{
Package: "p", Package: "p",
@ -38,13 +42,22 @@ func TestDpkgMetadata_pURL(t *testing.T) {
Version: "v", Version: "v",
Architecture: "a", Architecture: "a",
}, },
expected: "pkg:deb/ubuntu/p@v?arch=a", expected: "pkg:deb/ubuntu/p@v?arch=a&distro=ubuntu-16.04",
},
{
name: "missing distro",
metadata: DpkgMetadata{
Package: "p",
Source: "s",
Version: "v",
},
expected: "pkg:deb/p@v",
}, },
} }
for _, test := range tests { for _, test := range tests {
t.Run(test.expected, func(t *testing.T) { t.Run(test.name, func(t *testing.T) {
actual := test.metadata.PackageURL(&test.distro) actual := test.metadata.PackageURL(test.distro)
if actual != test.expected { if actual != test.expected {
dmp := diffmatchpatch.New() dmp := diffmatchpatch.New()
diffs := dmp.DiffMain(test.expected, actual, true) diffs := dmp.DiffMain(test.expected, actual, true)

View File

@ -3,10 +3,14 @@ package pkg
import ( import (
"strings" "strings"
"github.com/anchore/syft/syft/linux"
"github.com/anchore/packageurl-go" "github.com/anchore/packageurl-go"
"github.com/anchore/syft/internal" "github.com/anchore/syft/internal"
) )
var _ urlIdentifier = (*JavaMetadata)(nil)
var JenkinsPluginPomPropertiesGroupIDs = []string{ var JenkinsPluginPomPropertiesGroupIDs = []string{
"io.jenkins.plugins", "io.jenkins.plugins",
"org.jenkins.plugins", "org.jenkins.plugins",
@ -69,7 +73,7 @@ type JavaManifest struct {
} }
// PackageURL returns the PURL for the specific Alpine package (see https://github.com/package-url/purl-spec) // PackageURL returns the PURL for the specific Alpine package (see https://github.com/package-url/purl-spec)
func (m JavaMetadata) PackageURL() string { func (m JavaMetadata) PackageURL(_ *linux.Release) string {
if m.PomProperties != nil { if m.PomProperties != nil {
pURL := packageurl.NewPackageURL( pURL := packageurl.NewPackageURL(
packageurl.TypeMaven, packageurl.TypeMaven,

View File

@ -136,7 +136,7 @@ func TestJavaMetadata_pURL(t *testing.T) {
for _, test := range tests { for _, test := range tests {
t.Run(test.expected, func(t *testing.T) { t.Run(test.expected, func(t *testing.T) {
actual := test.metadata.PackageURL() actual := test.metadata.PackageURL(nil)
if actual != test.expected { if actual != test.expected {
dmp := diffmatchpatch.New() dmp := diffmatchpatch.New()
diffs := dmp.DiffMain(test.expected, actual, true) diffs := dmp.DiffMain(test.expected, actual, true)

View File

@ -4,8 +4,11 @@ import (
"strings" "strings"
"github.com/anchore/packageurl-go" "github.com/anchore/packageurl-go"
"github.com/anchore/syft/syft/linux"
) )
var _ urlIdentifier = (*PhpComposerJSONMetadata)(nil)
// PhpComposerJSONMetadata represents information found from composer v1/v2 "installed.json" files as well as composer.lock files // PhpComposerJSONMetadata represents information found from composer v1/v2 "installed.json" files as well as composer.lock files
type PhpComposerJSONMetadata struct { type PhpComposerJSONMetadata struct {
Name string `json:"name"` Name string `json:"name"`
@ -40,7 +43,7 @@ type PhpComposerAuthors struct {
Homepage string `json:"homepage,omitempty"` Homepage string `json:"homepage,omitempty"`
} }
func (m PhpComposerJSONMetadata) PackageURL() string { func (m PhpComposerJSONMetadata) PackageURL(_ *linux.Release) string {
var name, vendor string var name, vendor string
fields := strings.Split(m.Name, "/") fields := strings.Split(m.Name, "/")
switch len(fields) { switch len(fields) {

View File

@ -20,7 +20,8 @@ func TestPhpComposerJsonMetadata_pURL(t *testing.T) {
Version: "1.0.1", Version: "1.0.1",
}, },
expected: "pkg:composer/ven/name@1.0.1", expected: "pkg:composer/ven/name@1.0.1",
}, { },
{
name: "name with slashes (invalid)", name: "name with slashes (invalid)",
metadata: PhpComposerJSONMetadata{ metadata: PhpComposerJSONMetadata{
Name: "ven/name/component", Name: "ven/name/component",
@ -36,11 +37,23 @@ func TestPhpComposerJsonMetadata_pURL(t *testing.T) {
}, },
expected: "pkg:composer/name@1.0.1", expected: "pkg:composer/name@1.0.1",
}, },
{
name: "ignores distro",
distro: &linux.Release{
ID: "rhel",
VersionID: "8.4",
},
metadata: PhpComposerJSONMetadata{
Name: "ven/name",
Version: "1.0.1",
},
expected: "pkg:composer/ven/name@1.0.1",
},
} }
for _, test := range tests { for _, test := range tests {
t.Run(test.name, func(t *testing.T) { t.Run(test.name, func(t *testing.T) {
actual := test.metadata.PackageURL() actual := test.metadata.PackageURL(test.distro)
if actual != test.expected { if actual != test.expected {
dmp := diffmatchpatch.New() dmp := diffmatchpatch.New()
diffs := dmp.DiffMain(test.expected, actual, true) diffs := dmp.DiffMain(test.expected, actual, true)

View File

@ -4,11 +4,16 @@ import (
"fmt" "fmt"
"sort" "sort"
"github.com/anchore/syft/syft/linux"
"github.com/anchore/packageurl-go" "github.com/anchore/packageurl-go"
"github.com/scylladb/go-set/strset" "github.com/scylladb/go-set/strset"
) )
var _ FileOwner = (*PythonPackageMetadata)(nil) var (
_ FileOwner = (*PythonPackageMetadata)(nil)
_ urlIdentifier = (*PythonPackageMetadata)(nil)
)
// PythonFileDigest represents the file metadata for a single file attributed to a python package. // PythonFileDigest represents the file metadata for a single file attributed to a python package.
type PythonFileDigest struct { type PythonFileDigest struct {
@ -76,7 +81,7 @@ func (m PythonPackageMetadata) OwnedFiles() (result []string) {
return result return result
} }
func (m PythonPackageMetadata) PackageURL() string { func (m PythonPackageMetadata) PackageURL(_ *linux.Release) string {
// generate a purl from the package data // generate a purl from the package data
pURL := packageurl.NewPackageURL( pURL := packageurl.NewPackageURL(
packageurl.TypePyPi, packageurl.TypePyPi,
@ -101,7 +106,7 @@ func (p PythonDirectURLOriginInfo) vcsURLQualifier() packageurl.Qualifiers {
if p.VCS != "" { if p.VCS != "" {
// Taken from https://github.com/package-url/purl-spec/blob/master/PURL-SPECIFICATION.rst#known-qualifiers-keyvalue-pairs // Taken from https://github.com/package-url/purl-spec/blob/master/PURL-SPECIFICATION.rst#known-qualifiers-keyvalue-pairs
// packageurl-go still doesn't support all qualifier names // packageurl-go still doesn't support all qualifier names
return packageurl.Qualifiers{{Key: "vcs_url", Value: fmt.Sprintf("%s+%s@%s", p.VCS, p.URL, p.CommitID)}} return packageurl.Qualifiers{{Key: purlVCSURL, Value: fmt.Sprintf("%s+%s@%s", p.VCS, p.URL, p.CommitID)}}
} }
return packageurl.Qualifiers{} return nil
} }

View File

@ -1,12 +1,60 @@
package pkg package pkg
import ( import (
"github.com/anchore/syft/syft/linux"
"github.com/sergi/go-diff/diffmatchpatch"
"strings" "strings"
"testing" "testing"
"github.com/go-test/deep" "github.com/go-test/deep"
) )
func TestPythonPackageMetadata_pURL(t *testing.T) {
tests := []struct {
name string
distro *linux.Release
metadata PythonPackageMetadata
expected string
}{
{
name: "with vcs info",
metadata: PythonPackageMetadata{
Name: "name",
Version: "v0.1.0",
DirectURLOrigin: &PythonDirectURLOriginInfo{
VCS: "git",
URL: "https://github.com/test/test.git",
CommitID: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
},
},
expected: "pkg:pypi/name@v0.1.0?vcs_url=git+https:%2F%2Fgithub.com%2Ftest%2Ftest.git@aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
},
{
name: "should not respond to release info",
distro: &linux.Release{
ID: "rhel",
VersionID: "8.4",
},
metadata: PythonPackageMetadata{
Name: "name",
Version: "v0.1.0",
},
expected: "pkg:pypi/name@v0.1.0",
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
actual := test.metadata.PackageURL(test.distro)
if actual != test.expected {
dmp := diffmatchpatch.New()
diffs := dmp.DiffMain(test.expected, actual, true)
t.Errorf("diff: %s", dmp.DiffPrettyText(diffs))
}
})
}
}
func TestPythonMetadata_FileOwner(t *testing.T) { func TestPythonMetadata_FileOwner(t *testing.T) {
tests := []struct { tests := []struct {
metadata PythonPackageMetadata metadata PythonPackageMetadata

View File

@ -15,7 +15,10 @@ import (
const RpmDBGlob = "**/var/lib/rpm/Packages" const RpmDBGlob = "**/var/lib/rpm/Packages"
var _ FileOwner = (*RpmdbMetadata)(nil) var (
_ FileOwner = (*RpmdbMetadata)(nil)
_ urlIdentifier = (*RpmdbMetadata)(nil)
)
// RpmdbMetadata represents all captured data for a RPM DB package entry. // RpmdbMetadata represents all captured data for a RPM DB package entry.
type RpmdbMetadata struct { type RpmdbMetadata struct {
@ -47,36 +50,32 @@ type RpmdbFileMode uint16
// PackageURL returns the PURL for the specific RHEL package (see https://github.com/package-url/purl-spec) // PackageURL returns the PURL for the specific RHEL package (see https://github.com/package-url/purl-spec)
func (m RpmdbMetadata) PackageURL(distro *linux.Release) string { func (m RpmdbMetadata) PackageURL(distro *linux.Release) string {
if distro == nil { var namespace string
return "" if distro != nil {
namespace = distro.ID
} }
qualifiers := packageurl.Qualifiers{ qualifiers := map[string]string{
{ purlArchQualifier: m.Arch,
Key: "arch",
Value: m.Arch,
},
} }
if m.Epoch != nil { if m.Epoch != nil {
qualifiers = append(qualifiers, qualifiers[purlEpochQualifier] = strconv.Itoa(*m.Epoch)
packageurl.Qualifier{
Key: "epoch",
Value: strconv.Itoa(*m.Epoch),
},
)
} }
pURL := packageurl.NewPackageURL( return packageurl.NewPackageURL(
packageurl.TypeRPM, packageurl.TypeRPM,
distro.ID, namespace,
m.Name, m.Name,
// for purl the epoch is a qualifier, not part of the version // for purl the epoch is a qualifier, not part of the version
// see https://github.com/package-url/purl-spec/blob/master/PURL-TYPES.rst under the RPM section // see https://github.com/package-url/purl-spec/blob/master/PURL-TYPES.rst under the RPM section
fmt.Sprintf("%s-%s", m.Version, m.Release), fmt.Sprintf("%s-%s", m.Version, m.Release),
qualifiers, purlQualifiers(
"") qualifiers,
return pURL.ToString() distro,
),
"",
).ToString()
} }
func (m RpmdbMetadata) OwnedFiles() (result []string) { func (m RpmdbMetadata) OwnedFiles() (result []string) {

View File

@ -12,13 +12,16 @@ import (
func TestRpmMetadata_pURL(t *testing.T) { func TestRpmMetadata_pURL(t *testing.T) {
tests := []struct { tests := []struct {
distro linux.Release name string
distro *linux.Release
metadata RpmdbMetadata metadata RpmdbMetadata
expected string expected string
}{ }{
{ {
distro: linux.Release{ name: "with arch and epoch",
ID: "centos", distro: &linux.Release{
ID: "centos",
VersionID: "7",
}, },
metadata: RpmdbMetadata{ metadata: RpmdbMetadata{
Name: "p", Name: "p",
@ -27,26 +30,37 @@ func TestRpmMetadata_pURL(t *testing.T) {
Release: "r", Release: "r",
Epoch: intRef(1), Epoch: intRef(1),
}, },
expected: "pkg:rpm/centos/p@v-r?arch=a&epoch=1", expected: "pkg:rpm/centos/p@v-r?arch=a&epoch=1&distro=centos-7",
}, },
{ {
distro: linux.Release{ name: "go case",
ID: "rhel", distro: &linux.Release{
ID: "rhel",
VersionID: "8.4",
}, },
metadata: RpmdbMetadata{ metadata: RpmdbMetadata{
Name: "p", Name: "p",
Version: "v", Version: "v",
Arch: "a",
Release: "r", Release: "r",
Epoch: nil, Epoch: nil,
}, },
expected: "pkg:rpm/rhel/p@v-r?arch=a", expected: "pkg:rpm/rhel/p@v-r?distro=rhel-8.4",
},
{
name: "missing distro",
metadata: RpmdbMetadata{
Name: "p",
Version: "v",
Release: "r",
Epoch: nil,
},
expected: "pkg:rpm/p@v-r",
}, },
} }
for _, test := range tests { for _, test := range tests {
t.Run(test.expected, func(t *testing.T) { t.Run(test.name, func(t *testing.T) {
actual := test.metadata.PackageURL(&test.distro) actual := test.metadata.PackageURL(test.distro)
if actual != test.expected { if actual != test.expected {
dmp := diffmatchpatch.New() dmp := diffmatchpatch.New()
diffs := dmp.DiffMain(test.expected, actual, true) diffs := dmp.DiffMain(test.expected, actual, true)

86
syft/pkg/url.go Normal file
View File

@ -0,0 +1,86 @@
package pkg
import (
"fmt"
"regexp"
"sort"
"strings"
"github.com/anchore/packageurl-go"
"github.com/anchore/syft/syft/linux"
)
const (
purlArchQualifier = "arch"
purlDistroQualifier = "distro"
purlEpochQualifier = "epoch"
purlVCSURL = "vcs_url"
)
type urlIdentifier interface {
PackageURL(*linux.Release) string
}
func URL(p Package, release *linux.Release) string {
if p.Metadata != nil {
if i, ok := p.Metadata.(urlIdentifier); ok {
return i.PackageURL(release)
}
}
// the remaining cases are primarily reserved for packages without metadata struct instances
var purlType = p.Type.PackageURLType()
var name = p.Name
var namespace = ""
switch {
case purlType == "":
// there is no purl type, don't attempt to craft a purl
// TODO: should this be a "generic" purl type instead?
return ""
case p.Type == GoModulePkg:
re := regexp.MustCompile(`(/)[^/]*$`)
fields := re.Split(p.Name, -1)
namespace = fields[0]
name = strings.TrimPrefix(p.Name, namespace+"/")
}
// generate a purl from the package data
return packageurl.NewPackageURL(
purlType,
namespace,
name,
p.Version,
nil,
"",
).ToString()
}
func purlQualifiers(vars map[string]string, release *linux.Release) (q packageurl.Qualifiers) {
keys := make([]string, 0, len(vars))
for k := range vars {
keys = append(keys, k)
}
sort.Strings(keys)
for _, k := range keys {
val := vars[k]
if val == "" {
continue
}
q = append(q, packageurl.Qualifier{
Key: k,
Value: vars[k],
})
}
if release != nil && release.ID != "" && release.VersionID != "" {
q = append(q, packageurl.Qualifier{
Key: purlDistroQualifier,
Value: fmt.Sprintf("%s-%s", release.ID, release.VersionID),
})
}
return q
}

200
syft/pkg/url_test.go Normal file
View File

@ -0,0 +1,200 @@
package pkg
import (
"testing"
"github.com/anchore/syft/syft/linux"
"github.com/scylladb/go-set/strset"
"github.com/sergi/go-diff/diffmatchpatch"
"github.com/stretchr/testify/assert"
)
func TestPackageURL(t *testing.T) {
tests := []struct {
name string
pkg Package
distro *linux.Release
expected string
}{
{
name: "golang",
pkg: Package{
Name: "github.com/anchore/syft",
Version: "v0.1.0",
Type: GoModulePkg,
},
expected: "pkg:golang/github.com/anchore/syft@v0.1.0",
},
{
name: "python",
pkg: Package{
Name: "bad-name",
Version: "bad-v0.1.0",
Type: PythonPkg,
Metadata: PythonPackageMetadata{
Name: "name",
Version: "v0.1.0",
},
},
expected: "pkg:pypi/name@v0.1.0",
},
{
name: "gem",
pkg: Package{
Name: "name",
Version: "v0.1.0",
Type: GemPkg,
},
expected: "pkg:gem/name@v0.1.0",
},
{
name: "npm",
pkg: Package{
Name: "name",
Version: "v0.1.0",
Type: NpmPkg,
},
expected: "pkg:npm/name@v0.1.0",
},
{
name: "deb",
distro: &linux.Release{
ID: "ubuntu",
VersionID: "20.04",
},
pkg: Package{
Name: "bad-name",
Version: "bad-v0.1.0",
Type: DebPkg,
Metadata: DpkgMetadata{
Package: "name",
Version: "v0.1.0",
Architecture: "amd64",
},
},
expected: "pkg:deb/ubuntu/name@v0.1.0?arch=amd64&distro=ubuntu-20.04",
},
{
name: "rpm",
distro: &linux.Release{
ID: "centos",
VersionID: "7",
},
pkg: Package{
Name: "bad-name",
Version: "bad-v0.1.0",
Type: RpmPkg,
Metadata: RpmdbMetadata{
Name: "name",
Version: "0.1.0",
Epoch: intRef(2),
Arch: "amd64",
Release: "3",
},
},
expected: "pkg:rpm/centos/name@0.1.0-3?arch=amd64&epoch=2&distro=centos-7",
},
{
name: "cargo",
pkg: Package{
Name: "name",
Version: "v0.1.0",
Type: RustPkg,
},
expected: "pkg:cargo/name@v0.1.0",
},
{
name: "apk",
distro: &linux.Release{
ID: "alpine",
VersionID: "3.4.6",
},
pkg: Package{
Name: "bad-name",
Version: "bad-v0.1.0",
Type: ApkPkg,
Metadata: ApkMetadata{
Package: "name",
Version: "v0.1.0",
Architecture: "amd64",
},
},
expected: "pkg:alpine/name@v0.1.0?arch=amd64&distro=alpine-3.4.6",
},
{
name: "php-composer",
pkg: Package{
Name: "bad-name",
Version: "bad-v0.1.0",
Type: PhpComposerPkg,
Metadata: PhpComposerJSONMetadata{
Name: "vendor/name",
Version: "2.0.1",
},
},
expected: "pkg:composer/vendor/name@2.0.1",
},
{
name: "java",
pkg: Package{
Name: "bad-name",
Version: "bad-v0.1.0",
Type: JavaPkg,
Metadata: JavaMetadata{
PomProperties: &PomProperties{
Path: "p",
Name: "n",
GroupID: "g.id",
ArtifactID: "a",
Version: "v",
},
},
},
expected: "pkg:maven/g.id/a@v",
},
{
name: "jenkins-plugin",
pkg: Package{
Name: "bad-name",
Version: "bad-v0.1.0",
Type: JenkinsPluginPkg,
Metadata: JavaMetadata{
PomProperties: &PomProperties{
Path: "p",
Name: "n",
GroupID: "g.id",
ArtifactID: "a",
Version: "v",
},
},
},
expected: "pkg:maven/g.id/a@v",
},
}
var pkgTypes []string
var expectedTypes = strset.New()
for _, ty := range AllPkgs {
expectedTypes.Add(string(ty))
}
// testing microsoft packages is not valid for purl at this time
expectedTypes.Remove(string(KbPkg))
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
if test.pkg.Type != "" {
pkgTypes = append(pkgTypes, string(test.pkg.Type))
}
actual := URL(test.pkg, test.distro)
if actual != test.expected {
dmp := diffmatchpatch.New()
diffs := dmp.DiffMain(test.expected, actual, true)
t.Errorf("diff: %s", dmp.DiffPrettyText(diffs))
}
})
}
assert.ElementsMatch(t, expectedTypes.List(), pkgTypes, "missing one or more package types to test against (maybe a package type was added?)")
}