diff --git a/grype/db/internal/provider/unmarshal/osv_vulnerability.go b/grype/db/internal/provider/unmarshal/osv_vulnerability.go index 0fa38e21be0..4950033eaa1 100644 --- a/grype/db/internal/provider/unmarshal/osv_vulnerability.go +++ b/grype/db/internal/provider/unmarshal/osv_vulnerability.go @@ -6,7 +6,16 @@ import ( "github.com/google/osv-scanner/pkg/models" ) -type OSVVulnerability = models.Vulnerability +// OSVVulnerability extends the standard OSV Vulnerability model with additional +// fields used by Chainguard/Wolfi advisories that aren't yet in the osv-scanner library. +type OSVVulnerability struct { + models.Vulnerability + + // Upstream contains IDs of upstream vulnerabilities that this advisory addresses. + // Per OSV spec, this is semantically correct for distro advisories that reference + // upstream CVEs. Included alongside "related" for backwards compatibility. + Upstream []string `json:"upstream,omitempty"` +} func OSVVulnerabilityEntries(reader io.Reader) ([]OSVVulnerability, error) { return unmarshalSingleOrMulti[OSVVulnerability](reader) diff --git a/grype/db/v6/blobs.go b/grype/db/v6/blobs.go index 719dcede9c2..95281aa392d 100644 --- a/grype/db/v6/blobs.go +++ b/grype/db/v6/blobs.go @@ -146,6 +146,10 @@ type PackageQualifiers struct { // PlatformCPEs lists Common Platform Enumeration (CPE) identifiers for affected platforms. PlatformCPEs []string `json:"platform_cpes,omitempty"` + + // Architecture specifies the CPU architecture this vulnerability applies to (e.g., "aarch64", "x86_64"). + // If nil, the vulnerability applies to all architectures. + Architecture *string `json:"architecture,omitempty"` } // Range defines a specific range of package versions pertaining to a vulnerability. diff --git a/grype/db/v6/build/transformers/osv/testdata/CGA-224q-ccj5-2p53.json b/grype/db/v6/build/transformers/osv/testdata/CGA-224q-ccj5-2p53.json new file mode 100644 index 00000000000..a9db40b38c9 --- /dev/null +++ b/grype/db/v6/build/transformers/osv/testdata/CGA-224q-ccj5-2p53.json @@ -0,0 +1,51 @@ +{ + "modified": "2026-01-07T00:00:00Z", + "published": "2026-01-07T00:00:00Z", + "id": "CGA-224q-ccj5-2p53", + "related": [ + "CVE-2025-32464", + "GHSA-frg5-h47x-75j9" + ], + "affected": [ + { + "package": { + "ecosystem": "Chainguard", + "name": "haproxy-2.8", + "purl": "pkg:apk/chainguard/haproxy-2.8" + }, + "ranges": [ + { + "type": "ECOSYSTEM", + "events": [ + { + "introduced": "0" + }, + { + "fixed": "2.8.18-r0" + } + ] + } + ] + }, + { + "package": { + "ecosystem": "Wolfi", + "name": "haproxy-3.1", + "purl": "pkg:apk/wolfi/haproxy-3.1" + }, + "ranges": [ + { + "type": "ECOSYSTEM", + "events": [ + { + "introduced": "0" + }, + { + "fixed": "3.1.7-r0" + } + ] + } + ] + } + ] +} diff --git a/grype/db/v6/build/transformers/osv/testdata/CGA-with-arch-and-upstream.json b/grype/db/v6/build/transformers/osv/testdata/CGA-with-arch-and-upstream.json new file mode 100644 index 00000000000..92fea20fc4b --- /dev/null +++ b/grype/db/v6/build/transformers/osv/testdata/CGA-with-arch-and-upstream.json @@ -0,0 +1,74 @@ +{ + "schema_version": "1.6.0", + "id": "CGA-test-arch-upstream", + "published": "2026-03-01T10:00:00Z", + "modified": "2026-03-05T10:00:00Z", + "summary": "Test vulnerability with arch in PURL and upstream field", + "severity": [ + { + "type": "CVSS_V3", + "score": "CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H" + } + ], + "upstream": [ + "CVE-2024-12345", + "GHSA-xxxx-yyyy-zzzz" + ], + "related": [ + "CVE-2024-12345", + "GHSA-xxxx-yyyy-zzzz" + ], + "references": [ + {"type": "ADVISORY", "url": "https://nvd.nist.gov/vuln/detail/CVE-2024-12345"} + ], + "affected": [ + { + "package": { + "ecosystem": "Chainguard", + "name": "openssl", + "purl": "pkg:apk/chainguard/openssl?arch=aarch64" + }, + "ranges": [ + { + "type": "ECOSYSTEM", + "events": [ + { "introduced": "0" }, + { "fixed": "3.2.1-r5" } + ] + } + ] + }, + { + "package": { + "ecosystem": "Chainguard", + "name": "openssl", + "purl": "pkg:apk/chainguard/openssl?arch=x86_64" + }, + "ranges": [ + { + "type": "ECOSYSTEM", + "events": [ + { "introduced": "0" }, + { "fixed": "3.2.1-r5" } + ] + } + ] + }, + { + "package": { + "ecosystem": "Chainguard", + "name": "openssl-dev", + "purl": "pkg:apk/chainguard/openssl-dev" + }, + "ranges": [ + { + "type": "ECOSYSTEM", + "events": [ + { "introduced": "0" }, + { "fixed": "3.2.1-r5" } + ] + } + ] + } + ] +} diff --git a/grype/db/v6/build/transformers/osv/transform.go b/grype/db/v6/build/transformers/osv/transform.go index afbf63c3cdf..1c79807bb8f 100644 --- a/grype/db/v6/build/transformers/osv/transform.go +++ b/grype/db/v6/build/transformers/osv/transform.go @@ -18,6 +18,8 @@ import ( "github.com/anchore/grype/grype/db/v6/build/transformers" "github.com/anchore/grype/grype/db/v6/build/transformers/internal" "github.com/anchore/grype/grype/db/v6/name" + "github.com/anchore/grype/internal/stringutil" + "github.com/anchore/packageurl-go" "github.com/anchore/syft/syft/pkg" ) @@ -36,8 +38,17 @@ func Transform(vulnerability unmarshal.OSVVulnerability, state provider.State) ( if isAdvisory { aliases = append(aliases, vulnerability.Related...) + } else if strings.HasPrefix(vulnerability.ID, "CGA-") { + // Chainguard CGA records put CVE/GHSA IDs in "upstream" and "related" + // rather than "aliases". Per OSV spec, "upstream" is semantically correct + // for distro advisories; "related" is kept for backwards compatibility. + aliases = append(aliases, vulnerability.Upstream...) + aliases = append(aliases, vulnerability.Related...) } + // Deduplicate aliases (related and upstream may contain the same IDs) + aliases = stringutil.NewStringSetFromSlice(aliases).ToSlice() + in := []any{ db.VulnerabilityHandle{ Name: vulnerability.ID, @@ -115,7 +126,7 @@ func getAffectedPackages(vuln unmarshal.OSVVulnerability) []db.AffectedPackageHa } // getPackageQualifiers extracts package qualifiers from affected package data -// including CPE information and RPM modularity +// including CPE information, RPM modularity, and architecture from PURL func getPackageQualifiers(affected models.Affected, cpes any, withCPE bool) *db.PackageQualifiers { var qualifiers *db.PackageQualifiers @@ -135,9 +146,40 @@ func getPackageQualifiers(affected models.Affected, cpes any, withCPE bool) *db. qualifiers.RpmModularity = &rpmModularity } + // Extract architecture from PURL qualifier (e.g., "pkg:apk/chainguard/foo?arch=aarch64") + arch := extractArchFromPURL(affected.Package.Purl) + if arch != "" { + if qualifiers == nil { + qualifiers = &db.PackageQualifiers{} + } + qualifiers.Architecture = &arch + } + return qualifiers } +// extractArchFromPURL extracts the "arch" qualifier from a PURL string. +// Returns empty string if no arch qualifier is present or if PURL is invalid. +// Example: "pkg:apk/chainguard/openssl?arch=aarch64" returns "aarch64" +func extractArchFromPURL(purlStr string) string { + if purlStr == "" { + return "" + } + + purl, err := packageurl.FromString(purlStr) + if err != nil { + return "" + } + + for _, qualifier := range purl.Qualifiers { + if qualifier.Key == pkg.PURLQualifierArch { + return qualifier.Value + } + } + + return "" +} + // extractRpmModularity extracts RPM modularity information from affected package ecosystem_specific func extractRpmModularity(affected models.Affected) string { if affected.EcosystemSpecific == nil { @@ -326,6 +368,12 @@ func normalizeRangeType(t models.RangeType, ecosystem string) string { return "bitnami" } + // APK distros (Chainguard, Wolfi, Alpine) use ECOSYSTEM ranges but require APK + // version semantics for correct constraint evaluation (e.g. epoch-release suffixes). + if t == models.RangeEcosystem && getPackageTypeFromEcosystem(ecosystem) == pkg.ApkPkg { + return "apk" + } + switch t { case models.RangeSemVer, models.RangeEcosystem, models.RangeGit: return strings.ToLower(string(t)) @@ -358,19 +406,19 @@ func getPackage(p models.Package) *db.Package { } } -// getPackageTypeFromEcosystem determines package type from OSV ecosystem -// Currently only supports AlmaLinux; other ecosystems use PURL-based detection +// getPackageTypeFromEcosystem determines package type from OSV ecosystem. +// This is used when no PURL is present; when a PURL is present it takes priority. func getPackageTypeFromEcosystem(ecosystem string) pkg.Type { if ecosystem == "" { return "" } - // Split ecosystem by colon to get OS name - parts := strings.Split(ecosystem, ":") - osName := strings.ToLower(parts[0]) + osName := strings.ToLower(strings.Split(ecosystem, ":")[0]) - // Only handle AlmaLinux - if osName == almaLinux { + switch osName { + case "chainguard", "wolfi", "alpine": + return pkg.ApkPkg + case almaLinux: return pkg.RpmPkg } @@ -459,24 +507,28 @@ func getSeverities(vuln unmarshal.OSVVulnerability) ([]db.Severity, error) { return severities, nil } -// getOperatingSystemFromEcosystem extracts operating system information from OSV ecosystem field -// Currently only supports AlmaLinux ecosystems -// Example: "AlmaLinux:8" -> almalinux 8 +// getOperatingSystemFromEcosystem extracts operating system information from OSV ecosystem field. +// Examples: +// - "Chainguard" -> chainguard rolling +// - "Wolfi" -> wolfi rolling +// - "AlmaLinux:8" -> almalinux 8 +// - "Alpine:3.21" -> alpine 3.21 func getOperatingSystemFromEcosystem(ecosystem string) *db.OperatingSystem { if ecosystem == "" { return nil } - // Split ecosystem by colon to get components parts := strings.Split(ecosystem, ":") - if len(parts) < 2 { - return nil - } - osName := strings.ToLower(parts[0]) - // Only handle AlmaLinux - if osName != almaLinux { + // Rolling APK distros — no version suffix in the ecosystem field + switch osName { + case "chainguard", "wolfi": + return &db.OperatingSystem{Name: osName, LabelVersion: "rolling"} + } + + // Versioned ecosystems require a colon-separated version component + if len(parts) < 2 { return nil } @@ -509,17 +561,8 @@ func getOperatingSystemFromEcosystem(ecosystem string) *db.OperatingSystem { } } -// normalizeOSName normalizes operating system names for consistency -// Currently only supports AlmaLinux func normalizeOSName(osName string) string { - osName = strings.ToLower(osName) - - // Only handle AlmaLinux - if osName == almaLinux { - return almaLinux - } - - return osName + return strings.ToLower(osName) } // isAdvisoryRecord checks if the OSV record is marked as an advisory diff --git a/grype/db/v6/build/transformers/osv/transform_test.go b/grype/db/v6/build/transformers/osv/transform_test.go index 37cde5f43b5..18215fd5e76 100644 --- a/grype/db/v6/build/transformers/osv/transform_test.go +++ b/grype/db/v6/build/transformers/osv/transform_test.go @@ -533,6 +533,138 @@ func Test_getPackage(t *testing.T) { } } +// Test_getOperatingSystemFromEcosystem covers the distro ecosystem mappings we added. +// Alpine is included to verify it would be handled if OSV data for Alpine ever appeared, +// but Alpine currently uses secdb (OS schema) and never reaches this code path. +func Test_getOperatingSystemFromEcosystem(t *testing.T) { + tests := []struct { + ecosystem string + wantName string + wantLabel string + wantMajor string + wantMinor string + wantNil bool + }{ + {ecosystem: "Chainguard", wantName: "chainguard", wantLabel: "rolling"}, + {ecosystem: "Wolfi", wantName: "wolfi", wantLabel: "rolling"}, + {ecosystem: "AlmaLinux:8", wantName: "almalinux", wantMajor: "8"}, + {ecosystem: "Alpine:3.21", wantName: "alpine", wantMajor: "3", wantMinor: "21"}, + // "alpine" without a version cannot identify the distro + {ecosystem: "alpine", wantNil: true}, + {ecosystem: "", wantNil: true}, + {ecosystem: "npm", wantNil: true}, + } + for _, tt := range tests { + t.Run(tt.ecosystem, func(t *testing.T) { + got := getOperatingSystemFromEcosystem(tt.ecosystem) + if tt.wantNil { + require.Nil(t, got) + return + } + require.NotNil(t, got) + require.Equal(t, tt.wantName, got.Name) + require.Equal(t, tt.wantLabel, got.LabelVersion) + require.Equal(t, tt.wantMajor, got.MajorVersion) + require.Equal(t, tt.wantMinor, got.MinorVersion) + }) + } +} + +func Test_getPackageTypeFromEcosystem(t *testing.T) { + tests := []struct { + ecosystem string + wantType string + }{ + {ecosystem: "Chainguard", wantType: "apk"}, + {ecosystem: "Wolfi", wantType: "apk"}, + {ecosystem: "Alpine:3.21", wantType: "apk"}, + {ecosystem: "AlmaLinux:8", wantType: "rpm"}, + {ecosystem: "npm", wantType: ""}, + {ecosystem: "", wantType: ""}, + } + for _, tt := range tests { + t.Run(tt.ecosystem, func(t *testing.T) { + got := getPackageTypeFromEcosystem(tt.ecosystem) + require.Equal(t, tt.wantType, string(got)) + }) + } +} + +func Test_normalizeRangeType_APKEcosystems(t *testing.T) { + // ECOSYSTEM ranges for APK distros must use "apk" version format so that + // APK-specific version comparison semantics (epoch-release suffix) are applied. + // Other ecosystems must fall through to their existing behaviour unchanged. + tests := []struct { + ecosystem string + rangeType models.RangeType + want string + }{ + {ecosystem: "Chainguard", rangeType: models.RangeEcosystem, want: "apk"}, + {ecosystem: "Wolfi", rangeType: models.RangeEcosystem, want: "apk"}, + {ecosystem: "Alpine:3.21", rangeType: models.RangeEcosystem, want: "apk"}, + // AlmaLinux falls through — see comment in normalizeRangeType + {ecosystem: "AlmaLinux:8", rangeType: models.RangeEcosystem, want: "ecosystem"}, + // Non-ECOSYSTEM range types are unaffected + {ecosystem: "Chainguard", rangeType: models.RangeSemVer, want: "semver"}, + {ecosystem: "npm", rangeType: models.RangeEcosystem, want: "ecosystem"}, + } + for _, tt := range tests { + t.Run(string(tt.rangeType)+"/"+tt.ecosystem, func(t *testing.T) { + got := normalizeRangeType(tt.rangeType, tt.ecosystem) + require.Equal(t, tt.want, got) + }) + } +} + +func TestTransform_CGA(t *testing.T) { + // Verify end-to-end transformation of a real Chainguard CGA record. + // Key properties: + // - CVE/GHSA IDs in "related" become aliases in the blob + // - Chainguard and Wolfi packages from the same record get separate AffectedPackageHandles + // - ECOSYSTEM ranges produce "apk" version type (not "ecosystem") + // - OperatingSystem is set to rolling for both distros + entries := loadFixture(t, "testdata/CGA-224q-ccj5-2p53.json") + require.Len(t, entries, 1) + + result, err := Transform(entries[0], inputProviderState()) + require.NoError(t, err) + require.Len(t, result, 1) + + re := result[0].Data.(transformers.RelatedEntries) + + // primary ID is the CGA, related CVE/GHSA appear as aliases + require.Equal(t, "CGA-224q-ccj5-2p53", re.VulnerabilityHandle.Name) + require.Contains(t, re.VulnerabilityHandle.BlobValue.Aliases, "CVE-2025-32464") + require.Contains(t, re.VulnerabilityHandle.BlobValue.Aliases, "GHSA-frg5-h47x-75j9") + + // two affected packages: one Chainguard, one Wolfi + require.Len(t, re.Related, 2) + + byEcosystem := map[string]db.AffectedPackageHandle{} + for _, r := range re.Related { + aph := r.(db.AffectedPackageHandle) + byEcosystem[aph.Package.Ecosystem] = aph + } + + cgPkg, ok := byEcosystem["Chainguard"] + require.True(t, ok) + require.Equal(t, "haproxy-2.8", cgPkg.Package.Name) + require.NotNil(t, cgPkg.OperatingSystem) + require.Equal(t, "chainguard", cgPkg.OperatingSystem.Name) + require.Equal(t, "rolling", cgPkg.OperatingSystem.LabelVersion) + require.Len(t, cgPkg.BlobValue.Ranges, 1) + require.Equal(t, "apk", cgPkg.BlobValue.Ranges[0].Version.Type) + require.Equal(t, "2.8.18-r0", cgPkg.BlobValue.Ranges[0].Fix.Version) + + wolfiPkg, ok := byEcosystem["Wolfi"] + require.True(t, ok) + require.Equal(t, "haproxy-3.1", wolfiPkg.Package.Name) + require.NotNil(t, wolfiPkg.OperatingSystem) + require.Equal(t, "wolfi", wolfiPkg.OperatingSystem.Name) + require.Equal(t, "rolling", wolfiPkg.OperatingSystem.LabelVersion) + require.Equal(t, "apk", wolfiPkg.BlobValue.Ranges[0].Version.Type) +} + func Test_extractCVSSInfo(t *testing.T) { tests := []struct { name string @@ -723,3 +855,121 @@ func Test_getPackageQualifiers(t *testing.T) { func stringRef(s string) *string { return &s } + +func Test_extractArchFromPURL(t *testing.T) { + tests := []struct { + name string + purl string + want string + }{ + { + name: "purl with arch qualifier", + purl: "pkg:apk/chainguard/openssl?arch=aarch64", + want: "aarch64", + }, + { + name: "purl with arch qualifier x86_64", + purl: "pkg:apk/chainguard/openssl?arch=x86_64", + want: "x86_64", + }, + { + name: "purl with multiple qualifiers including arch", + purl: "pkg:apk/chainguard/openssl?distro=chainguard&arch=aarch64", + want: "aarch64", + }, + { + name: "purl without arch qualifier", + purl: "pkg:apk/chainguard/openssl", + want: "", + }, + { + name: "purl with other qualifier but no arch", + purl: "pkg:apk/chainguard/openssl?distro=chainguard", + want: "", + }, + { + name: "empty purl", + purl: "", + want: "", + }, + { + name: "maven purl without arch", + purl: "pkg:maven/io.netty/netty@3.10.6.Final", + want: "", + }, + } + + for _, testToRun := range tests { + test := testToRun + t.Run(test.name, func(tt *testing.T) { + got := extractArchFromPURL(test.purl) + if got != test.want { + t.Errorf("extractArchFromPURL() = %v, want %v", got, test.want) + } + }) + } +} + +func TestTransform_CGA_WithArchAndUpstream(t *testing.T) { + // Verify transformation handles: + // - upstream field (same as related, but per OSV spec is more semantically correct) + // - architecture extracted from PURL qualifier + // - deduplication of aliases when upstream and related contain same values + entries := loadFixture(t, "testdata/CGA-with-arch-and-upstream.json") + require.Len(t, entries, 1) + + result, err := Transform(entries[0], inputProviderState()) + require.NoError(t, err) + require.Len(t, result, 1) + + re := result[0].Data.(transformers.RelatedEntries) + + // Check vulnerability handle + require.Equal(t, "CGA-test-arch-upstream", re.VulnerabilityHandle.Name) + + // Aliases should be deduplicated (upstream and related have same values) + require.Len(t, re.VulnerabilityHandle.BlobValue.Aliases, 2) + require.Contains(t, re.VulnerabilityHandle.BlobValue.Aliases, "CVE-2024-12345") + require.Contains(t, re.VulnerabilityHandle.BlobValue.Aliases, "GHSA-xxxx-yyyy-zzzz") + + // Three affected packages + require.Len(t, re.Related, 3) + + // Group by package name and architecture for easier testing + type pkgKey struct { + name string + arch string + } + byKey := make(map[pkgKey]db.AffectedPackageHandle) + for _, r := range re.Related { + aph := r.(db.AffectedPackageHandle) + var arch string + if aph.BlobValue.Qualifiers != nil && aph.BlobValue.Qualifiers.Architecture != nil { + arch = *aph.BlobValue.Qualifiers.Architecture + } + byKey[pkgKey{name: aph.Package.Name, arch: arch}] = aph + } + + // Check openssl aarch64 + aarch64Pkg, ok := byKey[pkgKey{name: "openssl", arch: "aarch64"}] + require.True(t, ok, "expected openssl aarch64 package") + require.NotNil(t, aarch64Pkg.BlobValue.Qualifiers) + require.NotNil(t, aarch64Pkg.BlobValue.Qualifiers.Architecture) + require.Equal(t, "aarch64", *aarch64Pkg.BlobValue.Qualifiers.Architecture) + + // Check openssl x86_64 + x86Pkg, ok := byKey[pkgKey{name: "openssl", arch: "x86_64"}] + require.True(t, ok, "expected openssl x86_64 package") + require.NotNil(t, x86Pkg.BlobValue.Qualifiers) + require.NotNil(t, x86Pkg.BlobValue.Qualifiers.Architecture) + require.Equal(t, "x86_64", *x86Pkg.BlobValue.Qualifiers.Architecture) + + // Check openssl-dev (no arch - should apply to all architectures) + devPkg, ok := byKey[pkgKey{name: "openssl-dev", arch: ""}] + require.True(t, ok, "expected openssl-dev package without arch") + // Should have nil qualifiers or nil architecture + if devPkg.BlobValue.Qualifiers != nil { + require.Nil(t, devPkg.BlobValue.Qualifiers.Architecture) + } +} +