diff --git a/pkg/fanal/applier/docker.go b/pkg/fanal/applier/docker.go index 75c6a41bf6..1d29e7ceee 100644 --- a/pkg/fanal/applier/docker.go +++ b/pkg/fanal/applier/docker.go @@ -235,6 +235,9 @@ func ApplyLayers(layers []ftypes.BlobInfo) ftypes.ArtifactDetail { } } + // Filter OS packages with mismatched PURL namespace + mergedLayer.Packages = filterMismatchedOSPkgs(mergedLayer.OS.Family, mergedLayer.Packages) + // De-duplicate same debian packages from different dirs // cf. https://github.com/aquasecurity/trivy/issues/8297 mergedLayer.Packages = xslices.ZeroToNil(lo.UniqBy(mergedLayer.Packages, func(pkg ftypes.Package) string { @@ -343,3 +346,41 @@ func secretFindingsContains(findings []ftypes.SecretFinding, finding ftypes.Secr } return false } + +// purlMatchesOS checks if a package's PURL namespace matches the detected OS family. +// Returns true if the package should be kept (matches OS or has no PURL/namespace). +// Returns false if the package should be filtered out (has PURL with mismatched namespace). +func purlMatchesOS(pkg ftypes.Package, osFamily ftypes.OSType) bool { + if pkg.Identifier.PURL == nil || osFamily == "" { + return true // Keep packages without PURL or when OS is not detected + } + if pkg.Identifier.PURL.Namespace == "" { + return true // Keep packages without namespace + } + return pkg.Identifier.PURL.Namespace == osFamily.PurlNamespace() +} + +// filterMismatchedOSPkgs removes OS packages whose PURL namespace doesn't match the detected OS. +// Packages with pre-existing PURLs are typically from SBOM files embedded in the image. +func filterMismatchedOSPkgs(osFamily ftypes.OSType, pkgs ftypes.Packages) ftypes.Packages { + if osFamily == "" { + return pkgs // No OS detected, keep all packages + } + + var filtered int + result := lo.Filter(pkgs, func(pkg ftypes.Package, _ int) bool { + if purlMatchesOS(pkg, osFamily) { + return true + } + filtered++ + return false + }) + + if filtered > 0 { + log.WithPrefix("applier").Warn("Some OS packages were skipped due to mismatched PURL namespace", + log.Int("pkg_count", filtered), + log.String("detected_os", string(osFamily))) + } + + return result +} diff --git a/pkg/fanal/applier/docker_test.go b/pkg/fanal/applier/docker_test.go index 61bf8d4c61..1d5c71bdb6 100644 --- a/pkg/fanal/applier/docker_test.go +++ b/pkg/fanal/applier/docker_test.go @@ -1462,6 +1462,56 @@ func TestApplyLayers(t *testing.T) { }, }, }, + { + // Duplicate packages with different PURL namespaces, prefer OS-matching PURL + name: "prefer OS-matching PURL during deduplication", + inputLayers: []types.BlobInfo{ + { + SchemaVersion: 2, + OS: types.OS{Family: "chainguard", Name: "20230201"}, + PackageInfos: []types.PackageInfo{ + { + FilePath: "lib/apk/db/installed", + Packages: types.Packages{ + {Name: "libcrypto3", Version: "3.0.0"}, // No PURL - will get chainguard + { + Name: "libcrypto3", + Version: "3.0.0", + Identifier: types.PkgIdentifier{ + PURL: &packageurl.PackageURL{ + Type: packageurl.TypeApk, + Namespace: "wolfi", // Mismatched + Name: "libcrypto3", + Version: "3.0.0", + Qualifiers: packageurl.Qualifiers{{Key: "distro", Value: "wolfi"}}, + }, + }, + }, + }, + }, + }, + }, + }, + want: types.ArtifactDetail{ + OS: types.OS{Family: "chainguard", Name: "20230201"}, + Packages: types.Packages{ + { + Name: "libcrypto3", + Version: "3.0.0", + Identifier: types.PkgIdentifier{ + UID: "bdca9f208c0174a0", + PURL: &packageurl.PackageURL{ + Type: packageurl.TypeApk, + Namespace: "chainguard", // Matches OS + Name: "libcrypto3", + Version: "3.0.0", + Qualifiers: packageurl.Qualifiers{{Key: "distro", Value: "20230201"}}, + }, + }, + }, + }, + }, + }, } for _, tt := range tests { diff --git a/pkg/fanal/types/const.go b/pkg/fanal/types/const.go index b52228508a..8fa3b720d2 100644 --- a/pkg/fanal/types/const.go +++ b/pkg/fanal/types/const.go @@ -48,6 +48,22 @@ const ( Wolfi OSType = "wolfi" ) +// PurlNamespace returns the normalized namespace for Package URL (PURL) representation. +// For SUSE-based distributions (SLES, SLE Micro), it returns "suse". +// For openSUSE variants (Tumbleweed, Leap), it returns "opensuse". +// For all other OSTypes, it returns the string representation of the OSType. +func (o OSType) PurlNamespace() string { + // SLES string has whitespace, also highlevel family is not the same as distro + if o == SLES || o == SLEMicro { + return "suse" + } + if o == OpenSUSETumbleweed || o == OpenSUSELeap { + return "opensuse" + } + + return string(o) +} + // OSTypeAliases is a map of aliases for operating systems. var OSTypeAliases = map[OSType]OSType{ // This is used to map the old family names to the new ones for backward compatibility. diff --git a/pkg/purl/purl.go b/pkg/purl/purl.go index 2597a07aed..0f657650ec 100644 --- a/pkg/purl/purl.go +++ b/pkg/purl/purl.go @@ -77,7 +77,7 @@ func New(t ftypes.TargetType, metadata types.Metadata, pkg ftypes.Package) (*Pac switch ptype { case packageurl.TypeRPM: ns, qs := parseRPM(metadata.OS, pkg.Modularitylabel) - namespace = string(ns) + namespace = ns qualifiers = append(qualifiers, qs...) case packageurl.TypeDebian: qualifiers = append(qualifiers, parseDeb(metadata.OS)...) @@ -369,20 +369,11 @@ func parseDeb(fos *ftypes.OS) packageurl.Qualifiers { } // ref. https://github.com/package-url/purl-spec/blob/a748c36ad415c8aeffe2b8a4a5d8a50d16d6d85f/PURL-TYPES.rst#rpm -func parseRPM(fos *ftypes.OS, modularityLabel string) (ftypes.OSType, packageurl.Qualifiers) { +func parseRPM(fos *ftypes.OS, modularityLabel string) (string, packageurl.Qualifiers) { if fos == nil { return "", packageurl.Qualifiers{} } - family := fos.Family - // SLES string has whitespace, also highlevel family is not the same as distro - if fos.Family == ftypes.SLES || fos.Family == ftypes.SLEMicro { - family = "suse" - } - if fos.Family == ftypes.OpenSUSETumbleweed || fos.Family == ftypes.OpenSUSELeap { - family = "opensuse" - } - qualifiers := packageurl.Qualifiers{ { Key: "distro", @@ -396,7 +387,7 @@ func parseRPM(fos *ftypes.OS, modularityLabel string) (ftypes.OSType, packageurl Value: modularityLabel, }) } - return family, qualifiers + return fos.Family.PurlNamespace(), qualifiers } // ref. https://github.com/package-url/purl-spec/blob/a748c36ad415c8aeffe2b8a4a5d8a50d16d6d85f/PURL-TYPES.rst#maven