Skip to content
Open
82 changes: 82 additions & 0 deletions alpine/purl.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
package alpine

import (
"context"
"strconv"
"strings"

"github.com/package-url/packageurl-go"

"github.com/quay/claircore"
)

const (
// PURLType is the type of package URL for Alpine APKs.
PURLType = "apk"
// PURLNamespace is the namespace of Alpine APKs.
PURLNamespace = "alpine"
// PURLDistroQualifier is the qualifier key for the distribution.
PURLDistroQualifier = "distro"
)

// GeneratePURL generates a PURL for an Alpine APK package in the format:
// pkg:apk/alpine/<package-name>@<package-version>?arch=<package-arch>&distro=<distro-name>-<distro-version>
func GeneratePURL(ctx context.Context, r *claircore.IndexRecord) (packageurl.PackageURL, error) {
var distro string
if r.Distribution != nil {
distro = r.Distribution.Name + "-" + r.Distribution.Version
}
return packageurl.PackageURL{
Type: PURLType,
Namespace: PURLNamespace,
Name: r.Package.Name,
Version: r.Package.Version,
Qualifiers: packageurl.QualifiersFromMap(map[string]string{
"arch": r.Package.Arch,
PURLDistroQualifier: distro,
}),
}, nil
}

// ParsePURL parses a PURL for an Alpine APK package into a list of IndexRecords.
func ParsePURL(ctx context.Context, purl packageurl.PackageURL) ([]*claircore.IndexRecord, error) {
d := distoToDistribution(purl.Qualifiers.Map()[PURLDistroQualifier])
return []*claircore.IndexRecord{
{
Package: &claircore.Package{
Name: purl.Name,
Version: purl.Version,
Kind: claircore.BINARY,
Arch: purl.Qualifiers.Map()["arch"],
},
Distribution: d,
},
}, nil
}

// DistributionFromPURL converts a PURL string to a *claircore.Distribution.
// distro strings are expected to be in the form "alpine-<version>".
// The distro format is discussed here: https://github.com/package-url/purl-spec/issues/423
func distoToDistribution(distro string) *claircore.Distribution {
// split the distro string into name and version
d := strings.Split(distro, "-")
if d[1] == edgeDist.VersionID {
return edgeDist
}
v := strings.Split(d[1], ".")
if len(v) < 2 {
// There are some cases where the version is 3 parts but the patch doesn't
// influence addressability so we can ignore it.
return nil
}
maj, err := strconv.Atoi(v[0])
if err != nil {
return nil
}
min, err := strconv.Atoi(v[1])
if err != nil {
return nil
}
dist := stableRelease{maj, min}.Distribution()
return dist
}
86 changes: 86 additions & 0 deletions alpine/purl_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
package alpine

import (
"context"
"testing"

"github.com/google/go-cmp/cmp"
"github.com/google/go-cmp/cmp/cmpopts"

"github.com/quay/claircore"
)

func TestRoundTripIndexRecordAlpine(t *testing.T) {
t.Parallel()
ctx := context.Background()

tests := []struct {
name string
ir *claircore.IndexRecord
}{
{
name: "basic",
ir: &claircore.IndexRecord{
Package: &claircore.Package{
Name: "busybox",
Version: "1.36.1-r0",
Arch: "x86_64",
Kind: claircore.BINARY,
PackageDB: "apk:/busybox",
Filepath: "/bin/busybox",
},
Distribution: &claircore.Distribution{
Name: "Alpine Linux",
PrettyName: "Alpine Linux v3.18",
Version: "3.18",
DID: "alpine",
},
},
},
{
name: "edge",
ir: &claircore.IndexRecord{
Package: &claircore.Package{
Name: "busybox",
Version: "1.36.1-r0",
Arch: "x86_64",
Kind: claircore.BINARY,
PackageDB: "apk:/busybox",
Filepath: "/bin/busybox",
},
Distribution: &claircore.Distribution{
Name: "Alpine Linux",
PrettyName: "Alpine Linux edge",
Version: "edge",
DID: "alpine",
},
},
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()

p, err := GeneratePURL(ctx, tc.ir)
if err != nil {
t.Fatalf("GeneratePURL: %v", err)
}
got, err := ParsePURL(ctx, p)
if err != nil {
t.Fatalf("ParsePURL: %v", err)
}
if diff := cmp.Diff([]*claircore.IndexRecord{tc.ir}, got, purlCmp); diff != "" {
t.Fatalf("round-trip mismatch (-want +got):\n%s", diff)
}
})
}
}

var purlCmp = cmp.Options{
// Ignore PackageDB and Filepath as they are not currently used in the matching.
cmpopts.IgnoreFields(claircore.Package{}, "PackageDB", "Filepath"),
// The version is what we save when indexing, the versionID is what we parse
// from the vulnerability database. Neither are used in the matching, the DID,
// DistributionName, and DistributionPrettyName are used.
cmpopts.IgnoreFields(claircore.Distribution{}, "VersionID", "Version"),
}
6 changes: 3 additions & 3 deletions aws/distributionscanner.go
Original file line number Diff line number Diff line change
Expand Up @@ -85,19 +85,19 @@ func (ds *DistributionScanner) Scan(ctx context.Context, l *claircore.Layer) ([]
return nil, nil
}
for _, buff := range files {
dist := ds.parse(buff)
dist := parse(buff)
if dist != nil {
return []*claircore.Distribution{dist}, nil
}
}
return []*claircore.Distribution{}, nil
}

// parse attempts to match all AWS release regexp and returns the associated
// Parse attempts to match all AWS release regexp and returns the associated
// distribution if it exists.
//
// separated into its own method to aid testing.
func (ds *DistributionScanner) parse(buff *bytes.Buffer) *claircore.Distribution {
func parse(buff *bytes.Buffer) *claircore.Distribution {
for _, ur := range awsRegexes {
if ur.regexp.Match(buff.Bytes()) {
dist := releaseToDist(ur.release)
Expand Down
45 changes: 22 additions & 23 deletions aws/distributionscanner_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -72,52 +72,51 @@ SUPPORT_END="2028-03-01"`)

func TestDistributionScanner(t *testing.T) {
table := []struct {
name string
release Release
osRelease []byte
name string
release Release
osRelease []byte
prettyDistName string
}{
{
name: "AL1",
release: AmazonLinux1,
osRelease: AL1v201609OSRelease,
name: "AL1",
release: AmazonLinux1,
osRelease: AL1v201609OSRelease,
prettyDistName: "Amazon Linux AMI 2018.03",
},
{
name: "AL1",
release: AmazonLinux1,
osRelease: AL1v201703OSRelease,
name: "AL1",
release: AmazonLinux1,
osRelease: AL1v201703OSRelease,
prettyDistName: "Amazon Linux AMI 2018.03",
},
{
name: "AL1",
release: AmazonLinux1,
osRelease: AL1v201709OSRelease,
name: "AL1",
release: AmazonLinux1,
osRelease: AL1v201709OSRelease,
prettyDistName: "Amazon Linux AMI 2018.03",
},
{
name: "AL1",
release: AmazonLinux1,
osRelease: AL1v201803OSRelease,
name: "AL1",
release: AmazonLinux1,
osRelease: AL1v201803OSRelease,
prettyDistName: "Amazon Linux AMI 2018.03",
},
{
name: "AL2",
release: AmazonLinux2,
osRelease: AL2OSRelease,
name: "AL2",
release: AmazonLinux2,
osRelease: AL2OSRelease,
prettyDistName: "Amazon Linux 2",
},
{
name: "AL2023",
release: AmazonLinux2023,
osRelease: AL2023OSRelease,
name: "AL2023",
release: AmazonLinux2023,
osRelease: AL2023OSRelease,
prettyDistName: "Amazon Linux 2023",
},
}
for _, tt := range table {
t.Run(tt.name, func(t *testing.T) {
scanner := DistributionScanner{}
dist := scanner.parse(bytes.NewBuffer(tt.osRelease))
dist := parse(bytes.NewBuffer(tt.osRelease))
cmpDist := releaseToDist(tt.release)
if !cmp.Equal(dist, cmpDist) {
t.Fatalf("%v", cmp.Diff(dist, cmpDist))
Expand Down
89 changes: 89 additions & 0 deletions aws/purl.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
package aws

import (
"context"
"fmt"
"strings"

"github.com/package-url/packageurl-go"
"github.com/quay/claircore"
"github.com/quay/claircore/toolkit/types/cpe"
)

const (
// PURLType is the type of package URL for RPM packages.
PURLType = "rpm"
// PURLNamespace is the namespace of AWS RPMs.
PURLNamespace = "aws"
)

// GeneratePURL generates an RPM PURL for a given [claircore.IndexRecord].
func GeneratePURL(ctx context.Context, ir *claircore.IndexRecord) (packageurl.PackageURL, error) {
p := packageurl.PackageURL{
Type: PURLType,
Namespace: PURLNamespace,
Name: ir.Package.Name,
Version: ir.Package.Version,
Qualifiers: packageurl.QualifiersFromMap(map[string]string{
"arch": ir.Package.Arch,
}),
}
if ir.Distribution != nil {
// We don't persist the CPE in the Distribution but try it first in case it's available.
if c := ir.Distribution.CPE.String(); c != "" {
p.Qualifiers = append(p.Qualifiers, packageurl.Qualifier{
Key: "distro_cpe",
Value: c,
})
}

if ir.Distribution.DID != "" && ir.Distribution.VersionID != "" {
p.Qualifiers = append(p.Qualifiers, packageurl.Qualifier{
Key: "distro",
Value: "amzn-" + ir.Distribution.VersionID,
})
}
}
return p, nil
}

// ParsePURL parses an RPM PURL into a list of [claircore.IndexRecord]s.
func ParsePURL(ctx context.Context, purl packageurl.PackageURL) ([]*claircore.IndexRecord, error) {
ir := &claircore.IndexRecord{
Package: &claircore.Package{
Name: purl.Name,
Version: purl.Version,
Arch: purl.Qualifiers.Map()["arch"],
Kind: claircore.BINARY,
},
Distribution: &claircore.Distribution{},
}
// Prefer a distro CPE if provided.
if dc := purl.Qualifiers.Map()["distro_cpe"]; dc != "" {
if wf, err := cpe.Unbind(dc); err == nil {
ir.Distribution = cpeToDistribution(wf)
return []*claircore.IndexRecord{ir}, nil
}
}

// Fallback to legacy distro qualifier parsing: "Name-VersionID".
distroQualifier := purl.Qualifiers.Map()["distro"]
distroParts := strings.SplitN(distroQualifier, "-", 2)
if len(distroParts) != 2 {
return nil, fmt.Errorf("invalid distro PURL: %s", distroQualifier)
}
ver := distroParts[1]
if ver == AL1Dist.Version {
ir.Distribution = AL1Dist
} else {
ir.Distribution = &claircore.Distribution{
Name: "Amazon Linux",
DID: ID,
Version: ver,
VersionID: ver,
PrettyName: "Amazon Linux " + ver,
CPE: cpe.MustUnbind("cpe:o:amazon:amazon_linux:" + ver),
}
}
return []*claircore.IndexRecord{ir}, nil
}
Loading