Repository: anchore/grype
Pull request #2645 (2025-05-07 19:04:55)

fix: include group/namespace across formats
This PR is a follow-on from [adding a PURL list format to Syft](https://github.com/anchore/syft/pull/3853).

This PR removes the specialized PURL conversion to packages, instead allowing Syft to manage this as well as enhance provided SBOMs using data found in the PURL. The results of this is it fixes a number of issues that many package types were incomplete from SBOMs generated using tools other than Syft, which properly included the CycloneDX `group` field and/or namespace in the PURL. Additionally, per-package distro handling has been centralized to populate package distros regardless of import format, so SBOMs created with tools other than Syft are able to specify upstream packages and linux distributions on a per-package basis.

- Fixes https://github.com/anchore/grype/issues/2628
- Fixes https://github.com/anchore/grype/issues/2554
- Fixes https://github.com/anchore/grype/issues/1886
- Fixes https://github.com/anchore/grype/issues/1701



Patch:
diff --git a/cmd/grype/cli/commands/root.go b/cmd/grype/cli/commands/root.go
index b4b8dd8f..b2e64ed6 100644
--- a/cmd/grype/cli/commands/root.go
+++ b/cmd/grype/cli/commands/root.go
@@ -12,6 +12,7 @@ import (
 	"github.com/anchore/clio"
 	"github.com/anchore/grype/cmd/grype/cli/options"
 	"github.com/anchore/grype/grype"
+	"github.com/anchore/grype/grype/distro"
 	"github.com/anchore/grype/grype/event"
 	"github.com/anchore/grype/grype/event/parsers"
 	"github.com/anchore/grype/grype/grypeerr"
@@ -35,7 +36,6 @@ import (
 	"github.com/anchore/grype/internal/stringutil"
 	"github.com/anchore/syft/syft"
 	"github.com/anchore/syft/syft/cataloging"
-	"github.com/anchore/syft/syft/linux"
 	syftPkg "github.com/anchore/syft/syft/pkg"
 	"github.com/anchore/syft/syft/sbom"
 )
@@ -261,15 +261,10 @@ func applyDistroHint(pkgs []pkg.Package, context *pkg.Context, opts *options.Gry
 		if len(split) > 1 {
 			v = split[1]
 		}
-		context.Distro = &linux.Release{
-			PrettyName: d,
-			Name:       d,
-			ID:         d,
-			IDLike: []string{
-				d,
-			},
-			Version:   v,
-			VersionID: v,
+		var err error
+		context.Distro, err = distro.NewFromNameVersion(d, v)
+		if err != nil {
+			log.WithFields("distro", opts.Distro, "error", err).Warn("unable to parse distro")
 		}
 	}
 
diff --git a/go.mod b/go.mod
index a69bb4c9..1ae7937b 100644
--- a/go.mod
+++ b/go.mod
@@ -19,7 +19,7 @@ require (
 	github.com/anchore/go-version v1.2.2-0.20210903204242-51efa5b487c4
 	github.com/anchore/packageurl-go v0.1.1-0.20250220190351-d62adb6e1115
 	github.com/anchore/stereoscope v0.1.4
-	github.com/anchore/syft v1.23.2-0.20250509082920-0b78186a97a0
+	github.com/anchore/syft v1.23.2-0.20250512173324-621d21eb04ca
 	github.com/aquasecurity/go-pep440-version v0.0.1
 	github.com/araddon/dateparse v0.0.0-20210429162001-6b43995a97de
 	github.com/bmatcuk/doublestar/v2 v2.0.4
diff --git a/go.sum b/go.sum
index 03fb3333..c050d466 100644
--- a/go.sum
+++ b/go.sum
@@ -708,8 +708,8 @@ github.com/anchore/packageurl-go v0.1.1-0.20250220190351-d62adb6e1115 h1:ZyRCmiE
 github.com/anchore/packageurl-go v0.1.1-0.20250220190351-d62adb6e1115/go.mod h1:KoYIv7tdP5+CC9VGkeZV4/vGCKsY55VvoG+5dadg4YI=
 github.com/anchore/stereoscope v0.1.4 h1:e+iT9UdUzLBabWGe84hn5sTHDRioY+4IHsVzJXuJlek=
 github.com/anchore/stereoscope v0.1.4/go.mod h1:omWgXDEp/XfqCJlZXIByEo1c3ArZg/qTJ5LBKVLAIdw=
-github.com/anchore/syft v1.23.2-0.20250509082920-0b78186a97a0 h1:Jnsz2S3H6PtGM2A11vqyD6Iyl479lY2be9lNWGLjkuA=
-github.com/anchore/syft v1.23.2-0.20250509082920-0b78186a97a0/go.mod h1:rD7NI0LzopzDuVe5SW+NOUAcbaRe8TYwwourGwBYZZk=
+github.com/anchore/syft v1.23.2-0.20250512173324-621d21eb04ca h1:GY1N+iXgj9rdibkf46vm+VQkASGGtW4NtDSNDAZSUuk=
+github.com/anchore/syft v1.23.2-0.20250512173324-621d21eb04ca/go.mod h1:rD7NI0LzopzDuVe5SW+NOUAcbaRe8TYwwourGwBYZZk=
 github.com/andreyvit/diff v0.0.0-20170406064948-c7f18ee00883/go.mod h1:rCTlJbsFo29Kk6CurOXKm700vrz8f0KW0JNfpkRJY/8=
 github.com/andybalholm/brotli v1.0.4/go.mod h1:fO7iG3H7G2nSZ7m0zPUDn85XEX2GTukHGRSepvi9Eig=
 github.com/andybalholm/brotli v1.1.1 h1:PR2pgnyFznKEugtsUo0xLdDop5SKXd5Qf5ysW+7XdTA=
diff --git a/grype/deprecated.go b/grype/deprecated.go
index fc8ec72b..050974fe 100644
--- a/grype/deprecated.go
+++ b/grype/deprecated.go
@@ -1,6 +1,7 @@
 package grype
 
 import (
+	"github.com/anchore/grype/grype/distro"
 	"github.com/anchore/grype/grype/match"
 	"github.com/anchore/grype/grype/matcher"
 	"github.com/anchore/grype/grype/pkg"
@@ -8,7 +9,6 @@ import (
 	"github.com/anchore/grype/internal/log"
 	"github.com/anchore/stereoscope/pkg/image"
 	"github.com/anchore/syft/syft"
-	"github.com/anchore/syft/syft/linux"
 	"github.com/anchore/syft/syft/source"
 )
 
@@ -33,7 +33,7 @@ func FindVulnerabilities(store vulnerability.Provider, userImageStr string, scop
 }
 
 // TODO: deprecated, will remove before v1.0.0
-func FindVulnerabilitiesForPackage(store vulnerability.Provider, d *linux.Release, matchers []match.Matcher, packages []pkg.Package) match.Matches {
+func FindVulnerabilitiesForPackage(store vulnerability.Provider, d *distro.Distro, matchers []match.Matcher, packages []pkg.Package) match.Matches {
 	exclusionProvider, _ := store.(match.ExclusionProvider) // TODO v5 is an exclusion provider, but v6 is not
 	runner := VulnerabilityMatcher{
 		VulnerabilityProvider: store,
diff --git a/grype/distro/distro.go b/grype/distro/distro.go
index 13f95224..50cf6885 100644
--- a/grype/distro/distro.go
+++ b/grype/distro/distro.go
@@ -6,6 +6,7 @@ import (
 
 	hashiVer "github.com/hashicorp/go-version"
 
+	"github.com/anchore/grype/internal/log"
 	"github.com/anchore/syft/syft/linux"
 )
 
@@ -43,6 +44,13 @@ func New(t Type, version, label string, idLikes ...string) (*Distro, error) {
 		}
 	}
 
+	for i := range idLikes {
+		typ, ok := IDMapping[strings.TrimSpace(idLikes[i])]
+		if ok {
+			idLikes[i] = typ.String()
+		}
+	}
+
 	return &Distro{
 		Type:      t,
 		major:     major,
@@ -54,6 +62,35 @@ func New(t Type, version, label string, idLikes ...string) (*Distro, error) {
 	}, nil
 }
 
+// NewFromNameVersion creates a new Distro object derived from the provided name and version
+func NewFromNameVersion(name, version string) (*Distro, error) {
+	var codename string
+
+	// if there are no digits in the version, it is likely a codename
+	if !strings.ContainsAny(version, "0123456789") {
+		codename = version
+		version = ""
+	}
+
+	typ := IDMapping[name]
+	if typ == "" {
+		typ = Type(name)
+	}
+	return New(typ, version, codename, string(typ))
+}
+
+// FromRelease attempts to get a distro from the linux release, only logging any errors
+func FromRelease(linuxRelease *linux.Release) *Distro {
+	if linuxRelease == nil {
+		return nil
+	}
+	d, err := NewFromRelease(*linuxRelease)
+	if err != nil {
+		log.WithFields("error", err).Warn("unable to create distro from linux distribution")
+	}
+	return d
+}
+
 // NewFromRelease creates a new Distro object derived from a syft linux.Release object.
 func NewFromRelease(release linux.Release) (*Distro, error) {
 	t := TypeFromRelease(release)
@@ -105,6 +142,8 @@ func (d Distro) String() string {
 	versionStr := "(version unknown)"
 	if d.Version != "" {
 		versionStr = d.Version
+	} else if d.Codename != "" {
+		versionStr = d.Codename
 	}
 	return fmt.Sprintf("%s %s", d.Type, versionStr)
 }
diff --git a/grype/internal/packagemetadata/discover_type_names.go b/grype/internal/packagemetadata/discover_type_names.go
index 617e18d8..a5974875 100644
--- a/grype/internal/packagemetadata/discover_type_names.go
+++ b/grype/internal/packagemetadata/discover_type_names.go
@@ -16,7 +16,7 @@ import (
 
 var metadataExceptions = strset.New(
 	"FileMetadata",
-	"PURLFileMetadata",
+	"SBOMFileMetadata",
 	"PURLLiteralMetadata",
 	"CPELiteralMetadata",
 )
diff --git a/grype/pkg/context.go b/grype/pkg/context.go
index 5f46a6f9..0279e24a 100644
--- a/grype/pkg/context.go
+++ b/grype/pkg/context.go
@@ -1,11 +1,11 @@
 package pkg
 
 import (
-	"github.com/anchore/syft/syft/linux"
+	"github.com/anchore/grype/grype/distro"
 	"github.com/anchore/syft/syft/source"
 )
 
 type Context struct {
 	Source *source.Description
-	Distro *linux.Release
+	Distro *distro.Distro
 }
diff --git a/grype/pkg/package.go b/grype/pkg/package.go
index 6a58eef2..abf6b244 100644
--- a/grype/pkg/package.go
+++ b/grype/pkg/package.go
@@ -3,15 +3,16 @@ package pkg
 import (
 	"fmt"
 	"regexp"
+	"slices"
 	"strings"
 
 	"github.com/anchore/grype/grype/distro"
 	"github.com/anchore/grype/internal/log"
 	"github.com/anchore/grype/internal/stringutil"
+	"github.com/anchore/packageurl-go"
 	"github.com/anchore/syft/syft/artifact"
 	"github.com/anchore/syft/syft/cpe"
 	"github.com/anchore/syft/syft/file"
-	"github.com/anchore/syft/syft/linux"
 	syftPkg "github.com/anchore/syft/syft/pkg"
 	cpes "github.com/anchore/syft/syft/pkg/cataloger/common/cpe"
 )
@@ -44,7 +45,7 @@ type Package struct {
 	Metadata  interface{} // This is NOT 1-for-1 the syft metadata! Only the select data needed for vulnerability matching
 }
 
-func New(p syftPkg.Package) Package {
+func New(p syftPkg.Package, enhancers ...Enhancer) Package {
 	metadata, upstreams := dataFromPkg(p)
 
 	licenseObjs := p.Licenses.ToSlice()
@@ -57,7 +58,7 @@ func New(p syftPkg.Package) Package {
 		licenses = []string{}
 	}
 
-	return Package{
+	out := Package{
 		ID:        ID(p.ID()),
 		Name:      p.Name,
 		Version:   p.Version,
@@ -70,13 +71,25 @@ func New(p syftPkg.Package) Package {
 		Upstreams: upstreams,
 		Metadata:  metadata,
 	}
+
+	if len(enhancers) > 0 {
+		purl, err := packageurl.FromString(p.PURL)
+		if err != nil {
+			log.WithFields("purl", purl, "error", err).Debug("unable to parse PURL")
+		}
+		for _, e := range enhancers {
+			e(&out, purl, p)
+		}
+	}
+
+	return out
 }
 
-func FromCollection(catalog *syftPkg.Collection, config SynthesisConfig) []Package {
-	return FromPackages(catalog.Sorted(), config)
+func FromCollection(catalog *syftPkg.Collection, config SynthesisConfig, enhancers ...Enhancer) []Package {
+	return FromPackages(catalog.Sorted(), config, enhancers...)
 }
 
-func FromPackages(syftpkgs []syftPkg.Package, config SynthesisConfig) []Package {
+func FromPackages(syftpkgs []syftPkg.Package, config SynthesisConfig, enhancers ...Enhancer) []Package {
 	var pkgs []Package
 	for _, p := range syftpkgs {
 		if len(p.CPEs) == 0 {
@@ -87,7 +100,7 @@ func FromPackages(syftpkgs []syftPkg.Package, config SynthesisConfig) []Package
 				log.Debugf("no CPEs for package: %s", p)
 			}
 		}
-		pkgs = append(pkgs, New(p))
+		pkgs = append(pkgs, New(p, enhancers...))
 	}
 
 	return pkgs
@@ -98,7 +111,7 @@ func (p Package) String() string {
 	return fmt.Sprintf("Pkg(type=%s, name=%s, version=%s, upstreams=%d)", p.Type, p.Name, p.Version, len(p.Upstreams))
 }
 
-func removePackagesByOverlap(catalog *syftPkg.Collection, relationships []artifact.Relationship, distro *linux.Release) *syftPkg.Collection {
+func removePackagesByOverlap(catalog *syftPkg.Collection, relationships []artifact.Relationship, distro *distro.Distro) *syftPkg.Collection {
 	byOverlap := map[artifact.ID]artifact.Relationship{}
 	for _, r := range relationships {
 		if r.Type == artifact.OwnershipByFileOverlapRelationship {
@@ -151,23 +164,23 @@ func excludePackage(comprehensiveDistroFeed bool, p syftPkg.Package, parent syft
 // distroFeedIsComprehensive returns true if the distro feed
 // is comprehensive enough that we can drop packages owned by distro packages
 // before matching.
-func distroFeedIsComprehensive(distro *linux.Release) bool {
+func distroFeedIsComprehensive(dst *distro.Distro) bool {
 	// TODO: this mechanism should be re-examined once https://github.com/anchore/grype/issues/1426
 	// is addressed
-	if distro == nil {
+	if dst == nil {
 		return false
 	}
-	if distro.ID == "amzn" {
+	if dst.Type == distro.AmazonLinux {
 		// AmazonLinux shows "like rhel" but is not an rhel clone
 		// and does not have an exhaustive vulnerability feed.
 		return false
 	}
 	for _, d := range comprehensiveDistros {
-		if strings.EqualFold(d, distro.ID) {
+		if strings.EqualFold(string(d), dst.Name()) {
 			return true
 		}
-		for _, n := range distro.IDLike {
-			if strings.EqualFold(d, n) {
+		for _, n := range dst.IDLike {
+			if strings.EqualFold(string(d), n) {
 				return true
 			}
 		}
@@ -177,13 +190,13 @@ func distroFeedIsComprehensive(distro *linux.Release) bool {
 
 // computed by:
 // sqlite3 vulnerability.db 'select distinct namespace from vulnerability where fix_state in ("wont-fix", "not-fixed") order by namespace;' | cut -d ':' -f 1 | sort | uniq
-// then removing 'github' and replacing 'redhat' with 'rhel'
-var comprehensiveDistros = []string{
-	"azurelinux",
-	"debian",
-	"mariner",
-	"rhel",
-	"ubuntu",
+// then removing 'github'
+var comprehensiveDistros = []distro.Type{
+	distro.Azure,
+	distro.Debian,
+	distro.Mariner,
+	distro.RedHat,
+	distro.Ubuntu,
 }
 
 func isOSPackage(p syftPkg.Package) bool {
@@ -195,7 +208,7 @@ func isOSPackage(p syftPkg.Package) bool {
 	}
 }
 
-func dataFromPkg(p syftPkg.Package) (interface{}, []UpstreamPackage) {
+func dataFromPkg(p syftPkg.Package) (any, []UpstreamPackage) {
 	var metadata interface{}
 	var upstreams []UpstreamPackage
 
@@ -222,6 +235,7 @@ func dataFromPkg(p syftPkg.Package) (interface{}, []UpstreamPackage) {
 	case syftPkg.JavaVMInstallation:
 		metadata = javaVMDataFromPkg(p)
 	}
+
 	return metadata, upstreams
 }
 
@@ -409,3 +423,93 @@ func ByID(id ID, pkgs []Package) *Package {
 	}
 	return nil
 }
+
+func parseUpstream(pkgName string, value string, pkgType syftPkg.Type) []UpstreamPackage {
+	if pkgType == syftPkg.RpmPkg {
+		return handleSourceRPM(pkgName, value)
+	}
+	return handleDefaultUpstream(pkgName, value)
+}
+
+func handleDefaultUpstream(pkgName string, value string) []UpstreamPackage {
+	fields := strings.Split(value, "@")
+	switch len(fields) {
+	case 2:
+		if fields[0] == pkgName {
+			return nil
+		}
+		return []UpstreamPackage{
+			{
+				Name:    fields[0],
+				Version: fields[1],
+			},
+		}
+	case 1:
+		if fields[0] == pkgName {
+			return nil
+		}
+		return []UpstreamPackage{
+			{
+				Name: fields[0],
+			},
+		}
+	}
+	return nil
+}
+
+func setUpstreamsFromPURL(out *Package, purl packageurl.PackageURL, syftPkg syftPkg.Package) {
+	if len(out.Upstreams) == 0 {
+		out.Upstreams = upstreamsFromPURL(purl, syftPkg.Type)
+	}
+}
+
+// upstreamsFromPURL reads any additional data Grype can use, which is ignored by Syft's PURL conversion
+func upstreamsFromPURL(purl packageurl.PackageURL, pkgType syftPkg.Type) (upstreams []UpstreamPackage) {
+	for _, qualifier := range purl.Qualifiers {
+		if qualifier.Key == syftPkg.PURLQualifierUpstream {
+			for _, newUpstream := range parseUpstream(purl.Name, qualifier.Value, pkgType) {
+				if slices.Contains(upstreams, newUpstream) {
+					continue
+				}
+				upstreams = append(upstreams, newUpstream)
+			}
+		}
+	}
+	return upstreams
+}
+
+func setDistroFromPURL(out *Package, purl packageurl.PackageURL, _ syftPkg.Package) {
+	if out.Distro == nil {
+		out.Distro = distroFromPURL(purl)
+	}
+}
+
+// distroFromPURL reads distro data for Grype can use, which is ignored by Syft's PURL conversion
+func distroFromPURL(purl packageurl.PackageURL) (d *distro.Distro) {
+	var distroName, distroVersion string
+
+	for _, qualifier := range purl.Qualifiers {
+		if qualifier.Key == syftPkg.PURLQualifierDistro {
+			fields := strings.SplitN(qualifier.Value, "-", 2)
+			distroName = fields[0]
+			if len(fields) > 1 {
+				distroVersion = fields[1]
+			}
+		}
+	}
+
+	if distroName != "" {
+		var err error
+		d, err = distro.NewFromNameVersion(distroName, distroVersion)
+		if err != nil {
+			log.WithFields("purl", purl, "error", err).Debug("unable to create distro from a release")
+			d = nil
+		}
+	}
+
+	return d
+}
+
+type Enhancer func(out *Package, purl packageurl.PackageURL, pkg syftPkg.Package)
+
+var purlEnhancers = []Enhancer{setUpstreamsFromPURL, setDistroFromPURL}
diff --git a/grype/pkg/provider.go b/grype/pkg/provider.go
index 165d490a..e06af83b 100644
--- a/grype/pkg/provider.go
+++ b/grype/pkg/provider.go
@@ -6,6 +6,7 @@ import (
 
 	"github.com/bmatcuk/doublestar/v2"
 
+	"github.com/anchore/grype/grype/distro"
 	"github.com/anchore/grype/internal/log"
 	"github.com/anchore/syft/syft/file"
 	"github.com/anchore/syft/syft/sbom"
@@ -15,20 +16,17 @@ var errDoesNotProvide = fmt.Errorf("cannot provide packages from the given sourc
 
 // Provide a set of packages and context metadata describing where they were sourced from.
 func Provide(userInput string, config ProviderConfig) ([]Package, Context, *sbom.SBOM, error) {
-	packages, ctx, s, err := syftSBOMProvider(userInput, config)
-	if !errors.Is(err, errDoesNotProvide) {
-		if len(config.Exclusions) > 0 {
-			var exclusionsErr error
-			packages, exclusionsErr = filterPackageExclusions(packages, config.Exclusions)
-			if exclusionsErr != nil {
-				return nil, ctx, s, exclusionsErr
-			}
-		}
-		log.WithFields("input", userInput).Trace("interpreting input as an SBOM document")
-		return packages, ctx, s, err
+	packages, ctx, s, err := provide(userInput, config)
+	if err != nil {
+		return nil, Context{}, nil, err
 	}
+	setContextDistro(packages, &ctx)
+	return packages, ctx, s, nil
+}
 
-	packages, ctx, s, err = purlProvider(userInput)
+// Provide a set of packages and context metadata describing where they were sourced from.
+func provide(userInput string, config ProviderConfig) ([]Package, Context, *sbom.SBOM, error) {
+	packages, ctx, s, err := purlProvider(userInput, config)
 	if !errors.Is(err, errDoesNotProvide) {
 		log.WithFields("input", userInput).Trace("interpreting input as one or more PURLs")
 		return packages, ctx, s, err
@@ -40,6 +38,19 @@ func Provide(userInput string, config ProviderConfig) ([]Package, Context, *sbom
 		return packages, ctx, s, err
 	}
 
+	packages, ctx, s, err = syftSBOMProvider(userInput, config)
+	if !errors.Is(err, errDoesNotProvide) {
+		if len(config.Exclusions) > 0 {
+			var exclusionsErr error
+			packages, exclusionsErr = filterPackageExclusions(packages, config.Exclusions)
+			if exclusionsErr != nil {
+				return nil, ctx, s, exclusionsErr
+			}
+		}
+		log.WithFields("input", userInput).Trace("interpreting input as an SBOM document")
+		return packages, ctx, s, err
+	}
+
 	log.WithFields("input", userInput).Trace("passing input to syft for interpretation")
 	return syftProvider(userInput, config)
 }
@@ -92,3 +103,29 @@ func locationMatches(location file.Location, exclusion string) (bool, error) {
 	}
 	return matchesRealPath || matchesVirtualPath, nil
 }
+
+func setContextDistro(packages []Package, ctx *Context) {
+	if ctx.Distro != nil {
+		return
+	}
+	var singleDistro *distro.Distro
+	for _, p := range packages {
+		if p.Distro == nil {
+			continue
+		}
+		if singleDistro == nil {
+			singleDistro = p.Distro
+			continue
+		}
+		if singleDistro.Type != p.Distro.Type ||
+			singleDistro.Version != p.Distro.Version ||
+			singleDistro.Codename != p.Distro.Codename {
+			return
+		}
+	}
+
+	// if there is one distro (with one version) represented, use that
+	if singleDistro != nil {
+		ctx.Distro = singleDistro
+	}
+}
diff --git a/grype/pkg/purl_provider.go b/grype/pkg/purl_provider.go
index 8fa83e21..740f3496 100644
--- a/grype/pkg/purl_provider.go
+++ b/grype/pkg/purl_provider.go
@@ -1,21 +1,11 @@
 package pkg
 
 import (
-	"bufio"
 	"fmt"
 	"io"
-	"os"
 	"strings"
 
-	"github.com/scylladb/go-set/strset"
-
-	"github.com/anchore/go-homedir"
-	"github.com/anchore/grype/grype/distro"
-	"github.com/anchore/grype/internal/log"
-	"github.com/anchore/packageurl-go"
-	"github.com/anchore/syft/syft/cpe"
-	"github.com/anchore/syft/syft/linux"
-	"github.com/anchore/syft/syft/pkg"
+	"github.com/anchore/syft/syft/format"
 	"github.com/anchore/syft/syft/sbom"
 	"github.com/anchore/syft/syft/source"
 )
@@ -23,41 +13,28 @@ import (
 const (
 	purlInputPrefix       = "purl:"
 	singlePurlInputPrefix = "pkg:"
-	cpesQualifierKey      = "cpes"
 )
 
 type PURLLiteralMetadata struct {
 	PURL string
 }
 
-type PURLFileMetadata struct {
-	Path string
-}
-
-func purlProvider(userInput string) ([]Package, Context, *sbom.SBOM, error) {
+func purlProvider(userInput string, config ProviderConfig) ([]Package, Context, *sbom.SBOM, error) {
 	reader, ctx, err := getPurlReader(userInput)
 	if err != nil {
 		return nil, Context{}, nil, err
 	}
 
-	return decodePurlsFromReader(reader, ctx)
+	s, _, _, err := format.Decode(reader)
+	if s == nil {
+		return nil, Context{}, nil, fmt.Errorf("unable to decode purl: %w", err)
+	}
+
+	return FromCollection(s.Artifacts.Packages, config.SynthesisConfig, purlEnhancers...), ctx, s, nil
 }
 
 func getPurlReader(userInput string) (r io.Reader, ctx Context, err error) {
-	switch {
-	case strings.HasPrefix(userInput, purlInputPrefix):
-		path := strings.TrimPrefix(userInput, purlInputPrefix)
-		ctx.Source = &source.Description{
-			Metadata: PURLFileMetadata{
-				Path: path,
-			},
-		}
-		file, err := openPurlFile(path)
-		if err != nil {
-			return nil, ctx, err
-		}
-		return file, ctx, nil
-	case strings.HasPrefix(userInput, singlePurlInputPrefix):
+	if strings.HasPrefix(userInput, singlePurlInputPrefix) {
 		ctx.Source = &source.Description{
 			Metadata: PURLLiteralMetadata{
 				PURL: userInput,
@@ -67,210 +44,3 @@ func getPurlReader(userInput string) (r io.Reader, ctx Context, err error) {
 	}
 	return nil, ctx, errDoesNotProvide
 }
-
-func openPurlFile(path string) (*os.File, error) {
-	expandedPath, err := homedir.Expand(path)
-	if err != nil {
-		return nil, fmt.Errorf("unable to open purls: %w", err)
-	}
-
-	f, err := os.Open(expandedPath)
-	if err != nil {
-		return nil, fmt.Errorf("unable to open file %s: %w", expandedPath, err)
-	}
-
-	return f, nil
-}
-
-func createLinuxRelease(name string, version string) *linux.Release {
-	var codename string
-
-	// if there are no digits in the version, it is likely a codename
-	if !strings.ContainsAny(version, "0123456789") {
-		codename = version
-		version = ""
-	}
-
-	return &linux.Release{
-		Name:            name,
-		ID:              name,
-		IDLike:          []string{name},
-		Version:         version,
-		VersionCodename: codename,
-	}
-}
-
-func decodePurlsFromReader(reader io.Reader, ctx Context) ([]Package, Context, *sbom.SBOM, error) {
-	scanner := bufio.NewScanner(reader)
-	var packages []Package
-	var syftPkgs []pkg.Package
-
-	distros := make(map[string]*strset.Set)
-	for scanner.Scan() {
-		rawLine := scanner.Text()
-		p, syftPkg, distroName, distroVersion, err := purlToPackage(rawLine)
-		if err != nil {
-			return nil, Context{}, nil, err
-		}
-		if distroName != "" {
-			if _, ok := distros[distroName]; !ok {
-				distros[distroName] = strset.New()
-			}
-			distros[distroName].Add(distroVersion)
-		}
-		if p != nil {
-			packages = append(packages, *p)
-		}
-		if syftPkg != nil {
-			syftPkgs = append(syftPkgs, *syftPkg)
-		}
-	}
-
-	if err := scanner.Err(); err != nil {
-		return nil, Context{}, nil, err
-	}
-
-	s := &sbom.SBOM{
-		Artifacts: sbom.Artifacts{
-			Packages: pkg.NewCollection(syftPkgs...),
-		},
-	}
-	// Do we have multiple purls
-	// purl litteral <--
-	// purl file <-- FileMetadata
-
-	// if there is one distro (with one version) represented, use that
-	if len(distros) == 1 {
-		for name, versions := range distros {
-			if versions.Size() == 1 {
-				version := versions.List()[0]
-				ctx.Distro = createLinuxRelease(name, version)
-				s.Artifacts.LinuxDistribution = createLinuxRelease(name, version)
-			}
-		}
-	}
-
-	return packages, ctx, s, nil
-}
-
-//nolint:funlen
-func purlToPackage(rawLine string) (*Package, *pkg.Package, string, string, error) {
-	purl, err := packageurl.FromString(rawLine)
-	if err != nil {
-		return nil, nil, "", "", fmt.Errorf("unable to decode purl %s: %w", rawLine, err)
-	}
-
-	var cpes []cpe.CPE
-	var upstreams []UpstreamPackage
-	var distroName, distroVersion string
-	epoch := "0"
-
-	pkgType := pkg.TypeByName(purl.Type)
-
-	for _, qualifier := range purl.Qualifiers {
-		switch qualifier.Key {
-		case cpesQualifierKey:
-			rawCpes := strings.Split(qualifier.Value, ",")
-			for _, rawCpe := range rawCpes {
-				c, err := cpe.New(rawCpe, "")
-				if err != nil {
-					return nil, nil, "", "", fmt.Errorf("unable to decode cpe %s in purl %s: %w", rawCpe, rawLine, err)
-				}
-				cpes = append(cpes, c)
-			}
-		case pkg.PURLQualifierEpoch:
-			epoch = qualifier.Value
-		case pkg.PURLQualifierUpstream:
-			upstreams = append(upstreams, parseUpstream(purl.Name, qualifier.Value, pkgType)...)
-		case pkg.PURLQualifierDistro:
-			name, version := parseDistroQualifier(qualifier.Value)
-			if name != "" && version != "" {
-				distroName = name
-				distroVersion = version
-			}
-		}
-	}
-
-	version := purl.Version
-	if purl.Type == packageurl.TypeRPM && !strings.HasPrefix(purl.Version, fmt.Sprintf("%s:", epoch)) {
-		version = fmt.Sprintf("%s:%s", epoch, purl.Version)
-	}
-
-	name := purl.Name
-	if pkgType == pkg.GoModulePkg && purl.Namespace != "" {
-		name = purl.Namespace + "/" + name
-	}
-
-	syftPkg := pkg.Package{
-		Name:     name,
-		Version:  version,
-		Type:     pkgType,
-		CPEs:     cpes,
-		PURL:     purl.String(),
-		Language: pkg.LanguageByName(purl.Type),
-	}
-
-	syftPkg.SetID()
-
-	distribution, err := distro.NewFromRelease(*createLinuxRelease(distroName, distroVersion))
-	if err != nil {
-		log.Trace("Unable to create Distro from a release: %s", err)
-		distribution = nil
-	}
-
-	return &Package{
-		ID:        ID(purl.String()),
-		CPEs:      cpes,
-		Name:      name,
-		Version:   version,
-		Type:      pkgType,
-		Language:  pkg.LanguageByName(purl.Type),
-		PURL:      purl.String(),
-		Upstreams: upstreams,
-		Distro:    distribution,
-	}, &syftPkg, distroName, distroVersion, nil
-}
-
-func parseDistroQualifier(value string) (string, string) {
-	fields := strings.SplitN(value, "-", 2)
-	switch len(fields) {
-	case 2:
-		return fields[0], fields[1]
-	case 1:
-		return fields[0], ""
-	}
-	return "", ""
-}
-
-func parseUpstream(pkgName string, value string, pkgType pkg.Type) []UpstreamPackage {
-	if pkgType == pkg.RpmPkg {
-		return handleSourceRPM(pkgName, value)
-	}
-	return handleDefaultUpstream(pkgName, value)
-}
-
-func handleDefaultUpstream(pkgName string, value string) []UpstreamPackage {
-	fields := strings.Split(value, "@")
-	switch len(fields) {
-	case 2:
-		if fields[0] == pkgName {
-			return nil
-		}
-		return []UpstreamPackage{
-			{
-				Name:    fields[0],
-				Version: fields[1],
-			},
-		}
-	case 1:
-		if fields[0] == pkgName {
-			return nil
-		}
-		return []UpstreamPackage{
-			{
-				Name: fields[0],
-			},
-		}
-	}
-	return nil
-}
diff --git a/grype/pkg/syft_provider.go b/grype/pkg/syft_provider.go
index 8cfaec2d..9bd8a609 100644
--- a/grype/pkg/syft_provider.go
+++ b/grype/pkg/syft_provider.go
@@ -5,6 +5,7 @@ import (
 	"errors"
 
 	"github.com/anchore/go-collections"
+	"github.com/anchore/grype/grype/distro"
 	"github.com/anchore/grype/internal/log"
 	"github.com/anchore/stereoscope"
 	"github.com/anchore/stereoscope/pkg/image"
@@ -19,14 +20,7 @@ func syftProvider(userInput string, config ProviderConfig) ([]Package, Context,
 	if err != nil {
 		return nil, Context{}, nil, err
 	}
-
-	defer func() {
-		if src != nil {
-			if err := src.Close(); err != nil {
-				log.Tracef("unable to close source: %+v", err)
-			}
-		}
-	}()
+	defer log.CloseAndLogError(src, "syft source")
 
 	s, err := syft.CreateSBOM(context.Background(), src, config.SBOMOptions)
 	if err != nil {
@@ -37,14 +31,16 @@ func syftProvider(userInput string, config ProviderConfig) ([]Package, Context,
 		return nil, Context{}, nil, errors.New("no SBOM provided")
 	}
 
-	pkgCatalog := removePackagesByOverlap(s.Artifacts.Packages, s.Relationships, s.Artifacts.LinuxDistribution)
-
 	srcDescription := src.Describe()
 
+	d := distro.FromRelease(s.Artifacts.LinuxDistribution)
+
+	pkgCatalog := removePackagesByOverlap(s.Artifacts.Packages, s.Relationships, d)
+
 	packages := FromCollection(pkgCatalog, config.SynthesisConfig)
 	pkgCtx := Context{
 		Source: &srcDescription,
-		Distro: s.Artifacts.LinuxDistribution,
+		Distro: d,
 	}
 
 	return packages, pkgCtx, s, nil
diff --git a/grype/pkg/syft_sbom_provider.go b/grype/pkg/syft_sbom_provider.go
index 4c7b88ba..f2ebf9bb 100644
--- a/grype/pkg/syft_sbom_provider.go
+++ b/grype/pkg/syft_sbom_provider.go
@@ -11,66 +11,70 @@ import (
 	"github.com/gabriel-vasile/mimetype"
 
 	"github.com/anchore/go-homedir"
+	"github.com/anchore/grype/grype/distro"
 	"github.com/anchore/grype/internal"
 	"github.com/anchore/grype/internal/log"
 	"github.com/anchore/syft/syft/format"
+	"github.com/anchore/syft/syft/format/syftjson"
 	"github.com/anchore/syft/syft/sbom"
 )
 
+type SBOMFileMetadata struct {
+	Path string
+}
+
 func syftSBOMProvider(userInput string, config ProviderConfig) ([]Package, Context, *sbom.SBOM, error) {
-	s, err := getSBOM(userInput)
+	s, fmtID, path, err := getSBOM(userInput)
 	if err != nil {
 		return nil, Context{}, nil, err
 	}
 
-	catalog := removePackagesByOverlap(s.Artifacts.Packages, s.Relationships, s.Artifacts.LinuxDistribution)
+	src := s.Source
+	if src.Metadata == nil && path != "" {
+		src.Metadata = SBOMFileMetadata{
+			Path: path,
+		}
+	}
+
+	d := distro.FromRelease(s.Artifacts.LinuxDistribution)
 
-	return FromCollection(catalog, config.SynthesisConfig), Context{
-		Source: &s.Source,
-		Distro: s.Artifacts.LinuxDistribution,
-	}, s, nil
-}
+	catalog := removePackagesByOverlap(s.Artifacts.Packages, s.Relationships, d)
 
-func newInputInfo(scheme, contentTye string) *inputInfo {
-	return &inputInfo{
-		Scheme:      scheme,
-		ContentType: contentTye,
+	var enhancers []Enhancer
+	if fmtID != syftjson.ID {
+		enhancers = purlEnhancers
 	}
-}
 
-type inputInfo struct {
-	ContentType string
-	Scheme      string
+	return FromCollection(catalog, config.SynthesisConfig, enhancers...), Context{
+		Source: &src,
+		Distro: d,
+	}, s, nil
 }
 
-func getSBOM(userInput string) (*sbom.SBOM, error) {
-	reader, err := getSBOMReader(userInput)
+func getSBOM(userInput string) (*sbom.SBOM, sbom.FormatID, string, error) {
+	reader, path, err := getSBOMReader(userInput)
 	if err != nil {
-		return nil, err
+		return nil, "", path, err
 	}
 
+	s, fmtID, err := readSBOM(reader)
+	return s, fmtID, path, err
+}
+
+func readSBOM(reader io.ReadSeeker) (*sbom.SBOM, sbom.FormatID, error) {
 	s, fmtID, _, err := format.Decode(reader)
 	if err != nil {
-		return nil, fmt.Errorf("unable to decode sbom: %w", err)
+		return nil, "", fmt.Errorf("unable to decode sbom: %w", err)
 	}
 
 	if fmtID == "" || s == nil {
-		return nil, errDoesNotProvide
+		return nil, "", errDoesNotProvide
 	}
 
-	return s, nil
+	return s, fmtID, nil
 }
 
-func getSBOMReader(userInput string) (r io.ReadSeeker, err error) {
-	r, _, err = extractReaderAndInfo(userInput)
-	if err != nil {
-		return nil, err
-	}
-
-	return r, nil
-}
-
-func extractReaderAndInfo(userInput string) (io.ReadSeeker, *inputInfo, error) {
+func getSBOMReader(userInput string) (io.ReadSeeker, string, error) {
 	switch {
 	// the order of cases matter
 	case userInput == "":
@@ -78,44 +82,39 @@ func extractReaderAndInfo(userInput string) (io.ReadSeeker, *inputInfo, error) {
 		// options from the CLI, otherwise we should not assume there is any valid input from stdin.
 		r, err := stdinReader()
 		if err != nil {
-			return nil, nil, err
+			return nil, "", err
 		}
 		return decodeStdin(r)
 
+	case explicitlySpecifyingPurlList(userInput):
+		filepath := strings.TrimPrefix(userInput, purlInputPrefix)
+		return openFile(filepath)
+
 	case explicitlySpecifyingSBOM(userInput):
 		filepath := strings.TrimPrefix(userInput, "sbom:")
-		return parseSBOM("sbom", filepath)
+		return openFile(filepath)
 
 	case isPossibleSBOM(userInput):
-		return parseSBOM("", userInput)
+		return openFile(userInput)
 
 	default:
-		return nil, nil, errDoesNotProvide
+		return nil, "", errDoesNotProvide
 	}
 }
 
-func parseSBOM(scheme, path string) (io.ReadSeeker, *inputInfo, error) {
-	r, err := openFile(path)
-	if err != nil {
-		return nil, nil, err
-	}
-	info := newInputInfo(scheme, "sbom")
-	return r, info, nil
-}
-
-func decodeStdin(r io.Reader) (io.ReadSeeker, *inputInfo, error) {
+func decodeStdin(r io.Reader) (io.ReadSeeker, string, error) {
 	b, err := io.ReadAll(r)
 	if err != nil {
-		return nil, nil, fmt.Errorf("failed reading stdin: %w", err)
+		return nil, "", fmt.Errorf("failed reading stdin: %w", err)
 	}
 
 	reader := bytes.NewReader(b)
 	_, err = reader.Seek(0, io.SeekStart)
 	if err != nil {
-		return nil, nil, fmt.Errorf("failed to parse stdin: %w", err)
+		return nil, "", fmt.Errorf("failed to parse stdin: %w", err)
 	}
 
-	return reader, newInputInfo("", "sbom"), nil
+	return reader, "", nil
 }
 
 func stdinReader() (io.Reader, error) {
@@ -131,37 +130,26 @@ func stdinReader() (io.Reader, error) {
 	return os.Stdin, nil
 }
 
-func closeFile(f *os.File) {
-	if f == nil {
-		return
-	}
-
-	err := f.Close()
-	if err != nil {
-		log.Warnf("failed to close file %s: %v", f.Name(), err)
-	}
-}
-
-func openFile(path string) (*os.File, error) {
+func openFile(path string) (io.ReadSeekCloser, string, error) {
 	expandedPath, err := homedir.Expand(path)
 	if err != nil {
-		return nil, fmt.Errorf("unable to open SBOM: %w", err)
+		return nil, path, fmt.Errorf("unable to open SBOM: %w", err)
 	}
 
 	f, err := os.Open(expandedPath)
 	if err != nil {
-		return nil, fmt.Errorf("unable to open file %s: %w", expandedPath, err)
+		return nil, path, fmt.Errorf("unable to open file %s: %w", expandedPath, err)
 	}
 
-	return f, nil
+	return f, path, nil
 }
 
 func isPossibleSBOM(userInput string) bool {
-	f, err := openFile(userInput)
+	f, path, err := openFile(userInput)
 	if err != nil {
 		return false
 	}
-	defer closeFile(f)
+	defer log.CloseAndLogError(f, path)
 
 	mType, err := mimetype.DetectReader(f)
 	if err != nil {
@@ -185,3 +173,7 @@ func isAncestorOfMimetype(mType *mimetype.MIME, expected string) bool {
 func explicitlySpecifyingSBOM(userInput string) bool {
 	return strings.HasPrefix(userInput, "sbom:")
 }
+
+func explicitlySpecifyingPurlList(userInput string) bool {
+	return strings.HasPrefix(userInput, purlInputPrefix)
+}
diff --git a/grype/presenter/models/distribution.go b/grype/presenter/models/distribution.go
index e105a8da..a8b9d792 100644
--- a/grype/presenter/models/distribution.go
+++ b/grype/presenter/models/distribution.go
@@ -2,8 +2,6 @@ package models
 
 import (
 	"github.com/anchore/grype/grype/distro"
-	"github.com/anchore/grype/internal/log"
-	"github.com/anchore/syft/syft/linux"
 )
 
 // distribution provides information about a detected Linux distribution.
@@ -14,24 +12,11 @@ type distribution struct {
 }
 
 // newDistribution creates a struct with the Linux distribution to be represented in JSON.
-func newDistribution(r *linux.Release) distribution {
-	if r == nil {
+func newDistribution(d *distro.Distro) distribution {
+	if d == nil {
 		return distribution{}
 	}
 
-	// attempt to use the strong distro type (like the matchers do)
-	d, err := distro.NewFromRelease(*r)
-	if err != nil {
-		log.Warnf("unable to determine linux distribution: %+v", err)
-
-		// as a fallback use the raw release information
-		return distribution{
-			Name:    r.ID,
-			Version: r.VersionID,
-			IDLike:  cleanIDLike(r.IDLike),
-		}
-	}
-
 	return distribution{
 		Name:    d.Name(),
 		Version: d.Version,
diff --git a/grype/presenter/models/source.go b/grype/presenter/models/source.go
index 6bbb1fe6..f21c0674 100644
--- a/grype/presenter/models/source.go
+++ b/grype/presenter/models/source.go
@@ -15,9 +15,9 @@ type source struct {
 // newSource creates a new source object to be represented into JSON.
 func newSource(src syftSource.Description) (source, error) {
 	switch m := src.Metadata.(type) {
-	case pkg.PURLFileMetadata:
+	case pkg.SBOMFileMetadata:
 		return source{
-			Type:   "purl-file",
+			Type:   "sbom-file",
 			Target: m.Path,
 		}, nil
 	case pkg.PURLLiteralMetadata:
diff --git a/grype/presenter/sarif/presenter.go b/grype/presenter/sarif/presenter.go
index ac8d0b28..4bd6a3cc 100644
--- a/grype/presenter/sarif/presenter.go
+++ b/grype/presenter/sarif/presenter.go
@@ -407,8 +407,8 @@ func (p Presenter) resultMessage(m models.Match) sarif.Message {
 		src = fmt.Sprintf("at: %s", path)
 	case pkg.PURLLiteralMetadata:
 		src = fmt.Sprintf("from purl literal %q", meta.PURL)
-	case pkg.PURLFileMetadata:
-		src = fmt.Sprintf("from purl file %s", meta.Path)
+	case pkg.SBOMFileMetadata:
+		src = fmt.Sprintf("from SBOM file %s", meta.Path)
 	}
 	message := fmt.Sprintf("A %s vulnerability in %s package: %s, version %s was found %s",
 		severityText(m), m.Artifact.Type, m.Artifact.Name, m.Artifact.Version, src)
diff --git a/grype/vulnerability_matcher.go b/grype/vulnerability_matcher.go
index e0889ae5..0df6da3c 100644
--- a/grype/vulnerability_matcher.go
+++ b/grype/vulnerability_matcher.go
@@ -20,7 +20,6 @@ import (
 	"github.com/anchore/grype/grype/vulnerability"
 	"github.com/anchore/grype/internal/bus"
 	"github.com/anchore/grype/internal/log"
-	"github.com/anchore/syft/syft/linux"
 	syftPkg "github.com/anchore/syft/syft/pkg"
 )
 
@@ -139,7 +138,7 @@ func (m *VulnerabilityMatcher) mergeIgnoredMatches(allIgnoredMatches ...[]match.
 
 //nolint:funlen
 func (m *VulnerabilityMatcher) searchDBForMatches(
-	release *linux.Release,
+	d *distro.Distro,
 	packages []pkg.Package,
 	progressMonitor *monitorWriter,
 ) (match.Matches, error) {
@@ -147,19 +146,6 @@ func (m *VulnerabilityMatcher) searchDBForMatches(
 	var allIgnored []match.IgnoredMatch
 	matcherIndex, defaultMatcher := newMatcherIndex(m.Matchers)
 
-	var d *distro.Distro
-	if release != nil {
-		var err error
-		d, err = distro.NewFromRelease(*release)
-		if err != nil {
-			log.Warnf("unable to determine linux distribution: %+v", err)
-		}
-		if d != nil && d.Disabled() {
-			log.Warnf("unsupported linux distribution: %s", d.Name())
-			return match.NewMatches(), nil
-		}
-	}
-
 	if defaultMatcher == nil {
 		defaultMatcher = stock.NewStockMatcher(stock.MatcherConfig{UseCPEs: true})
 	}


Test patch:
diff --git a/cmd/grype/cli/commands/root_test.go b/cmd/grype/cli/commands/root_test.go
index bd1d27b8..e50d3408 100644
--- a/cmd/grype/cli/commands/root_test.go
+++ b/cmd/grype/cli/commands/root_test.go
@@ -28,24 +28,24 @@ func Test_applyDistroHint(t *testing.T) {
 	applyDistroHint([]pkg.Package{}, &ctx, &cfg)
 	assert.NotNil(t, ctx.Distro)
 
-	assert.Equal(t, "alpine", ctx.Distro.Name)
+	assert.Equal(t, "alpine", ctx.Distro.Name())
 	assert.Equal(t, "3.10", ctx.Distro.Version)
 
 	// does override an existing distro
-	cfg.Distro = "ubuntu:latest"
+	cfg.Distro = "ubuntu:24.04"
 	applyDistroHint([]pkg.Package{}, &ctx, &cfg)
 	assert.NotNil(t, ctx.Distro)
 
-	assert.Equal(t, "ubuntu", ctx.Distro.Name)
-	assert.Equal(t, "latest", ctx.Distro.Version)
+	assert.Equal(t, "ubuntu", ctx.Distro.Name())
+	assert.Equal(t, "24.04", ctx.Distro.Version)
 
 	// doesn't remove an existing distro when empty
 	cfg.Distro = ""
 	applyDistroHint([]pkg.Package{}, &ctx, &cfg)
 	assert.NotNil(t, ctx.Distro)
 
-	assert.Equal(t, "ubuntu", ctx.Distro.Name)
-	assert.Equal(t, "latest", ctx.Distro.Version)
+	assert.Equal(t, "ubuntu", ctx.Distro.Name())
+	assert.Equal(t, "24.04", ctx.Distro.Version)
 }
 
 func Test_getProviderConfig(t *testing.T) {
diff --git a/grype/distro/distro_test.go b/grype/distro/distro_test.go
index e399b7d2..c9ca73e6 100644
--- a/grype/distro/distro_test.go
+++ b/grype/distro/distro_test.go
@@ -29,10 +29,12 @@ func Test_NewDistroFromRelease(t *testing.T) {
 				ID:        "centos",
 				VersionID: "8",
 				Version:   "7",
+				IDLike:    []string{"rhel"},
 			},
 			expected: &Distro{
 				Type:    CentOS,
 				Version: "8",
+				IDLike:  []string{"redhat"},
 			},
 			major: "8",
 			minor: "",
diff --git a/grype/pkg/package_test.go b/grype/pkg/package_test.go
index 1267b3ea..bb64df53 100644
--- a/grype/pkg/package_test.go
+++ b/grype/pkg/package_test.go
@@ -7,6 +7,7 @@ import (
 
 	"github.com/stretchr/testify/assert"
 
+	"github.com/anchore/grype/grype/distro"
 	"github.com/anchore/syft/syft/artifact"
 	"github.com/anchore/syft/syft/cpe"
 	"github.com/anchore/syft/syft/file"
@@ -983,35 +984,35 @@ func Test_RemovePackagesByOverlap(t *testing.T) {
 		},
 		{
 			name: "python bindings for system RPM install",
-			sbom: withDistro(catalogWithOverlaps(
+			sbom: withLinuxRelease(catalogWithOverlaps(
 				[]string{"rpm:python3-rpm@4.14.3-26.el8", "python:rpm@4.14.3"},
 				[]string{"rpm:python3-rpm@4.14.3-26.el8 -> python:rpm@4.14.3"}), "rhel"),
 			expectedPackages: []string{"rpm:python3-rpm@4.14.3-26.el8"},
 		},
 		{
 			name: "amzn linux doesn't remove packages in this way",
-			sbom: withDistro(catalogWithOverlaps(
+			sbom: withLinuxRelease(catalogWithOverlaps(
 				[]string{"rpm:python3-rpm@4.14.3-26.el8", "python:rpm@4.14.3"},
 				[]string{"rpm:python3-rpm@4.14.3-26.el8 -> python:rpm@4.14.3"}), "amzn"),
 			expectedPackages: []string{"rpm:python3-rpm@4.14.3-26.el8", "python:rpm@4.14.3"},
 		},
 		{
 			name: "remove overlapping package when parent version is prefix of child version",
-			sbom: withDistro(catalogWithOverlaps(
+			sbom: withLinuxRelease(catalogWithOverlaps(
 				[]string{"rpm:kernel-rt-core@5.14.0-503.40.1.el9_5", "linux-kernel:linux-kernel@5.14.0-503.40.1.el9_5.x86_64+rt"},
 				[]string{"rpm:kernel-rt-core@5.14.0-503.40.1.el9_5 -> linux-kernel:linux-kernel@5.14.0-503.40.1.el9_5.x86_64+rt"}), "rhel"),
 			expectedPackages: []string{"rpm:kernel-rt-core@5.14.0-503.40.1.el9_5"},
 		},
 		{
 			name: "remove overlapping package when child version is prefix of parent version",
-			sbom: withDistro(catalogWithOverlaps(
+			sbom: withLinuxRelease(catalogWithOverlaps(
 				[]string{"rpm:kernel-rt-core@5.14.0-503.40.1.el9_5+rt", "linux-kernel:linux-kernel@5.14.0-503.40.1.el9_5"},
 				[]string{"rpm:kernel-rt-core@5.14.0-503.40.1.el9_5+rt -> linux-kernel:linux-kernel@5.14.0-503.40.1.el9_5"}), "rhel"),
 			expectedPackages: []string{"rpm:kernel-rt-core@5.14.0-503.40.1.el9_5+rt"},
 		},
 		{
 			name: "do not remove overlapping package when versions are not similar",
-			sbom: withDistro(catalogWithOverlaps(
+			sbom: withLinuxRelease(catalogWithOverlaps(
 				[]string{"rpm:kernel@5.14.0-503.40.1.el9_5", "linux-kernel:linux-kernel@6.17"},
 				[]string{"rpm:kernel@5.14.0-503.40.1.el9_5 -> linux-kernel:linux-kernel@6.17"}), "rhel"),
 			expectedPackages: []string{"rpm:kernel@5.14.0-503.40.1.el9_5", "linux-kernel:linux-kernel@6.17"},
@@ -1019,7 +1020,8 @@ func Test_RemovePackagesByOverlap(t *testing.T) {
 	}
 	for _, test := range tests {
 		t.Run(test.name, func(t *testing.T) {
-			catalog := removePackagesByOverlap(test.sbom.Artifacts.Packages, test.sbom.Relationships, test.sbom.Artifacts.LinuxDistribution)
+			d := distro.FromRelease(test.sbom.Artifacts.LinuxDistribution)
+			catalog := removePackagesByOverlap(test.sbom.Artifacts.Packages, test.sbom.Relationships, d)
 			pkgs := FromCollection(catalog, SynthesisConfig{})
 			var pkgNames []string
 			for _, p := range pkgs {
@@ -1100,7 +1102,7 @@ func catalogWithOverlaps(packages []string, overlaps []string) *sbom.SBOM {
 	}
 }
 
-func withDistro(s *sbom.SBOM, id string) *sbom.SBOM {
+func withLinuxRelease(s *sbom.SBOM, id string) *sbom.SBOM {
 	s.Artifacts.LinuxDistribution = &linux.Release{
 		ID: id,
 	}
diff --git a/grype/pkg/purl_provider_test.go b/grype/pkg/purl_provider_test.go
index a2dd5058..a63d5573 100644
--- a/grype/pkg/purl_provider_test.go
+++ b/grype/pkg/purl_provider_test.go
@@ -8,10 +8,7 @@ import (
 	"github.com/stretchr/testify/require"
 
 	"github.com/anchore/grype/grype/distro"
-	"github.com/anchore/syft/syft/file"
-	"github.com/anchore/syft/syft/linux"
 	"github.com/anchore/syft/syft/pkg"
-	"github.com/anchore/syft/syft/sbom"
 	"github.com/anchore/syft/syft/source"
 )
 
@@ -21,7 +18,6 @@ func Test_PurlProvider(t *testing.T) {
 		userInput string
 		context   Context
 		pkgs      []Package
-		sbom      *sbom.SBOM
 		wantErr   require.ErrorAssertionFunc
 	}{
 		{
@@ -42,26 +38,15 @@ func Test_PurlProvider(t *testing.T) {
 					PURL:    "pkg:apk/curl@7.61.1",
 				},
 			},
-			sbom: &sbom.SBOM{
-				Artifacts: sbom.Artifacts{
-					Packages: pkg.NewCollection(pkg.Package{
-						Name:    "curl",
-						Version: "7.61.1",
-						Type:    pkg.ApkPkg,
-						PURL:    "pkg:apk/curl@7.61.1",
-					}),
-				},
-			},
 		},
 		{
 			name:      "os with codename",
 			userInput: "pkg:deb/debian/sysv-rc@2.88dsf-59?arch=all&distro=debian-jessie&upstream=sysvinit",
 			context: Context{
-				Distro: &linux.Release{
-					Name:            "debian",
-					ID:              "debian",
-					IDLike:          []string{"debian"},
-					VersionCodename: "jessie", // important!
+				Distro: &distro.Distro{
+					Type:     "debian",
+					IDLike:   []string{"debian"},
+					Codename: "jessie", // important!
 				},
 				Source: &source.Description{
 					Metadata: PURLLiteralMetadata{
@@ -83,22 +68,6 @@ func Test_PurlProvider(t *testing.T) {
 					},
 				},
 			},
-			sbom: &sbom.SBOM{
-				Artifacts: sbom.Artifacts{
-					Packages: pkg.NewCollection(pkg.Package{
-						Name:    "sysv-rc",
-						Version: "2.88dsf-59",
-						Type:    pkg.DebPkg,
-						PURL:    "pkg:deb/debian/sysv-rc@2.88dsf-59?arch=all&distro=debian-jessie&upstream=sysvinit",
-					}),
-					LinuxDistribution: &linux.Release{
-						Name:            "debian",
-						ID:              "debian",
-						IDLike:          []string{"debian"},
-						VersionCodename: "jessie",
-					},
-				},
-			},
 		},
 		{
 			name:      "default upstream",
@@ -123,16 +92,6 @@ func Test_PurlProvider(t *testing.T) {
 					},
 				},
 			},
-			sbom: &sbom.SBOM{
-				Artifacts: sbom.Artifacts{
-					Packages: pkg.NewCollection(pkg.Package{
-						Name:    "libcrypto3",
-						Version: "3.3.2",
-						Type:    pkg.ApkPkg,
-						PURL:    "pkg:apk/libcrypto3@3.3.2?upstream=openssl",
-					}),
-				},
-			},
 		},
 		{
 			name:      "upstream with version",
@@ -158,25 +117,14 @@ func Test_PurlProvider(t *testing.T) {
 					},
 				},
 			},
-			sbom: &sbom.SBOM{
-				Artifacts: sbom.Artifacts{
-					Packages: pkg.NewCollection(pkg.Package{
-						Name:    "libcrypto3",
-						Version: "3.3.2",
-						Type:    pkg.ApkPkg,
-						PURL:    "pkg:apk/libcrypto3@3.3.2?upstream=openssl%403.2.1",
-					}),
-				},
-			},
 		},
 		{
 			name:      "upstream for source RPM",
 			userInput: "pkg:rpm/redhat/systemd-x@239-82.el8_10.2?arch=aarch64&distro=rhel-8.10&upstream=systemd-239-82.el8_10.2.src.rpm",
 			context: Context{
-				Distro: &linux.Release{
-					Name:    "rhel",
-					ID:      "rhel",
-					IDLike:  []string{"rhel"},
+				Distro: &distro.Distro{
+					Type:    "redhat",
+					IDLike:  []string{"redhat"},
 					Version: "8.10",
 				},
 				Source: &source.Description{
@@ -188,10 +136,10 @@ func Test_PurlProvider(t *testing.T) {
 			pkgs: []Package{
 				{
 					Name:    "systemd-x",
-					Version: "0:239-82.el8_10.2",
+					Version: "239-82.el8_10.2",
 					Type:    pkg.RpmPkg,
 					PURL:    "pkg:rpm/redhat/systemd-x@239-82.el8_10.2?arch=aarch64&distro=rhel-8.10&upstream=systemd-239-82.el8_10.2.src.rpm",
-					Distro:  &distro.Distro{Type: distro.RedHat, Version: "8.10", Codename: "", IDLike: []string{"rhel"}},
+					Distro:  &distro.Distro{Type: distro.RedHat, Version: "8.10", Codename: "", IDLike: []string{"redhat"}},
 					Upstreams: []UpstreamPackage{
 						{
 							Name:    "systemd",
@@ -200,31 +148,14 @@ func Test_PurlProvider(t *testing.T) {
 					},
 				},
 			},
-			sbom: &sbom.SBOM{
-				Artifacts: sbom.Artifacts{
-					Packages: pkg.NewCollection(pkg.Package{
-						Name:    "systemd-x",
-						Version: "0:239-82.el8_10.2",
-						Type:    pkg.RpmPkg,
-						PURL:    "pkg:rpm/redhat/systemd-x@239-82.el8_10.2?arch=aarch64&distro=rhel-8.10&upstream=systemd-239-82.el8_10.2.src.rpm",
-					}),
-					LinuxDistribution: &linux.Release{
-						Name:    "rhel",
-						ID:      "rhel",
-						IDLike:  []string{"rhel"},
-						Version: "8.10",
-					},
-				},
-			},
 		},
 		{
 			name:      "RPM with epoch",
 			userInput: "pkg:rpm/redhat/dbus-common@1.12.8-26.el8?arch=noarch&distro=rhel-8.10&epoch=1&upstream=dbus-1.12.8-26.el8.src.rpm",
 			context: Context{
-				Distro: &linux.Release{
-					Name:    "rhel",
-					ID:      "rhel",
-					IDLike:  []string{"rhel"},
+				Distro: &distro.Distro{
+					Type:    "redhat",
+					IDLike:  []string{"redhat"},
 					Version: "8.10",
 				},
 				Source: &source.Description{
@@ -239,7 +170,7 @@ func Test_PurlProvider(t *testing.T) {
 					Version: "1:1.12.8-26.el8",
 					Type:    pkg.RpmPkg,
 					PURL:    "pkg:rpm/redhat/dbus-common@1.12.8-26.el8?arch=noarch&distro=rhel-8.10&epoch=1&upstream=dbus-1.12.8-26.el8.src.rpm",
-					Distro:  &distro.Distro{Type: distro.RedHat, Version: "8.10", Codename: "", IDLike: []string{"rhel"}},
+					Distro:  &distro.Distro{Type: distro.RedHat, Version: "8.10", Codename: "", IDLike: []string{"redhat"}},
 					Upstreams: []UpstreamPackage{
 						{
 							Name:    "dbus",
@@ -248,104 +179,13 @@ func Test_PurlProvider(t *testing.T) {
 					},
 				},
 			},
-			sbom: &sbom.SBOM{
-				Artifacts: sbom.Artifacts{
-					Packages: pkg.NewCollection(pkg.Package{
-						Name:    "dbus-common",
-						Version: "1:1.12.8-26.el8",
-						Type:    pkg.RpmPkg,
-						PURL:    "pkg:rpm/redhat/dbus-common@1.12.8-26.el8?arch=noarch&distro=rhel-8.10&epoch=1&upstream=dbus-1.12.8-26.el8.src.rpm",
-					}),
-					LinuxDistribution: &linux.Release{
-						Name:    "rhel",
-						ID:      "rhel",
-						IDLike:  []string{"rhel"},
-						Version: "8.10",
-					},
-				},
-			},
-		},
-		{
-			name:      "takes multiple purls",
-			userInput: "purl:test-fixtures/purl/valid-purl.txt",
-			context: Context{
-				Distro: &linux.Release{
-					Name:    "debian",
-					ID:      "debian",
-					IDLike:  []string{"debian"},
-					Version: "8",
-				},
-				Source: &source.Description{
-					Metadata: PURLFileMetadata{
-						Path: "test-fixtures/purl/valid-purl.txt",
-					},
-				},
-			},
-			pkgs: []Package{
-				{
-					Name:    "sysv-rc",
-					Version: "2.88dsf-59",
-					Type:    pkg.DebPkg,
-					PURL:    "pkg:deb/debian/sysv-rc@2.88dsf-59?arch=all&distro=debian-8&upstream=sysvinit",
-					Distro:  &distro.Distro{Type: distro.Debian, Version: "8", Codename: "", IDLike: []string{"debian"}},
-					Upstreams: []UpstreamPackage{
-						{
-							Name: "sysvinit",
-						},
-					},
-				},
-				{
-					Name:    "ant",
-					Version: "1.10.8",
-					Type:    pkg.JavaPkg,
-					PURL:    "pkg:maven/org.apache.ant/ant@1.10.8",
-				},
-				{
-					Name:    "log4j-core",
-					Version: "2.14.1",
-					Type:    pkg.JavaPkg,
-					PURL:    "pkg:maven/org.apache.logging.log4j/log4j-core@2.14.1",
-				},
-			},
-			sbom: &sbom.SBOM{
-				Artifacts: sbom.Artifacts{
-					Packages: pkg.NewCollection(
-						pkg.Package{
-							Name:    "sysv-rc",
-							Version: "2.88dsf-59",
-							Type:    pkg.DebPkg,
-							PURL:    "pkg:deb/debian/sysv-rc@2.88dsf-59?arch=all&distro=debian-8&upstream=sysvinit",
-						},
-						pkg.Package{
-							Name:     "ant",
-							Version:  "1.10.8",
-							Type:     pkg.JavaPkg,
-							Language: pkg.Java,
-							PURL:     "pkg:maven/org.apache.ant/ant@1.10.8",
-						},
-						pkg.Package{
-							Name:     "log4j-core",
-							Version:  "2.14.1",
-							Type:     pkg.JavaPkg,
-							Language: pkg.Java,
-							PURL:     "pkg:maven/org.apache.logging.log4j/log4j-core@2.14.1",
-						}),
-					LinuxDistribution: &linux.Release{
-						Name:    "debian",
-						ID:      "debian",
-						IDLike:  []string{"debian"},
-						Version: "8",
-					},
-				},
-			},
 		},
 		{
 			name:      "infer context when distro is present for single purl",
 			userInput: "pkg:apk/curl@7.61.1?arch=aarch64&distro=alpine-3.20.3",
 			context: Context{
-				Distro: &linux.Release{
-					Name:    "alpine",
-					ID:      "alpine",
+				Distro: &distro.Distro{
+					Type:    "alpine",
 					IDLike:  []string{"alpine"},
 					Version: "3.20.3",
 				},
@@ -364,22 +204,6 @@ func Test_PurlProvider(t *testing.T) {
 					Distro:  &distro.Distro{Type: distro.Alpine, Version: "3.20.3", Codename: "", IDLike: []string{"alpine"}},
 				},
 			},
-			sbom: &sbom.SBOM{
-				Artifacts: sbom.Artifacts{
-					Packages: pkg.NewCollection(pkg.Package{
-						Name:    "curl",
-						Version: "7.61.1",
-						Type:    pkg.ApkPkg,
-						PURL:    "pkg:apk/curl@7.61.1?arch=aarch64&distro=alpine-3.20.3",
-					}),
-					LinuxDistribution: &linux.Release{
-						Name:    "alpine",
-						ID:      "alpine",
-						IDLike:  []string{"alpine"},
-						Version: "3.20.3",
-					},
-				},
-			},
 		},
 		{
 			name:      "include namespace in name when purl is type Golang",
@@ -397,17 +221,6 @@ func Test_PurlProvider(t *testing.T) {
 					PURL:    "pkg:golang/k8s.io/ingress-nginx@v1.11.2",
 				},
 			},
-			sbom: &sbom.SBOM{
-				Artifacts: sbom.Artifacts{
-					Packages: pkg.NewCollection(pkg.Package{
-						Name:     "k8s.io/ingress-nginx",
-						Version:  "v1.11.2",
-						Type:     pkg.GoModulePkg,
-						Language: pkg.Go,
-						PURL:     "pkg:golang/k8s.io/ingress-nginx@v1.11.2",
-					}),
-				},
-			},
 		},
 		{
 			name:      "include complex namespace in name when purl is type Golang",
@@ -425,17 +238,6 @@ func Test_PurlProvider(t *testing.T) {
 					PURL:    "pkg:golang/github.com/wazuh/wazuh@v4.5.0",
 				},
 			},
-			sbom: &sbom.SBOM{
-				Artifacts: sbom.Artifacts{
-					Packages: pkg.NewCollection(pkg.Package{
-						Name:     "github.com/wazuh/wazuh",
-						Version:  "v4.5.0",
-						Type:     pkg.GoModulePkg,
-						PURL:     "pkg:golang/github.com/wazuh/wazuh@v4.5.0",
-						Language: pkg.Go,
-					}),
-				},
-			},
 		},
 		{
 			name:      "do not include namespace when given blank input blank",
@@ -453,149 +255,12 @@ func Test_PurlProvider(t *testing.T) {
 					PURL:    "pkg:golang/wazuh@v4.5.0",
 				},
 			},
-			sbom: &sbom.SBOM{
-				Artifacts: sbom.Artifacts{
-					Packages: pkg.NewCollection(pkg.Package{
-						Name:     "wazuh",
-						Version:  "v4.5.0",
-						Type:     pkg.GoModulePkg,
-						PURL:     "pkg:golang/wazuh@v4.5.0",
-						Language: pkg.Go,
-					}),
-				},
-			},
 		},
 		{
-			name:      "infer context when distro is present for multiple similar purls",
-			userInput: "purl:test-fixtures/purl/homogeneous-os.txt",
-			context: Context{
-				Distro: &linux.Release{
-					Name:    "alpine",
-					ID:      "alpine",
-					IDLike:  []string{"alpine"},
-					Version: "3.20.3",
-				},
-				Source: &source.Description{
-					Metadata: PURLFileMetadata{
-						Path: "test-fixtures/purl/homogeneous-os.txt",
-					},
-				},
-			},
-			pkgs: []Package{
-				{
-					Name:    "openssl",
-					Version: "3.2.1",
-					Type:    pkg.ApkPkg,
-					PURL:    "pkg:apk/openssl@3.2.1?arch=aarch64&distro=alpine-3.20.3",
-					Distro:  &distro.Distro{Type: distro.Alpine, Version: "3.20.3", Codename: "", IDLike: []string{"alpine"}},
-				},
-				{
-					Name:    "curl",
-					Version: "7.61.1",
-					Type:    pkg.ApkPkg,
-					PURL:    "pkg:apk/curl@7.61.1?arch=aarch64&distro=alpine-3.20.3",
-					Distro:  &distro.Distro{Type: distro.Alpine, Version: "3.20.3", Codename: "", IDLike: []string{"alpine"}},
-				},
-			},
-			sbom: &sbom.SBOM{
-				Artifacts: sbom.Artifacts{
-					Packages: pkg.NewCollection(pkg.Package{
-						Name:    "openssl",
-						Version: "3.2.1",
-						Type:    pkg.ApkPkg,
-						PURL:    "pkg:apk/openssl@3.2.1?arch=aarch64&distro=alpine-3.20.3",
-					},
-						pkg.Package{
-							Name:    "curl",
-							Version: "7.61.1",
-							Type:    pkg.ApkPkg,
-							PURL:    "pkg:apk/curl@7.61.1?arch=aarch64&distro=alpine-3.20.3",
-						}),
-					LinuxDistribution: &linux.Release{
-						Name:    "alpine",
-						ID:      "alpine",
-						IDLike:  []string{"alpine"},
-						Version: "3.20.3",
-					},
-				},
-			},
-		},
-		{
-			name:      "different distro info in purls does not infer context",
-			userInput: "purl:test-fixtures/purl/different-os.txt",
-			context: Context{
-				// important: no distro info inferred
-				Source: &source.Description{
-					Metadata: PURLFileMetadata{
-						Path: "test-fixtures/purl/different-os.txt",
-					},
-				},
-			},
-			pkgs: []Package{
-				{
-					Name:    "openssl",
-					Version: "3.2.1",
-					Type:    pkg.ApkPkg,
-					PURL:    "pkg:apk/openssl@3.2.1?arch=aarch64&distro=alpine-3.20.3",
-					Distro:  &distro.Distro{Type: distro.Alpine, Version: "3.20.3", Codename: "", IDLike: []string{"alpine"}},
-				},
-				{
-					Name:    "curl",
-					Version: "7.61.1",
-					Type:    pkg.ApkPkg,
-					PURL:    "pkg:apk/curl@7.61.1?arch=aarch64&distro=alpine-3.20.2",
-					Distro:  &distro.Distro{Type: distro.Alpine, Version: "3.20.2", Codename: "", IDLike: []string{"alpine"}},
-				},
-			},
-			sbom: &sbom.SBOM{
-				Artifacts: sbom.Artifacts{
-					Packages: pkg.NewCollection(pkg.Package{
-						Name:    "openssl",
-						Version: "3.2.1",
-						Type:    pkg.ApkPkg,
-						PURL:    "pkg:apk/openssl@3.2.1?arch=aarch64&distro=alpine-3.20.3",
-					},
-						pkg.Package{
-							Name:    "curl",
-							Version: "7.61.1",
-							Type:    pkg.ApkPkg,
-							PURL:    "pkg:apk/curl@7.61.1?arch=aarch64&distro=alpine-3.20.2",
-						}),
-				},
-			},
-		},
-		{
-			name:      "fails on path with nonexistant file",
-			userInput: "purl:tttt/empty.txt",
-			wantErr:   require.Error,
-		},
-		{
-			name:      "fails on invalid path",
-			userInput: "purl:~&&",
-			wantErr:   require.Error,
-		},
-		{
-			name:      "allow empty purl file",
-			userInput: "purl:test-fixtures/purl/empty.json",
-			sbom:      &sbom.SBOM{},
-			context: Context{
-				Source: &source.Description{
-					Metadata: PURLFileMetadata{
-						Path: "test-fixtures/purl/empty.json",
-					},
-				},
-			},
-		},
-		{
-			name:      "fails on invalid purl in file",
+			name:      "fails on purl list input",
 			userInput: "purl:test-fixtures/purl/invalid-purl.txt",
 			wantErr:   require.Error,
 		},
-		{
-			name:      "fails on invalid cpe in file",
-			userInput: "purl:test-fixtures/purl/invalid-cpe.txt",
-			wantErr:   require.Error,
-		},
 		{
 			name:      "invalid prefix",
 			userInput: "dir:test-fixtures/purl",
@@ -603,23 +268,14 @@ func Test_PurlProvider(t *testing.T) {
 		},
 	}
 
-	opts := []cmp.Option{
-		cmpopts.IgnoreFields(Package{}, "ID", "Locations", "Licenses", "Metadata", "Language", "CPEs"),
-		cmpopts.IgnoreUnexported(distro.Distro{}),
-	}
-
-	syftPkgOpts := []cmp.Option{
-		cmpopts.IgnoreFields(pkg.Package{}, "id"),
-		cmpopts.IgnoreUnexported(pkg.Package{}, file.LocationSet{}, pkg.LicenseSet{}),
-	}
-
 	for _, tc := range tests {
 		t.Run(tc.name, func(t *testing.T) {
 			if tc.wantErr == nil {
 				tc.wantErr = require.NoError
 			}
 
-			packages, ctx, gotSBOM, err := purlProvider(tc.userInput)
+			packages, ctx, _, err := purlProvider(tc.userInput, ProviderConfig{})
+			setContextDistro(packages, &ctx)
 
 			tc.wantErr(t, err)
 			if err != nil {
@@ -627,35 +283,20 @@ func Test_PurlProvider(t *testing.T) {
 				return
 			}
 
-			if d := cmp.Diff(tc.context, ctx, opts...); d != "" {
+			if d := cmp.Diff(tc.context, ctx, diffOpts...); d != "" {
 				t.Errorf("unexpected context (-want +got):\n%s", d)
 			}
 			require.Len(t, packages, len(tc.pkgs))
 			for idx, expected := range tc.pkgs {
-				if d := cmp.Diff(expected, packages[idx], opts...); d != "" {
+				if d := cmp.Diff(expected, packages[idx], diffOpts...); d != "" {
 					t.Errorf("unexpected context (-want +got):\n%s", d)
 				}
 			}
-
-			gotSyftPkgs := gotSBOM.Artifacts.Packages.Sorted()
-			wantSyftPkgs := tc.sbom.Artifacts.Packages.Sorted()
-			require.Equal(t, len(gotSyftPkgs), len(wantSyftPkgs))
-			for idx, wantPkg := range wantSyftPkgs {
-				if d := cmp.Diff(wantPkg, gotSyftPkgs[idx], syftPkgOpts...); d != "" {
-					t.Errorf("unexpected Syft Pkg (-want +got):\n%s", d)
-				}
-			}
-
-			wantSyftDistro := tc.sbom.Artifacts.LinuxDistribution
-			gotDistro := gotSBOM.Artifacts.LinuxDistribution
-			if wantSyftDistro == nil {
-				require.Nil(t, gotDistro)
-				return
-			}
-
-			if d := cmp.Diff(wantSyftDistro, gotDistro); d != "" {
-				t.Errorf("unexpected Syft Distro (-want +got):\n%s", d)
-			}
 		})
 	}
 }
+
+var diffOpts = []cmp.Option{
+	cmpopts.IgnoreFields(Package{}, "ID", "Locations", "Licenses", "Language", "CPEs"),
+	cmpopts.IgnoreUnexported(distro.Distro{}),
+}
diff --git a/grype/pkg/syft_sbom_provider_test.go b/grype/pkg/syft_sbom_provider_test.go
index 2d2e4861..5d18d065 100644
--- a/grype/pkg/syft_sbom_provider_test.go
+++ b/grype/pkg/syft_sbom_provider_test.go
@@ -1,15 +1,19 @@
 package pkg
 
 import (
+	"slices"
 	"strings"
 	"testing"
 
 	"github.com/go-test/deep"
+	"github.com/google/go-cmp/cmp"
 	"github.com/stretchr/testify/assert"
+	"github.com/stretchr/testify/require"
 
+	"github.com/anchore/grype/grype/distro"
 	"github.com/anchore/syft/syft/cpe"
 	"github.com/anchore/syft/syft/file"
-	"github.com/anchore/syft/syft/linux"
+	"github.com/anchore/syft/syft/pkg"
 	"github.com/anchore/syft/syft/source"
 )
 
@@ -219,8 +223,8 @@ func TestParseSyftJSON(t *testing.T) {
 						},
 					},
 				},
-				Distro: &linux.Release{
-					Name:    "alpine",
+				Distro: &distro.Distro{
+					Type:    "alpine",
 					Version: "3.12.0",
 				},
 			},
@@ -337,9 +341,184 @@ var springImageTestCase = struct {
 				RepoDigests: []string{"springio/gs-spring-boot-docker@sha256:39c2ffc784f5f34862e22c1f2ccdbcb62430736114c13f60111eabdb79decb08"},
 			},
 		},
-		Distro: &linux.Release{
-			Name:    "debian",
+		Distro: &distro.Distro{
+			Type:    "debian",
 			Version: "9",
 		},
 	},
 }
+
+func Test_PurlList(t *testing.T) {
+	tests := []struct {
+		name      string
+		userInput string
+		context   Context
+		pkgs      []Package
+		wantErr   require.ErrorAssertionFunc
+	}{
+		{
+			name:      "takes multiple purls",
+			userInput: "purl:test-fixtures/purl/valid-purl.txt",
+			context: Context{
+				Distro: &distro.Distro{
+					Type:    "debian",
+					IDLike:  []string{"debian"},
+					Version: "8",
+				},
+				Source: &source.Description{
+					Metadata: SBOMFileMetadata{
+						Path: "test-fixtures/purl/valid-purl.txt",
+					},
+				},
+			},
+			pkgs: []Package{
+				{
+					Name:    "ant",
+					Version: "1.10.8",
+					Type:    pkg.JavaPkg,
+					PURL:    "pkg:maven/org.apache.ant/ant@1.10.8",
+					Metadata: JavaMetadata{
+						PomArtifactID: "ant",
+						PomGroupID:    "org.apache.ant",
+					},
+				},
+				{
+					Name:    "log4j-core",
+					Version: "2.14.1",
+					Type:    pkg.JavaPkg,
+					PURL:    "pkg:maven/org.apache.logging.log4j/log4j-core@2.14.1",
+					Metadata: JavaMetadata{
+						PomArtifactID: "log4j-core",
+						PomGroupID:    "org.apache.logging.log4j",
+					},
+				},
+				{
+					Name:    "sysv-rc",
+					Version: "2.88dsf-59",
+					Type:    pkg.DebPkg,
+					PURL:    "pkg:deb/debian/sysv-rc@2.88dsf-59?arch=all&distro=debian-8&upstream=sysvinit",
+					Distro:  &distro.Distro{Type: distro.Debian, Version: "8", Codename: "", IDLike: []string{"debian"}},
+					Upstreams: []UpstreamPackage{
+						{
+							Name: "sysvinit",
+						},
+					},
+				},
+			},
+		},
+		{
+			name:      "infer context when distro is present for multiple similar purls",
+			userInput: "purl:test-fixtures/purl/homogeneous-os.txt",
+			context: Context{
+				Distro: &distro.Distro{
+					Type:    "alpine",
+					IDLike:  []string{"alpine"},
+					Version: "3.20.3",
+				},
+				Source: &source.Description{
+					Metadata: SBOMFileMetadata{
+						Path: "test-fixtures/purl/homogeneous-os.txt",
+					},
+				},
+			},
+			pkgs: []Package{
+				{
+					Name:    "openssl",
+					Version: "3.2.1",
+					Type:    pkg.ApkPkg,
+					PURL:    "pkg:apk/openssl@3.2.1?arch=aarch64&distro=alpine-3.20.3",
+					Distro:  &distro.Distro{Type: distro.Alpine, Version: "3.20.3", Codename: "", IDLike: []string{"alpine"}},
+				},
+				{
+					Name:    "curl",
+					Version: "7.61.1",
+					Type:    pkg.ApkPkg,
+					PURL:    "pkg:apk/curl@7.61.1?arch=aarch64&distro=alpine-3.20.3",
+					Distro:  &distro.Distro{Type: distro.Alpine, Version: "3.20.3", Codename: "", IDLike: []string{"alpine"}},
+				},
+			},
+		},
+		{
+			name:      "different distro info in purls does not infer context",
+			userInput: "purl:test-fixtures/purl/different-os.txt",
+			context: Context{
+				// important: no distro info inferred
+				Source: &source.Description{
+					Metadata: SBOMFileMetadata{
+						Path: "test-fixtures/purl/different-os.txt",
+					},
+				},
+			},
+			pkgs: []Package{
+				{
+					Name:    "openssl",
+					Version: "3.2.1",
+					Type:    pkg.ApkPkg,
+					PURL:    "pkg:apk/openssl@3.2.1?arch=aarch64&distro=alpine-3.20.3",
+					Distro:  &distro.Distro{Type: distro.Alpine, Version: "3.20.3", Codename: "", IDLike: []string{"alpine"}},
+				},
+				{
+					Name:    "curl",
+					Version: "7.61.1",
+					Type:    pkg.ApkPkg,
+					PURL:    "pkg:apk/curl@7.61.1?arch=aarch64&distro=alpine-3.20.2",
+					Distro:  &distro.Distro{Type: distro.Alpine, Version: "3.20.2", Codename: "", IDLike: []string{"alpine"}},
+				},
+			},
+		},
+		{
+			name:      "fails on path with nonexistant file",
+			userInput: "purl:tttt/empty.txt",
+			wantErr:   require.Error,
+		},
+		{
+			name:      "fails on invalid path",
+			userInput: "purl:~&&",
+			wantErr:   require.Error,
+		},
+		{
+			name:      "fails for empty purl file",
+			userInput: "purl:test-fixtures/purl/empty.json",
+			wantErr:   require.Error,
+		},
+		{
+			name:      "fails on invalid purl in file",
+			userInput: "purl:test-fixtures/purl/invalid-purl.txt",
+			wantErr:   require.Error,
+		},
+	}
+
+	for _, tc := range tests {
+		t.Run(tc.name, func(t *testing.T) {
+			if tc.wantErr == nil {
+				tc.wantErr = require.NoError
+			}
+
+			packages, ctx, _, err := Provide(tc.userInput, ProviderConfig{})
+
+			tc.wantErr(t, err)
+			if err != nil {
+				require.Nil(t, packages)
+				return
+			}
+
+			if d := cmp.Diff(tc.context, ctx, diffOpts...); d != "" {
+				t.Errorf("unexpected context (-want +got):\n%s", d)
+			}
+			require.Len(t, packages, len(tc.pkgs))
+
+			slices.SortFunc(packages, func(a, b Package) int {
+				return strings.Compare(a.Name, b.Name)
+			})
+			slices.SortFunc(tc.pkgs, func(a, b Package) int {
+				return strings.Compare(a.Name, b.Name)
+			})
+
+			for idx, expected := range tc.pkgs {
+				if d := cmp.Diff(expected, packages[idx], diffOpts...); d != "" {
+					t.Errorf("unexpected context (-want +got):\n%s", d)
+				}
+			}
+		})
+	}
+}
diff --git a/grype/presenter/internal/test_helpers.go b/grype/presenter/internal/test_helpers.go
index aab61adc..aa2b528a 100644
--- a/grype/presenter/internal/test_helpers.go
+++ b/grype/presenter/internal/test_helpers.go
@@ -7,6 +7,7 @@ import (
 	"github.com/stretchr/testify/require"
 
 	"github.com/anchore/clio"
+	"github.com/anchore/grype/grype/distro"
 	"github.com/anchore/grype/grype/match"
 	"github.com/anchore/grype/grype/pkg"
 	"github.com/anchore/grype/grype/presenter/models"
@@ -15,7 +16,6 @@ import (
 	"github.com/anchore/stereoscope/pkg/image"
 	"github.com/anchore/syft/syft/cpe"
 	"github.com/anchore/syft/syft/file"
-	"github.com/anchore/syft/syft/linux"
 	syftPkg "github.com/anchore/syft/syft/pkg"
 	"github.com/anchore/syft/syft/sbom"
 	syftSource "github.com/anchore/syft/syft/source"
@@ -506,8 +506,8 @@ func generateContext(t *testing.T, scheme SyftSource) pkg.Context {
 
 	return pkg.Context{
 		Source: &desc,
-		Distro: &linux.Release{
-			Name: "centos",
+		Distro: &distro.Distro{
+			Type: "centos",
 			IDLike: []string{
 				"centos",
 			},
diff --git a/grype/presenter/json/presenter_test.go b/grype/presenter/json/presenter_test.go
index 3910b55c..dc2929ae 100644
--- a/grype/presenter/json/presenter_test.go
+++ b/grype/presenter/json/presenter_test.go
@@ -12,11 +12,11 @@ import (
 
 	"github.com/anchore/clio"
 	"github.com/anchore/go-testutils"
+	"github.com/anchore/grype/grype/distro"
 	"github.com/anchore/grype/grype/match"
 	"github.com/anchore/grype/grype/pkg"
 	"github.com/anchore/grype/grype/presenter/internal"
 	"github.com/anchore/grype/grype/presenter/models"
-	"github.com/anchore/syft/syft/linux"
 	"github.com/anchore/syft/syft/source"
 )
 
@@ -85,8 +85,8 @@ func TestEmptyJsonPresenter(t *testing.T) {
 
 	ctx := pkg.Context{
 		Source: &source.Description{},
-		Distro: &linux.Release{
-			ID:      "centos",
+		Distro: &distro.Distro{
+			Type:    "centos",
 			IDLike:  []string{"rhel"},
 			Version: "8.0",
 		},
diff --git a/grype/presenter/models/document_test.go b/grype/presenter/models/document_test.go
index d81f0dc1..0cb95916 100644
--- a/grype/presenter/models/document_test.go
+++ b/grype/presenter/models/document_test.go
@@ -7,10 +7,10 @@ import (
 	"github.com/stretchr/testify/assert"
 
 	"github.com/anchore/clio"
+	"github.com/anchore/grype/grype/distro"
 	"github.com/anchore/grype/grype/match"
 	"github.com/anchore/grype/grype/pkg"
 	"github.com/anchore/grype/grype/vulnerability"
-	"github.com/anchore/syft/syft/linux"
 	syftPkg "github.com/anchore/syft/syft/pkg"
 	syftSource "github.com/anchore/syft/syft/source"
 )
@@ -75,8 +75,8 @@ func TestPackagesAreSorted(t *testing.T) {
 		Source: &syftSource.Description{
 			Metadata: syftSource.DirectoryMetadata{},
 		},
-		Distro: &linux.Release{
-			ID:      "centos",
+		Distro: &distro.Distro{
+			Type:    "centos",
 			IDLike:  []string{"rhel"},
 			Version: "8.0",
 		},
@@ -136,8 +136,8 @@ func TestFixSuggestedVersion(t *testing.T) {
 		Source: &syftSource.Description{
 			Metadata: syftSource.DirectoryMetadata{},
 		},
-		Distro: &linux.Release{
-			ID:      "centos",
+		Distro: &distro.Distro{
+			Type:    "centos",
 			IDLike:  []string{"rhel"},
 			Version: "8.0",
 		},
diff --git a/grype/presenter/models/source_test.go b/grype/presenter/models/source_test.go
index 325b772a..851fdf9a 100644
--- a/grype/presenter/models/source_test.go
+++ b/grype/presenter/models/source_test.go
@@ -65,12 +65,12 @@ func TestNewSource(t *testing.T) {
 		{
 			name: "purl-file",
 			metadata: syftSource.Description{
-				Metadata: pkg.PURLFileMetadata{
+				Metadata: pkg.SBOMFileMetadata{
 					Path: "/path/to/purls.txt",
 				},
 			},
 			expected: source{
-				Type:   "purl-file",
+				Type:   "sbom-file",
 				Target: "/path/to/purls.txt",
 			},
 		},
diff --git a/grype/version/constraint_expression_test.go b/grype/version/constraint_expression_test.go
index 5b0420ed..4f5e8ba4 100644
--- a/grype/version/constraint_expression_test.go
+++ b/grype/version/constraint_expression_test.go
@@ -1,11 +1,11 @@
 package version
 
 import (
-	"github.com/google/go-cmp/cmp"
-	"github.com/stretchr/testify/require"
 	"testing"
 
 	"github.com/go-test/deep"
+	"github.com/google/go-cmp/cmp"
+	"github.com/stretchr/testify/require"
 )
 
 func TestScanExpression(t *testing.T) {
diff --git a/grype/vulnerability_matcher_test.go b/grype/vulnerability_matcher_test.go
index baf5658b..b9a70105 100644
--- a/grype/vulnerability_matcher_test.go
+++ b/grype/vulnerability_matcher_test.go
@@ -29,7 +29,6 @@ import (
 	"github.com/anchore/grype/internal/bus"
 	"github.com/anchore/syft/syft/cpe"
 	"github.com/anchore/syft/syft/file"
-	"github.com/anchore/syft/syft/linux"
 	syftPkg "github.com/anchore/syft/syft/pkg"
 	"github.com/anchore/syft/syft/source"
 )
@@ -264,9 +263,9 @@ func TestVulnerabilityMatcher_FindMatches(t *testing.T) {
 					},
 				},
 				context: pkg.Context{
-					Distro: &linux.Release{
-						ID:        "debian",
-						VersionID: "8",
+					Distro: &distro.Distro{
+						Type:    "debian",
+						Version: "8",
 					},
 				},
 			},
@@ -281,9 +280,9 @@ func TestVulnerabilityMatcher_FindMatches(t *testing.T) {
 					neutron2013Pkg,
 				},
 				context: pkg.Context{
-					Distro: &linux.Release{
-						ID:        "debian",
-						VersionID: "8",
+					Distro: &distro.Distro{
+						Type:    "debian",
+						Version: "8",
 					},
 				},
 			},
@@ -336,9 +335,9 @@ func TestVulnerabilityMatcher_FindMatches(t *testing.T) {
 					neutron2013Pkg,
 				},
 				context: pkg.Context{
-					Distro: &linux.Release{
-						ID:        "debian",
-						VersionID: "8",
+					Distro: &distro.Distro{
+						Type:    "debian",
+						Version: "8",
 					},
 				},
 			},
@@ -410,9 +409,9 @@ func TestVulnerabilityMatcher_FindMatches(t *testing.T) {
 							},
 						},
 					},
-					Distro: &linux.Release{
-						ID:        "debian",
-						VersionID: "8",
+					Distro: &distro.Distro{
+						Type:    "debian",
+						Version: "8",
 					},
 				},
 			},
@@ -1069,9 +1068,9 @@ func Test_fatalErrors(t *testing.T) {
 				},
 			},
 				pkg.Context{
-					Distro: &linux.Release{
-						ID:        "debian",
-						VersionID: "8",
+					Distro: &distro.Distro{
+						Type:    "debian",
+						Version: "8",
 					},
 				},
 			)
diff --git a/test/integration/match_by_image_test.go b/test/integration/match_by_image_test.go
index 254fa245..9a226cc2 100644
--- a/test/integration/match_by_image_test.go
+++ b/test/integration/match_by_image_test.go
@@ -11,6 +11,7 @@ import (
 	"github.com/stretchr/testify/require"
 
 	"github.com/anchore/grype/grype"
+	"github.com/anchore/grype/grype/distro"
 	"github.com/anchore/grype/grype/match"
 	"github.com/anchore/grype/grype/matcher"
 	"github.com/anchore/grype/grype/matcher/dotnet"
@@ -30,7 +31,6 @@ import (
 	"github.com/anchore/syft/syft"
 	"github.com/anchore/syft/syft/cataloging/pkgcataloging"
 	"github.com/anchore/syft/syft/cpe"
-	"github.com/anchore/syft/syft/linux"
 	syftPkg "github.com/anchore/syft/syft/pkg"
 	"github.com/anchore/syft/syft/source"
 )
@@ -787,7 +787,7 @@ func TestMatchByImage(t *testing.T) {
 				},
 			})
 
-			actualResults := grype.FindVulnerabilitiesForPackage(theProvider, s.Artifacts.LinuxDistribution, matchers, pkg.FromCollection(s.Artifacts.Packages, pkg.SynthesisConfig{}))
+			actualResults := grype.FindVulnerabilitiesForPackage(theProvider, distro.FromRelease(s.Artifacts.LinuxDistribution), matchers, pkg.FromCollection(s.Artifacts.Packages, pkg.SynthesisConfig{}))
 			for _, m := range actualResults.Sorted() {
 				for _, d := range m.Details {
 					observedMatchers.Add(string(d.Matcher))
@@ -945,7 +945,6 @@ func vexMatches(t *testing.T, ignoredMatches []match.IgnoredMatch, vexStatus vex
 				},
 			},
 		},
-		Distro: &linux.Release{},
 	}
 
 	vexedMatches, ignoredMatches, err := vexMatcher.ApplyVEX(pctx, &matches, ignoredMatches)