From d7ff0ed99992f9cff62504bff26f104ebac5253b Mon Sep 17 00:00:00 2001 From: Vaibhav Thatai Date: Wed, 1 Jul 2026 15:13:38 +0530 Subject: [PATCH 1/5] feat(rapidfort): integrate curated image advisories - Add RapidFort-specific matcher that identifies containers via image labels and routes vulnerability lookups through the RapidFort DB - Refactor matcher selection into the matcher package - Add Red Hat advisory support: parse OS FixedIn identifiers and emit release-identifier refs with vendor advisory URLs in v6 transform - Fix binary lookup match type (ExactDirectMatch) and expand test coverage for label detection and distro version parsing Signed-off-by: Vaibhav Thatai --- cmd/grype/cli/commands/root.go | 8 +- .../provider/unmarshal/os_vulnerability.go | 1 + .../os/testdata/rapidfort-redhat-9.json | 44 +++ .../os/testdata/rapidfort-ubuntu-20.04.json | 26 ++ .../db/v6/build/transformers/os/transform.go | 59 ++- .../build/transformers/os/transform_test.go | 180 +++++++++ grype/distro/distro_test.go | 3 + grype/distro/type.go | 119 +++--- grype/match/matcher_type.go | 1 + grype/matcher/matchers.go | 43 ++ grype/matcher/matchers_test.go | 62 +++ grype/matcher/rapidfort/label.go | 32 ++ grype/matcher/rapidfort/label_test.go | 89 +++++ grype/matcher/rapidfort/matcher.go | 371 ++++++++++++++++++ grype/matcher/rapidfort/matcher_test.go | 172 ++++++++ 15 files changed, 1136 insertions(+), 74 deletions(-) create mode 100644 grype/db/v6/build/transformers/os/testdata/rapidfort-redhat-9.json create mode 100644 grype/db/v6/build/transformers/os/testdata/rapidfort-ubuntu-20.04.json create mode 100644 grype/matcher/matchers_test.go create mode 100644 grype/matcher/rapidfort/label.go create mode 100644 grype/matcher/rapidfort/label_test.go create mode 100644 grype/matcher/rapidfort/matcher.go create mode 100644 grype/matcher/rapidfort/matcher_test.go diff --git a/cmd/grype/cli/commands/root.go b/cmd/grype/cli/commands/root.go index 9c99df5e12d..ea908a2a737 100644 --- a/cmd/grype/cli/commands/root.go +++ b/cmd/grype/cli/commands/root.go @@ -221,12 +221,14 @@ func runGrype(ctx context.Context, app clio.Application, opts *options.Grype, us return fmt.Errorf("failed to create VEX processor: %w", err) } + matchers := getMatchers(opts, pkgContext) + vulnMatcher := grype.VulnerabilityMatcher{ VulnerabilityProvider: vp, IgnoreRules: opts.Ignore, NormalizeByCVE: opts.ByCVE, FailSeverity: opts.FailOnSeverity(), - Matchers: getMatchers(opts), + Matchers: matchers, VexProcessor: vexProcessor, Alerts: grype.AlertsConfig{ EnableEOLDistroWarnings: opts.Alerts.EnableEOLDistroWarnings, @@ -392,8 +394,8 @@ func getMatcherConfig(opts *options.Grype) matcher.Config { } } -func getMatchers(opts *options.Grype) []match.Matcher { - return matcher.NewDefaultMatchers(getMatcherConfig(opts)) +func getMatchers(opts *options.Grype, pkgContext pkg.Context) []match.Matcher { + return matcher.ApplySelectionPolicy(matcher.NewDefaultMatchers(getMatcherConfig(opts)), pkgContext) } func getProviderConfig(opts *options.Grype) pkg.ProviderConfig { diff --git a/grype/db/internal/provider/unmarshal/os_vulnerability.go b/grype/db/internal/provider/unmarshal/os_vulnerability.go index 32fe7267061..c4f59f7dfa7 100644 --- a/grype/db/internal/provider/unmarshal/os_vulnerability.go +++ b/grype/db/internal/provider/unmarshal/os_vulnerability.go @@ -14,6 +14,7 @@ type OSFixedIn struct { Module *string `json:"Module,omitempty"` Name string `json:"Name"` NamespaceName string `json:"NamespaceName"` + Identifier string `json:"Identifier,omitempty"` VendorAdvisory struct { AdvisorySummary []struct { ID string `json:"ID"` diff --git a/grype/db/v6/build/transformers/os/testdata/rapidfort-redhat-9.json b/grype/db/v6/build/transformers/os/testdata/rapidfort-redhat-9.json new file mode 100644 index 00000000000..f283410223e --- /dev/null +++ b/grype/db/v6/build/transformers/os/testdata/rapidfort-redhat-9.json @@ -0,0 +1,44 @@ +[ + { + "Vulnerability": { + "CVSS": [], + "Description": "curl wildcard certificate validation issue", + "FixedIn": [ + { + "Identifier": "el9", + "Name": "curl", + "NamespaceName": "rapidfort-redhat:9", + "Version": "None", + "VersionFormat": "rpm", + "VulnerableRange": ">= 0", + "VendorAdvisory": { + "AdvisorySummary": [ + { + "ID": "RHSA-TEST-EL9", + "Link": "https://access.redhat.com/errata/RHSA-TEST-EL9" + } + ], + "NoAdvisory": false + } + }, + { + "Identifier": "fc36", + "Name": "curl", + "NamespaceName": "rapidfort-redhat:9", + "Version": "7.78.0-4.fc36", + "VersionFormat": "rpm", + "VulnerableRange": ">= 0, < 7.78.0-4.fc36", + "Available": { + "Date": "2024-01-01T00:00:00Z", + "Kind": "advisory" + } + } + ], + "Link": "https://www.cve.org/CVERecord?id=CVE-2014-0139", + "Metadata": {}, + "Name": "CVE-2014-0139", + "NamespaceName": "rapidfort-redhat:9", + "Severity": "Low" + } + } +] diff --git a/grype/db/v6/build/transformers/os/testdata/rapidfort-ubuntu-20.04.json b/grype/db/v6/build/transformers/os/testdata/rapidfort-ubuntu-20.04.json new file mode 100644 index 00000000000..8999f2437da --- /dev/null +++ b/grype/db/v6/build/transformers/os/testdata/rapidfort-ubuntu-20.04.json @@ -0,0 +1,26 @@ +[ + { + "Vulnerability": { + "CVSS": [], + "Description": "curl: OAUTH2 bearer bypass in connection re-use", + "FixedIn": [ + { + "Name": "curl", + "NamespaceName": "rapidfort-ubuntu:20.04", + "Version": "7.68.0-1ubuntu2.10", + "VersionFormat": "dpkg", + "VulnerableRange": ">= 7.68.0, < 7.68.0-1ubuntu2.10", + "Available": { + "Date": "2022-05-01T00:00:00Z", + "Kind": "advisory" + } + } + ], + "Link": "https://cve.mitre.org/cgi-bin/cvename.cgi?name=CVE-2022-22576", + "Metadata": {}, + "Name": "CVE-2022-22576", + "NamespaceName": "rapidfort-ubuntu:20.04", + "Severity": "Medium" + } + } +] diff --git a/grype/db/v6/build/transformers/os/transform.go b/grype/db/v6/build/transformers/os/transform.go index 6493662c961..330f39c59d4 100644 --- a/grype/db/v6/build/transformers/os/transform.go +++ b/grype/db/v6/build/transformers/os/transform.go @@ -163,6 +163,21 @@ func getFix(fixedInEntry unmarshal.OSFixedIn) *db.Fix { } var refs []db.Reference + if fixedInEntry.Identifier != "" { + rid := releaseIdentifierReferenceID(fixedInEntry.Identifier) + refURL := rid + for _, adv := range advisoryOrder { + if adv.link != "" { + refURL = adv.link + break + } + } + refs = append(refs, db.Reference{ + ID: rid, + URL: refURL, // prefer vendor advisory URL; else rid so toAdvisories includes the ref + Tags: []string{db.AdvisoryReferenceTag}, + }) + } for _, adv := range advisoryOrder { refs = append(refs, db.Reference{ ID: adv.id, @@ -204,6 +219,10 @@ func getFixAvailability(fixedInEntry unmarshal.OSFixedIn) *db.FixAvailability { } } +func releaseIdentifierReferenceID(identifier string) string { + return "release-identifier:" + strings.ToLower(strings.TrimSpace(identifier)) +} + func enforceConstraint(fixedVersion, vulnerableRange, format, vulnerabilityID string) string { if len(vulnerableRange) > 0 { return vulnerableRange @@ -243,14 +262,15 @@ func deriveConstraintFromFix(fixVersion, vulnerabilityID string) string { } type groupIndex struct { - name string - id string - osName string - osVersion string - osChannel string - hasModule bool - module string - format string + name string + id string + osName string + osVersion string + osChannel string + identifier string + hasModule bool + module string + format string } func groupFixedIns(vuln unmarshal.OSVulnerability) map[groupIndex][]unmarshal.OSFixedIn { @@ -263,14 +283,15 @@ func groupFixedIns(vuln unmarshal.OSVulnerability) map[groupIndex][]unmarshal.OS mod = *fixedIn.Module } g := groupIndex{ - name: fixedIn.Name, - id: oi.id, - osName: oi.name, - osVersion: oi.version, - osChannel: oi.channel, - hasModule: fixedIn.Module != nil, - module: mod, - format: fixedIn.VersionFormat, + name: fixedIn.Name, + id: oi.id, + osName: oi.name, + osVersion: oi.version, + osChannel: oi.channel, + identifier: fixedIn.Identifier, + hasModule: fixedIn.Module != nil, + module: mod, + format: fixedIn.VersionFormat, } grouped[g] = append(grouped[g], fixedIn) @@ -290,6 +311,12 @@ func getPackageType(osName string) pkg.Type { return pkg.ApkPkg case "windows": return pkg.KbPkg + case "rapidfort-ubuntu": + return pkg.DebPkg + case "rapidfort-alpine": + return pkg.ApkPkg + case "rapidfort-redhat": + return pkg.RpmPkg } return "" diff --git a/grype/db/v6/build/transformers/os/transform_test.go b/grype/db/v6/build/transformers/os/transform_test.go index 9736a9127e4..5148ff6fb13 100644 --- a/grype/db/v6/build/transformers/os/transform_test.go +++ b/grype/db/v6/build/transformers/os/transform_test.go @@ -44,6 +44,18 @@ func expectedProvider(name string) *db.Provider { func TestTransform(t *testing.T) { + rapidfortUbuntu2004OS := &db.OperatingSystem{ + Name: "rapidfort-ubuntu", + ReleaseID: "rapidfort-ubuntu", + MajorVersion: "20", + MinorVersion: "04", + } + rapidfortRedHat9OS := &db.OperatingSystem{ + Name: "rapidfort-redhat", + ReleaseID: "rapidfort-redhat", + MajorVersion: "9", + } + alpineOS := &db.OperatingSystem{ Name: "alpine", ReleaseID: "alpine", @@ -1138,6 +1150,147 @@ func TestTransform(t *testing.T) { }, }, }, + { + name: "testdata/rapidfort-ubuntu-20.04.json", + provider: "rapidfort", + want: []transformers.RelatedEntries{ + { + VulnerabilityHandle: &db.VulnerabilityHandle{ + Name: "CVE-2022-22576", + ProviderID: "rapidfort", + Provider: expectedProvider("rapidfort"), + Status: "active", + BlobValue: &db.VulnerabilityBlob{ + ID: "CVE-2022-22576", + Description: "curl: OAUTH2 bearer bypass in connection re-use", + References: []db.Reference{ + {URL: "https://cve.mitre.org/cgi-bin/cvename.cgi?name=CVE-2022-22576"}, + }, + Severities: []db.Severity{ + {Scheme: db.SeveritySchemeCHMLN, Value: "medium", Rank: 1}, + }, + }, + }, + Related: affectedPkgSlice( + db.AffectedPackageHandle{ + OperatingSystem: rapidfortUbuntu2004OS, + Package: &db.Package{Ecosystem: "deb", Name: "curl"}, + BlobValue: &db.PackageBlob{ + Ranges: []db.Range{ + { + Version: db.Version{ + Type: "dpkg", + Constraint: ">= 7.68.0, < 7.68.0-1ubuntu2.10", + }, + Fix: &db.Fix{ + Version: "7.68.0-1ubuntu2.10", + State: db.FixedStatus, + Detail: &db.FixDetail{ + Available: &db.FixAvailability{ + Date: timeRef(time.Date(2022, 5, 1, 0, 0, 0, 0, time.UTC)), + Kind: "advisory", + }, + }, + }, + }, + }, + }, + }, + ), + }, + }, + }, + { + name: "testdata/rapidfort-redhat-9.json", + provider: "rapidfort", + want: []transformers.RelatedEntries{ + { + VulnerabilityHandle: &db.VulnerabilityHandle{ + Name: "CVE-2014-0139", + ProviderID: "rapidfort", + Provider: expectedProvider("rapidfort"), + Status: "active", + BlobValue: &db.VulnerabilityBlob{ + ID: "CVE-2014-0139", + Description: "curl wildcard certificate validation issue", + References: []db.Reference{ + {URL: "https://www.cve.org/CVERecord?id=CVE-2014-0139"}, + }, + Severities: []db.Severity{ + {Scheme: db.SeveritySchemeCHMLN, Value: "low", Rank: 1}, + }, + }, + }, + Related: affectedPkgSlice( + db.AffectedPackageHandle{ + OperatingSystem: rapidfortRedHat9OS, + Package: &db.Package{Ecosystem: "rpm", Name: "curl"}, + BlobValue: &db.PackageBlob{ + Qualifiers: &db.PackageQualifiers{RpmModularity: strRef("")}, + Ranges: []db.Range{ + { + Version: db.Version{ + Type: "rpm", + Constraint: ">= 0", + }, + Fix: &db.Fix{ + Version: "", + State: db.NotFixedStatus, + Detail: &db.FixDetail{ + References: []db.Reference{ + { + ID: "release-identifier:el9", + URL: "https://access.redhat.com/errata/RHSA-TEST-EL9", + Tags: []string{db.AdvisoryReferenceTag}, + }, + { + ID: "RHSA-TEST-EL9", + URL: "https://access.redhat.com/errata/RHSA-TEST-EL9", + Tags: []string{db.AdvisoryReferenceTag}, + }, + }, + }, + }, + }, + }, + }, + }, + db.AffectedPackageHandle{ + OperatingSystem: rapidfortRedHat9OS, + Package: &db.Package{Ecosystem: "rpm", Name: "curl"}, + BlobValue: &db.PackageBlob{ + Qualifiers: &db.PackageQualifiers{RpmModularity: strRef("")}, + Ranges: []db.Range{ + { + Version: db.Version{ + Type: "rpm", + Constraint: ">= 0, < 7.78.0-4.fc36", + }, + Fix: &db.Fix{ + Version: "7.78.0-4.fc36", + State: db.FixedStatus, + Detail: &db.FixDetail{ + Available: &db.FixAvailability{ + Date: timeRef(time.Date(2024, 1, 1, 0, 0, 0, 0, time.UTC)), + Kind: "advisory", + }, + References: []db.Reference{ + { + ID: "release-identifier:fc36", + URL: "release-identifier:fc36", + Tags: []string{db.AdvisoryReferenceTag}, + }, + }, + }, + }, + }, + }, + }, + }, + ), + }, + }, + }, { name: "testdata/fedora-39.json", provider: "fedora", @@ -1506,6 +1659,33 @@ func TestGetOSInfo(t *testing.T) { channel: "eus", }, }, + { + name: "rapidfort ubuntu 20.04 (provider-curated, 2-part namespace)", + group: "rapidfort-ubuntu:20.04", + expected: osInfo{ + name: "rapidfort-ubuntu", + id: "rapidfort-ubuntu", + version: "20.04", + }, + }, + { + name: "rapidfort alpine 3.20 (provider-curated, 2-part namespace)", + group: "rapidfort-alpine:3.20", + expected: osInfo{ + name: "rapidfort-alpine", + id: "rapidfort-alpine", + version: "3.20", + }, + }, + { + name: "rapidfort redhat 9 (provider-curated, 2-part namespace)", + group: "rapidfort-redhat:9", + expected: osInfo{ + name: "rapidfort-redhat", + id: "rapidfort-redhat", + version: "9", + }, + }, } for _, tt := range tests { diff --git a/grype/distro/distro_test.go b/grype/distro/distro_test.go index 5ed02278f98..eea468e8d31 100644 --- a/grype/distro/distro_test.go +++ b/grype/distro/distro_test.go @@ -330,6 +330,9 @@ func Test_NewDistroFromRelease_Coverage(t *testing.T) { // Somewhat cheating with Windows. There is no support for detecting/parsing a Windows OS, so it is not // possible to comply with this test unless it is added manually to the "observed distros" definedDistros.Remove(string(Windows)) + definedDistros.Remove(string(RapidFortUbuntu)) + definedDistros.Remove(string(RapidFortAlpine)) + definedDistros.Remove(string(RapidFortRedHat)) tests := []struct { Name string diff --git a/grype/distro/type.go b/grype/distro/type.go index efac2c7900d..5acdcaffe1f 100644 --- a/grype/distro/type.go +++ b/grype/distro/type.go @@ -10,34 +10,37 @@ type Type string const ( // represents the set of supported Linux Distributions - Debian Type = "debian" - Ubuntu Type = "ubuntu" - RedHat Type = "redhat" - CentOS Type = "centos" - Fedora Type = "fedora" - Alpine Type = "alpine" - Busybox Type = "busybox" - AmazonLinux Type = "amazonlinux" - OracleLinux Type = "oraclelinux" - ArchLinux Type = "archlinux" - OpenSuseLeap Type = "opensuseleap" - SLES Type = "sles" - Photon Type = "photon" - Echo Type = "echo" - Windows Type = "windows" - Mariner Type = "mariner" - Azure Type = "azurelinux" - RockyLinux Type = "rockylinux" - AlmaLinux Type = "almalinux" - Gentoo Type = "gentoo" - Wolfi Type = "wolfi" - Chainguard Type = "chainguard" - MinimOS Type = "minimos" - Raspbian Type = "raspbian" - Scientific Type = "scientific" - SecureOS Type = "secureos" - PostmarketOS Type = "postmarketos" - Hummingbird Type = "hummingbird" + Debian Type = "debian" + Ubuntu Type = "ubuntu" + RedHat Type = "redhat" + CentOS Type = "centos" + Fedora Type = "fedora" + Alpine Type = "alpine" + Busybox Type = "busybox" + AmazonLinux Type = "amazonlinux" + OracleLinux Type = "oraclelinux" + ArchLinux Type = "archlinux" + OpenSuseLeap Type = "opensuseleap" + SLES Type = "sles" + Photon Type = "photon" + Echo Type = "echo" + Windows Type = "windows" + Mariner Type = "mariner" + Azure Type = "azurelinux" + RockyLinux Type = "rockylinux" + AlmaLinux Type = "almalinux" + Gentoo Type = "gentoo" + Wolfi Type = "wolfi" + Chainguard Type = "chainguard" + MinimOS Type = "minimos" + Raspbian Type = "raspbian" + Scientific Type = "scientific" + SecureOS Type = "secureos" + PostmarketOS Type = "postmarketos" + Hummingbird Type = "hummingbird" + RapidFortUbuntu Type = "rapidfort-ubuntu" + RapidFortAlpine Type = "rapidfort-alpine" + RapidFortRedHat Type = "rapidfort-redhat" ) // All contains all Linux distribution options @@ -70,37 +73,43 @@ var All = []Type{ SecureOS, PostmarketOS, Hummingbird, + RapidFortUbuntu, + RapidFortAlpine, + RapidFortRedHat, } // IDMapping maps a distro ID from the /etc/os-release (e.g. like "ubuntu") to a Distro type. var IDMapping = map[string]Type{ - "debian": Debian, - "ubuntu": Ubuntu, - "rhel": RedHat, - "centos": CentOS, - "fedora": Fedora, - "alpine": Alpine, - "busybox": Busybox, - "amzn": AmazonLinux, - "ol": OracleLinux, - "arch": ArchLinux, - "opensuse-leap": OpenSuseLeap, - "sles": SLES, - "photon": Photon, - "echo": Echo, - "mariner": Mariner, - "azurelinux": Azure, - "rocky": RockyLinux, - "almalinux": AlmaLinux, - "gentoo": Gentoo, - "wolfi": Wolfi, - "chainguard": Chainguard, - "minimos": MinimOS, - "raspbian": Raspbian, - "scientific": Scientific, - "secureos": SecureOS, - "postmarketos": PostmarketOS, - "hummingbird": Hummingbird, + "debian": Debian, + "ubuntu": Ubuntu, + "rhel": RedHat, + "centos": CentOS, + "fedora": Fedora, + "alpine": Alpine, + "busybox": Busybox, + "amzn": AmazonLinux, + "ol": OracleLinux, + "arch": ArchLinux, + "opensuse-leap": OpenSuseLeap, + "sles": SLES, + "photon": Photon, + "echo": Echo, + "mariner": Mariner, + "azurelinux": Azure, + "rocky": RockyLinux, + "almalinux": AlmaLinux, + "gentoo": Gentoo, + "wolfi": Wolfi, + "chainguard": Chainguard, + "minimos": MinimOS, + "raspbian": Raspbian, + "scientific": Scientific, + "secureos": SecureOS, + "postmarketos": PostmarketOS, + "hummingbird": Hummingbird, + "rapidfort-ubuntu": RapidFortUbuntu, + "rapidfort-alpine": RapidFortAlpine, + "rapidfort-redhat": RapidFortRedHat, } // aliasTypes maps common aliases to their corresponding Type. diff --git a/grype/match/matcher_type.go b/grype/match/matcher_type.go index 58a864918a6..a8948356500 100644 --- a/grype/match/matcher_type.go +++ b/grype/match/matcher_type.go @@ -20,6 +20,7 @@ const ( BitnamiMatcher MatcherType = "bitnami-matcher" PacmanMatcher MatcherType = "pacman-matcher" HexMatcher MatcherType = "hex-matcher" + RapidFortMatcher MatcherType = "rapidfort-matcher" ) var AllMatcherTypes = []MatcherType{ diff --git a/grype/matcher/matchers.go b/grype/matcher/matchers.go index 7ca44d65804..ce09744dce5 100644 --- a/grype/matcher/matchers.go +++ b/grype/matcher/matchers.go @@ -14,10 +14,12 @@ import ( "github.com/anchore/grype/grype/matcher/pacman" "github.com/anchore/grype/grype/matcher/portage" "github.com/anchore/grype/grype/matcher/python" + "github.com/anchore/grype/grype/matcher/rapidfort" "github.com/anchore/grype/grype/matcher/rpm" "github.com/anchore/grype/grype/matcher/ruby" "github.com/anchore/grype/grype/matcher/rust" "github.com/anchore/grype/grype/matcher/stock" + "github.com/anchore/grype/grype/pkg" ) // Config contains values used by individual matcher structs for advanced configuration @@ -55,3 +57,44 @@ func NewDefaultMatchers(mc Config) []match.Matcher { &pacman.Matcher{}, } } + +func ApplySelectionPolicy(matchers []match.Matcher, ctx pkg.Context) []match.Matcher { + if !rapidfort.IsRapidFortImage(ctx.Source) { + return matchers + } + + rfMatcher := rapidfort.NewMatcher() + replacements := map[match.MatcherType]match.Matcher{ + match.DpkgMatcher: rfMatcher, + match.ApkMatcher: rfMatcher, + match.RpmMatcher: rfMatcher, + } + + return applyMatcherOverrides(matchers, replacements) +} + +func applyMatcherOverrides(matchers []match.Matcher, replacements map[match.MatcherType]match.Matcher) []match.Matcher { + if len(replacements) == 0 { + return matchers + } + + var out []match.Matcher + added := make(map[match.MatcherType]struct{}) + + for _, m := range matchers { + replacement, ok := replacements[m.Type()] + if !ok { + out = append(out, m) + continue + } + + if _, exists := added[replacement.Type()]; exists { + continue + } + + out = append(out, replacement) + added[replacement.Type()] = struct{}{} + } + + return out +} diff --git a/grype/matcher/matchers_test.go b/grype/matcher/matchers_test.go new file mode 100644 index 00000000000..9792a5c7d80 --- /dev/null +++ b/grype/matcher/matchers_test.go @@ -0,0 +1,62 @@ +package matcher + +import ( + "testing" + + "github.com/stretchr/testify/assert" + + "github.com/anchore/grype/grype/match" + "github.com/anchore/grype/grype/pkg" + "github.com/anchore/syft/syft/source" +) + +func TestApplySelectionPolicy_UsesDefaultOSMatchersForNonRapidFortSources(t *testing.T) { + matchers := ApplySelectionPolicy(NewDefaultMatchers(Config{}), pkg.Context{}) + + assert.Contains(t, matcherTypes(matchers), match.DpkgMatcher) + assert.Contains(t, matcherTypes(matchers), match.ApkMatcher) + assert.NotContains(t, matcherTypes(matchers), match.RapidFortMatcher) +} + +func TestApplySelectionPolicy_UsesDefaultOSMatchersForNonRapidFortImage(t *testing.T) { + matchers := ApplySelectionPolicy(NewDefaultMatchers(Config{}), pkg.Context{ + Source: &source.Description{ + Metadata: source.ImageMetadata{ + Labels: map[string]string{ + "maintainer": "Other Vendor ", + }, + }, + }, + }) + + assert.Contains(t, matcherTypes(matchers), match.DpkgMatcher) + assert.Contains(t, matcherTypes(matchers), match.ApkMatcher) + assert.NotContains(t, matcherTypes(matchers), match.RapidFortMatcher) +} + +func TestApplySelectionPolicy_UsesRapidFortMatcherInsteadOfDebAndApkMatchers(t *testing.T) { + matchers := ApplySelectionPolicy(NewDefaultMatchers(Config{}), pkg.Context{ + Source: &source.Description{ + Metadata: source.ImageMetadata{ + Labels: map[string]string{ + "maintainer": "RapidFort Curation Team ", + }, + }, + }, + }) + + assert.Contains(t, matcherTypes(matchers), match.RapidFortMatcher) + assert.NotContains(t, matcherTypes(matchers), match.DpkgMatcher) + assert.NotContains(t, matcherTypes(matchers), match.ApkMatcher) + assert.NotContains(t, matcherTypes(matchers), match.RpmMatcher) + assert.Contains(t, matcherTypes(matchers), match.JavaMatcher) + assert.Contains(t, matcherTypes(matchers), match.StockMatcher) +} + +func matcherTypes(matchers []match.Matcher) []match.MatcherType { + out := make([]match.MatcherType, 0, len(matchers)) + for _, m := range matchers { + out = append(out, m.Type()) + } + return out +} diff --git a/grype/matcher/rapidfort/label.go b/grype/matcher/rapidfort/label.go new file mode 100644 index 00000000000..2e57519580a --- /dev/null +++ b/grype/matcher/rapidfort/label.go @@ -0,0 +1,32 @@ +package rapidfort + +import ( + "strings" + + "github.com/anchore/syft/syft/source" +) + +const maintainerLabel = "maintainer" + +// rapidFortMaintainerPrefix is the case-insensitive prefix used by all RapidFort +// maintainer label values (e.g. "RapidFort Curation Team "). +const rapidFortMaintainerPrefix = "rapidfort" + +// IsRapidFortImage returns true when the source is a container image whose +// "maintainer" label starts with "RapidFort" (case-insensitive) — the indicator +// that it is a RapidFort-curated image and should be scanned against RF advisories. +func IsRapidFortImage(src *source.Description) bool { + if src == nil { + return false + } + meta, ok := src.Metadata.(source.ImageMetadata) + if !ok { + return false + } + for k, v := range meta.Labels { + if strings.EqualFold(k, maintainerLabel) && strings.HasPrefix(strings.ToLower(v), rapidFortMaintainerPrefix) { + return true + } + } + return false +} diff --git a/grype/matcher/rapidfort/label_test.go b/grype/matcher/rapidfort/label_test.go new file mode 100644 index 00000000000..223d12639c9 --- /dev/null +++ b/grype/matcher/rapidfort/label_test.go @@ -0,0 +1,89 @@ +package rapidfort + +import ( + "testing" + + "github.com/stretchr/testify/assert" + + "github.com/anchore/syft/syft/source" +) + +func TestIsRapidFortImage(t *testing.T) { + tests := []struct { + name string + src *source.Description + expected bool + }{ + { + name: "nil source returns false", + src: nil, + expected: false, + }, + { + name: "non-image source (no ImageMetadata) returns false", + src: &source.Description{ + Metadata: source.DirectoryMetadata{}, + }, + expected: false, + }, + { + name: "image with rapidfort maintainer label matches", + src: &source.Description{ + Metadata: source.ImageMetadata{ + Labels: map[string]string{ + "maintainer": "RapidFort Curation Team ", + }, + }, + }, + expected: true, + }, + { + name: "label key is case-insensitive", + src: &source.Description{ + Metadata: source.ImageMetadata{ + Labels: map[string]string{ + "Maintainer": "RapidFort Curation Team ", + }, + }, + }, + expected: true, + }, + { + name: "label value prefix is case-insensitive", + src: &source.Description{ + Metadata: source.ImageMetadata{ + Labels: map[string]string{ + "maintainer": "RAPIDFORT Automation", + }, + }, + }, + expected: true, + }, + { + name: "non-rapidfort maintainer label does not match", + src: &source.Description{ + Metadata: source.ImageMetadata{ + Labels: map[string]string{ + "maintainer": "Other Vendor ", + }, + }, + }, + expected: false, + }, + { + name: "image with no labels returns false", + src: &source.Description{ + Metadata: source.ImageMetadata{ + Labels: map[string]string{}, + }, + }, + expected: false, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + assert.Equal(t, test.expected, IsRapidFortImage(test.src)) + }) + } +} diff --git a/grype/matcher/rapidfort/matcher.go b/grype/matcher/rapidfort/matcher.go new file mode 100644 index 00000000000..ab98ae69f32 --- /dev/null +++ b/grype/matcher/rapidfort/matcher.go @@ -0,0 +1,371 @@ +// Package rapidfort provides vulnerability matching for RapidFort-curated images. +// It is activated only when the scanned image is identified as RapidFort-curated +// (via the "maintainer=RapidFort" Docker label). It queries the DB for advisories +// stored under the "rapidfort-" OS name (e.g. "rapidfort-ubuntu"), +// which are never returned for standard distro scans. +package rapidfort + +import ( + "fmt" + "regexp" + "strings" + + "github.com/anchore/grype/grype/distro" + "github.com/anchore/grype/grype/match" + "github.com/anchore/grype/grype/matcher/internal" + "github.com/anchore/grype/grype/pkg" + "github.com/anchore/grype/grype/search" + "github.com/anchore/grype/grype/version" + "github.com/anchore/grype/grype/vulnerability" + "github.com/anchore/grype/internal/log" + syftPkg "github.com/anchore/syft/syft/pkg" +) + +// rfDistroMap maps a detected base OS type to its RF-prefixed counterpart in the DB. +// Extend this (plus getPackageType in v6 transform.go) when RF curates a new base distro. +var rfDistroMap = map[distro.Type]distro.Type{ + distro.Ubuntu: distro.RapidFortUbuntu, + distro.Alpine: distro.RapidFortAlpine, + distro.RedHat: distro.RapidFortRedHat, +} + +var ( + fedoraReleasePattern = regexp.MustCompile(`\.fc\d+(?:[._-].*)?$`) + rhelReleasePattern = regexp.MustCompile(`\.el(\d+)`) + rfReleasePattern = regexp.MustCompile(`\.rf(?:[._-].*)?$`) +) + +const ( + reasonNoReleaseIdentifier = "no release identifier on vulnerability" + reasonCannotDeriveReleaseID = "unable to derive rpm release identifier and no el release identifier on vulnerability" + reasonReleaseIdentifierMismatch = "package release identifier did not match vulnerability" +) + +// Matcher matches packages against RapidFort-specific advisories. +// It is only instantiated when the image under scan is detected as RF-curated. +type Matcher struct{} + +func NewMatcher() *Matcher { + return &Matcher{} +} + +func (m *Matcher) PackageTypes() []syftPkg.Type { + return []syftPkg.Type{syftPkg.DebPkg, syftPkg.ApkPkg, syftPkg.RpmPkg} +} + +func (m *Matcher) Type() match.MatcherType { + return match.RapidFortMatcher +} + +func (m *Matcher) Match(store vulnerability.Provider, p pkg.Package) ([]match.Match, []match.IgnoreFilter, error) { + if p.Distro == nil { + return nil, nil, nil + } + + // Only match against supported RF-curated base distros (ubuntu/alpine/redhat). + rfDistroType, ok := rfDistroMap[p.Distro.Type] + if !ok { + return nil, nil, nil + } + + // Build the RF-prefixed distro for DB queries. + // Intentionally omit the codename (e.g. "jammy") — the RF DB stores no codenames, + // and passing one would add a codename filter that yields 0 results. + rfDistro := distro.New(rfDistroType, rapidfortDistroVersion(*p.Distro, rfDistroType), "") + log.WithFields( + "package", p.Name, + "version", p.Version, + "sourceDistro", p.Distro.String(), + "rapidfortDistro", rfDistro.String(), + ).Debug("rapidfort matcher: remapped distro for RapidFort query") + + var all []match.Match + + // 1. Match by source/upstream package names first, when syft populated Upstreams (same idea as + // dpkg/rpm matchers): e.g. Debian binary → source, or RPM subpackage → source name from + // SourceRPM metadata. If Upstreams is empty, this step is a no-op. + sourceMatches, err := m.matchUpstreamPackages(store, p, rfDistro) + if err != nil { + return nil, nil, fmt.Errorf("rapidfort matcher failed upstream lookup for %q: %w", p.Name, err) + } + all = append(all, sourceMatches...) + + // 2. Direct lookup by installed package name (covers RF-prefixed binaries and advisories + // keyed on the subpackage/binary name rather than the source). + rfPkg := p + rfPkg.Distro = rfDistro + binaryMatches, err := m.matchPackageByDistro(store, rfPkg, &p, match.ExactDirectMatch) + if err != nil { + return nil, nil, fmt.Errorf("rapidfort matcher failed binary lookup for %q: %w", p.Name, err) + } + all = append(all, binaryMatches...) + + // 3. Post-filter: suppress any match where the installed version is already at or + // beyond the recorded fix version. This is an explicit safety net — well-formed + // RF advisories use strict "< fixVersion" constraints so the DB query already + // excludes fixed packages, but this guards against edge cases (e.g. advisory + // has only an "introduced" event and was later patched without updating the DB). + // + // Priority: fix-version check first, range constraint second. + format := pkg.VersionFormat(p) + filtered := filterAlreadyFixed(all, p.Version, format) + + // 4. Dedup: upstream and direct lookups can surface the same CVE for the same package. + return dedupMatches(filtered), nil, nil +} + +func rapidfortDistroVersion(baseDistro distro.Distro, rfDistroType distro.Type) string { + if rfDistroType == distro.RapidFortRedHat && baseDistro.MajorVersion() != "" { + return baseDistro.MajorVersion() + } + return baseDistro.Version +} + +// matchUpstreamPackages searches the RF DB using pkg.UpstreamPackages(p) — names from syft’s +// Upstreams field (Debian source packages, RPM source names derived from SourceRPM, etc.). +// Works for DebPkg, ApkPkg, and RpmPkg whenever the catalog includes upstream metadata. +func (m *Matcher) matchUpstreamPackages(store vulnerability.Provider, p pkg.Package, rfDistro *distro.Distro) ([]match.Match, error) { + var matches []match.Match + + for _, upstream := range pkg.UpstreamPackages(p) { + upstream.Distro = rfDistro + found, err := m.matchPackageByDistro(store, upstream, &p, match.ExactIndirectMatch) + if err != nil { + return nil, fmt.Errorf("failed RF upstream lookup for source %q: %w", upstream.Name, err) + } + matches = append(matches, found...) + } + + // Mark all upstream-sourced matches as indirect (the artifact is the binary pkg). + match.ConvertToIndirectMatches(matches, p) + return matches, nil +} + +func (m *Matcher) matchPackageByDistro(store vulnerability.Provider, searchPkg pkg.Package, catalogPkg *pkg.Package, ty match.Type) ([]match.Match, error) { + if searchPkg.Distro == nil { + return nil, nil + } + + pkgVersion := version.New(searchPkg.Version, pkg.VersionFormat(searchPkg)) + criteria := []vulnerability.Criteria{ + search.ByPackageName(searchPkg.Name), + search.ByDistro(*searchPkg.Distro), + internal.OnlyQualifiedPackages(searchPkg), + internal.OnlyVulnerableVersions(pkgVersion), + } + + if searchPkg.Distro.Type == distro.RapidFortRedHat { + criteria = append(criteria, byRPMReleaseIdentifier(searchPkg)) + } + + vulns, err := store.FindVulnerabilities(criteria...) + if err != nil { + return nil, fmt.Errorf("matcher failed to fetch distro=%q pkg=%q: %w", searchPkg.Distro, searchPkg.Name, err) + } + + log.WithFields( + "package", searchPkg.Name, + "version", searchPkg.Version, + "distro", searchPkg.Distro.String(), + "matches", len(vulns), + ).Debug("rapidfort matcher: fetched vulnerabilities for package") + + var matches []match.Match + for _, vuln := range vulns { + matches = append(matches, match.Match{ + Vulnerability: vuln, + Package: matchPackage(searchPkg, catalogPkg), + Details: distroMatchDetails(m.Type(), ty, searchPkg, catalogPkg, vuln), + }) + } + + return matches, nil +} + +func byRPMReleaseIdentifier(p pkg.Package) vulnerability.Criteria { + return search.ByFunc(func(vuln vulnerability.Vulnerability) (bool, string, error) { + expected := installedReleaseIdentifier(p) + if expected == "" { + for _, advisory := range vuln.Advisories { + id := advisoryID(advisory) + if strings.HasPrefix(id, "release-identifier:el") { + return true, "", nil + } + } + return false, reasonCannotDeriveReleaseID, nil + } + + found := false + for _, advisory := range vuln.Advisories { + id := advisoryID(advisory) + if !strings.HasPrefix(id, "release-identifier:") { + continue + } + found = true + if strings.TrimPrefix(id, "release-identifier:") == expected { + return true, "", nil + } + } + + if !found { + return false, reasonNoReleaseIdentifier, nil + } + + return false, reasonReleaseIdentifierMismatch, nil + }) +} + +func advisoryID(advisory vulnerability.Advisory) string { + return strings.ToLower(strings.TrimSpace(advisory.ID)) +} + +func installedReleaseIdentifier(p pkg.Package) string { + version := strings.ToLower(strings.TrimSpace(p.Version)) + + if id := fedoraReleaseID(p, version); id != "" { + return id + } + if id := rfReleaseID(p, version); id != "" { + return id + } + if id := rhelReleaseID(p, version); id != "" { + return id + } + if id := rfNameReleaseID(p); id != "" { + return id + } + + return "" +} + +func fedoraReleaseID(p pkg.Package, version string) string { + if !fedoraReleasePattern.MatchString(version) { + return "" + } + + idx := strings.LastIndex(version, ".fc") + if idx < 0 { + return "" + } + + id := version[idx+1:] + return id +} + +func rfReleaseID(p pkg.Package, version string) string { + if !rfReleasePattern.MatchString(version) { + return "" + } + + return "rf" +} + +func rhelReleaseID(p pkg.Package, version string) string { + if !rhelReleasePattern.MatchString(version) { + return "" + } + + match := rhelReleasePattern.FindStringSubmatch(version) + if len(match) != 2 { + return "" + } + id := "el" + match[1] + return id +} + +func rfNameReleaseID(p pkg.Package) string { + if !strings.HasPrefix(strings.ToLower(p.Name), "rf-") { + return "" + } + return "rf" +} + +func matchPackage(searchPkg pkg.Package, catalogPkg *pkg.Package) pkg.Package { + if catalogPkg != nil { + return *catalogPkg + } + return searchPkg +} + +func distroMatchDetails(upstreamMatcher match.MatcherType, ty match.Type, searchPkg pkg.Package, catalogPkg *pkg.Package, vuln vulnerability.Vulnerability) []match.Detail { + return []match.Detail{ + { + Type: ty, + Matcher: upstreamMatcher, + SearchedBy: match.DistroParameters{ + Distro: match.DistroIdentification{ + Type: searchPkg.Distro.Type.String(), + Version: searchPkg.Distro.Version, + }, + Package: match.PackageParameter{ + Name: searchPkg.Name, + Version: searchPkg.Version, + }, + Namespace: vuln.Namespace, + }, + Found: match.DistroResult{ + VulnerabilityID: vuln.ID, + VersionConstraint: vuln.Constraint.String(), + }, + Confidence: 1.0, + }, + } +} + +// filterAlreadyFixed removes matches where the installed version exactly equals +// a recorded fix version, meaning the package has been patched. +// +// Logic (in priority order): +// 1. If Fix.State == FixStateFixed AND installedVersion == fixVersion → suppress. +// 2. Otherwise the range constraint (already evaluated by FindVulnerabilities) decides. +func filterAlreadyFixed(matches []match.Match, installedVer string, format version.Format) []match.Match { + installed := version.New(installedVer, format) + var out []match.Match + for _, m := range matches { + fix := m.Vulnerability.Fix + if fix.State == vulnerability.FixStateFixed { + suppressed := false + for _, fixVer := range fix.Versions { + if fixVer == "" || fixVer == "None" { + continue + } + // cmp = fixVersion.Compare(installed): 0 means installed == fixVersion + cmp, err := version.New(fixVer, format).Compare(installed) + if err != nil { + log.WithFields("pkg", installedVer, "fixVersion", fixVer, "err", err). + Trace("rapidfort: could not compare fix version, keeping match") + continue + } + if cmp == 0 { + // installed version equals the fix — package is patched + log.WithFields("vuln", m.Vulnerability.ID, "pkg", installedVer, "fixVersion", fixVer). + Trace("rapidfort: suppressing match — installed version == fix version") + suppressed = true + break + } + } + if suppressed { + vulnerability.LogDropped(m.Vulnerability.ID, "rapidfort-matcher", + "installed version equals fix version", installedVer) + continue + } + } + out = append(out, m) + } + return out +} + +// dedupMatches removes duplicate matches with the same vulnerability ID. +// Source and binary package lookups can surface the same CVE for the same package. +func dedupMatches(matches []match.Match) []match.Match { + seen := make(map[string]struct{}, len(matches)) + out := make([]match.Match, 0, len(matches)) + for _, m := range matches { + if _, ok := seen[m.Vulnerability.ID]; ok { + continue + } + seen[m.Vulnerability.ID] = struct{}{} + out = append(out, m) + } + return out +} diff --git a/grype/matcher/rapidfort/matcher_test.go b/grype/matcher/rapidfort/matcher_test.go new file mode 100644 index 00000000000..72b2167ca2b --- /dev/null +++ b/grype/matcher/rapidfort/matcher_test.go @@ -0,0 +1,172 @@ +package rapidfort + +import ( + "testing" + + "github.com/stretchr/testify/assert" + + "github.com/anchore/grype/grype/distro" + "github.com/anchore/grype/grype/match" + "github.com/anchore/grype/grype/pkg" + "github.com/anchore/grype/grype/version" + "github.com/anchore/grype/grype/vulnerability" +) + +func TestInstalledReleaseIdentifier(t *testing.T) { + tests := []struct { + name string + pkg pkg.Package + expected string + }{ + { + name: "fedora release", + pkg: pkg.Package{Name: "curl", Version: "8.6.0-7.fc41"}, + expected: "fc41", + }, + { + name: "rapidfort release from version suffix", + pkg: pkg.Package{Name: "curl", Version: "3.6.1-11.rf"}, + expected: "rf", + }, + { + name: "rhel release with minor suffix", + pkg: pkg.Package{Name: "curl", Version: "7.88.1-5.el9_4.1"}, + expected: "el9", + }, + { + name: "rf package fallback", + pkg: pkg.Package{Name: "rf-curl", Version: "1.2.3-1"}, + expected: "rf", + }, + { + name: "unknown release", + pkg: pkg.Package{Name: "curl", Version: "1.2.3-1"}, + expected: "", + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + assert.Equal(t, test.expected, installedReleaseIdentifier(test.pkg)) + }) + } +} + +func TestByRPMReleaseIdentifier(t *testing.T) { + criteria := byRPMReleaseIdentifier(pkg.Package{ + Name: "curl", + Version: "8.6.0-7.fc41", + Distro: distro.New(distro.RapidFortRedHat, "9", ""), + }) + + matched, reason, err := criteria.MatchesVulnerability(vulnerability.Vulnerability{ + Advisories: []vulnerability.Advisory{ + {ID: "release-identifier:fc41"}, + }, + }) + assert.NoError(t, err) + assert.True(t, matched) + assert.Empty(t, reason) + + matched, reason, err = criteria.MatchesVulnerability(vulnerability.Vulnerability{ + Advisories: []vulnerability.Advisory{ + {ID: "release-identifier:el9"}, + }, + }) + assert.NoError(t, err) + assert.False(t, matched) + assert.Equal(t, reasonReleaseIdentifierMismatch, reason) +} + +func TestByRPMReleaseIdentifier_FallsBackToELWhenInstalledIdentifierUnknown(t *testing.T) { + criteria := byRPMReleaseIdentifier(pkg.Package{ + Name: "curl", + Version: "1.2.3-1", + Distro: distro.New(distro.RapidFortRedHat, "9", ""), + }) + + matched, reason, err := criteria.MatchesVulnerability(vulnerability.Vulnerability{ + Advisories: []vulnerability.Advisory{ + {ID: "release-identifier:el9"}, + }, + }) + assert.NoError(t, err) + assert.True(t, matched) + assert.Empty(t, reason) + + matched, reason, err = criteria.MatchesVulnerability(vulnerability.Vulnerability{ + Advisories: []vulnerability.Advisory{ + {ID: "release-identifier:fc41"}, + }, + }) + assert.NoError(t, err) + assert.False(t, matched) + assert.Contains(t, reason, "no el release identifier") +} + +func TestRapidfortDistroVersion(t *testing.T) { + tests := []struct { + name string + baseDistro distro.Distro + rfDistro distro.Type + expectedVer string + }{ + { + name: "rapidfort redhat uses major version only", + baseDistro: *distro.New(distro.RedHat, "9.5", ""), + rfDistro: distro.RapidFortRedHat, + expectedVer: "9", + }, + { + name: "rapidfort redhat keeps major only when already major", + baseDistro: *distro.New(distro.RedHat, "9", ""), + rfDistro: distro.RapidFortRedHat, + expectedVer: "9", + }, + { + name: "rapidfort ubuntu keeps full version", + baseDistro: *distro.New(distro.Ubuntu, "20.04", ""), + rfDistro: distro.RapidFortUbuntu, + expectedVer: "20.04", + }, + { + name: "rapidfort alpine keeps full version", + baseDistro: *distro.New(distro.Alpine, "3.15", ""), + rfDistro: distro.RapidFortAlpine, + expectedVer: "3.15", + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + assert.Equal(t, test.expectedVer, rapidfortDistroVersion(test.baseDistro, test.rfDistro)) + }) + } +} + +func TestFilterAlreadyFixed_KeepsVulnBeforeFixVersionAcrossReleases(t *testing.T) { + installed := "1.7.2-1.fc41" + format := version.RpmFormat + + matches := []match.Match{ + { + Vulnerability: vulnerability.Vulnerability{ + Reference: vulnerability.Reference{ + ID: "CVE-TEST-FC-CROSS-RELEASE", + }, + Fix: vulnerability.Fix{ + State: vulnerability.FixStateFixed, + Versions: []string{"1.7.5-1.fc45"}, + }, + }, + }, + } + + filtered := filterAlreadyFixed(matches, installed, format) + + // Installed version is before the fixed version in a later Fedora release, + // so the match must be kept and fix metadata preserved for reporting. + if assert.Len(t, filtered, 1) { + assert.Equal(t, matches[0].Vulnerability.Fix, filtered[0].Vulnerability.Fix) + } +} From 28ba28cc996da4accd8fece20255e7fcb0362014 Mon Sep 17 00:00:00 2001 From: rfJanit Date: Mon, 6 Jul 2026 13:10:27 +0530 Subject: [PATCH 2/5] Fixing Merge Conflict in Transform.Go (#4) * give install script uploader contents: read (#3489) Signed-off-by: Will Murphy * Govulndb OSV transformer (#3485) * govulndb osv Signed-off-by: Will Murphy * add fixed dates to govulndb osv transformer Signed-off-by: Will Murphy * enforce lowercase ids on cache Signed-off-by: Will Murphy --------- Signed-off-by: Will Murphy * chore(deps): update tool versions (#3488) Signed-off-by: anchore-oss-update-bot Co-authored-by: anchore-oss-update-bot * chore(deps): update quality gate database (#3478) Signed-off-by: anchore-oss-update-bot Co-authored-by: anchore-oss-update-bot * feat(scan): add support for a Zarf scan target (#3366) Signed-off-by: Brandt Keller * fix: respect withdrawn status of Go Vuln DB OSV records (#3495) Otherwise we get false positives. Includes a matcher test to assert that Grype does not find withdrawn go vulns. Signed-off-by: Will Murphy * chore(deps): update anchore dependencies (#3487) Signed-off-by: anchore-oss-update-bot Co-authored-by: anchore-oss-update-bot * test: add test fixtures for multi RHSA records from vunnel (#3480) Signed-off-by: Will Murphy * fix: avoid panic on invalid RHEL version IDs (#3490) Signed-off-by: Jeremy Spilman * chore(deps): bump the actions-minor-patch group across 2 directories with 7 updates (#3503) Bumps the actions-minor-patch group with 6 updates in the / directory: | Package | From | To | | --- | --- | --- | | [anchore/workflows/.github/workflows/codeql.yaml](https://github.com/anchore/workflows) | `0.7.0` | `0.7.2` | | [anchore/workflows/.github/workflows/check-version-available.yaml](https://github.com/anchore/workflows) | `0.7.0` | `0.7.2` | | [anchore/workflows/.github/workflows/check-gate.yaml](https://github.com/anchore/workflows) | `0.7.0` | `0.7.2` | | [actions/checkout](https://github.com/actions/checkout) | `6.0.2` | `6.0.3` | | [anchore/workflows/.github/workflows/release-install-script.yaml](https://github.com/anchore/workflows) | `0.7.0` | `0.7.2` | | [github/codeql-action](https://github.com/github/codeql-action) | `4.36.0` | `4.36.2` | Bumps the actions-minor-patch group with 1 update in the /.github/actions/bootstrap directory: [anchore/go-make](https://github.com/anchore/go-make). Updates `anchore/workflows/.github/workflows/codeql.yaml` from 0.7.0 to 0.7.2 - [Release notes](https://github.com/anchore/workflows/releases) - [Commits](https://github.com/anchore/workflows/compare/b3e328b5ae31ba96297e2ed9a6124e5e6352a4c5...b0c30a80409130d329aaa356fd64a34d8c0b3375) Updates `anchore/workflows/.github/workflows/check-version-available.yaml` from 0.7.0 to 0.7.2 - [Release notes](https://github.com/anchore/workflows/releases) - [Commits](https://github.com/anchore/workflows/compare/b3e328b5ae31ba96297e2ed9a6124e5e6352a4c5...b0c30a80409130d329aaa356fd64a34d8c0b3375) Updates `anchore/workflows/.github/workflows/check-gate.yaml` from 0.7.0 to 0.7.2 - [Release notes](https://github.com/anchore/workflows/releases) - [Commits](https://github.com/anchore/workflows/compare/b3e328b5ae31ba96297e2ed9a6124e5e6352a4c5...b0c30a80409130d329aaa356fd64a34d8c0b3375) Updates `actions/checkout` from 6.0.2 to 6.0.3 - [Release notes](https://github.com/actions/checkout/releases) - [Changelog](https://github.com/actions/checkout/blob/main/CHANGELOG.md) - [Commits](https://github.com/actions/checkout/compare/de0fac2e4500dabe0009e67214ff5f5447ce83dd...df4cb1c069e1874edd31b4311f1884172cec0e10) Updates `anchore/workflows/.github/workflows/release-install-script.yaml` from 0.7.0 to 0.7.2 - [Release notes](https://github.com/anchore/workflows/releases) - [Commits](https://github.com/anchore/workflows/compare/b3e328b5ae31ba96297e2ed9a6124e5e6352a4c5...b0c30a80409130d329aaa356fd64a34d8c0b3375) Updates `github/codeql-action` from 4.36.0 to 4.36.2 - [Release notes](https://github.com/github/codeql-action/releases) - [Changelog](https://github.com/github/codeql-action/blob/main/CHANGELOG.md) - [Commits](https://github.com/github/codeql-action/compare/7211b7c8077ea37d8641b6271f6a365a22a5fbfa...8aad20d150bbac5944a9f9d289da16a4b0d87c1e) Updates `anchore/go-make` from 0.5.0 to 0.6.0 - [Release notes](https://github.com/anchore/go-make/releases) - [Commits](https://github.com/anchore/go-make/compare/9de27be11ed73e2f9d5406a836a492b7d8aa1225...39fe5f71112d4dceb3ff0a92a40f272f067fc457) --- updated-dependencies: - dependency-name: anchore/workflows/.github/workflows/codeql.yaml dependency-version: 0.7.2 dependency-type: direct:production update-type: version-update:semver-patch dependency-group: actions-minor-patch - dependency-name: anchore/workflows/.github/workflows/check-version-available.yaml dependency-version: 0.7.2 dependency-type: direct:production update-type: version-update:semver-patch dependency-group: actions-minor-patch - dependency-name: anchore/workflows/.github/workflows/check-gate.yaml dependency-version: 0.7.2 dependency-type: direct:production update-type: version-update:semver-patch dependency-group: actions-minor-patch - dependency-name: actions/checkout dependency-version: 6.0.3 dependency-type: direct:production update-type: version-update:semver-patch dependency-group: actions-minor-patch - dependency-name: anchore/workflows/.github/workflows/release-install-script.yaml dependency-version: 0.7.2 dependency-type: direct:production update-type: version-update:semver-patch dependency-group: actions-minor-patch - dependency-name: github/codeql-action dependency-version: 4.36.2 dependency-type: direct:production update-type: version-update:semver-patch dependency-group: actions-minor-patch - dependency-name: anchore/go-make dependency-version: 0.6.0 dependency-type: direct:production update-type: version-update:semver-minor dependency-group: actions-minor-patch ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> * exclude linux-kbuild deb indirect matches by default (#3506) Signed-off-by: Weston Steimel * Add OSV Model generator (#3499) * Add OSV Model generator Previously, there was an OSV scanner repository that exported a models.Vulnerability struct we could use. That repository deprecated their exported models and the models in grype are falling behind. Therefore, add a generator target that generates our own models based on the latest v1 JSON scehma from the official OSV spec repository. In this way automation can PR new models in. Signed-off-by: Will Murphy * less coupled generator Signed-off-by: Will Murphy * parse severity as singular and drop entry type Signed-off-by: Alex Goodman * Remove v1 from file names If we need a v2 model, we should have a package with v2 in the path so that both can be imported. v1 in a filename is just noise. Signed-off-by: Will Murphy --------- Signed-off-by: Will Murphy Signed-off-by: Alex Goodman Co-authored-by: Alex Goodman * chore(deps): bump github.com/anchore/go-make (#3502) Bumps the go-minor-patch group with 1 update in the /.make directory: [github.com/anchore/go-make](https://github.com/anchore/go-make). Updates `github.com/anchore/go-make` from 0.5.0 to 0.6.0 - [Release notes](https://github.com/anchore/go-make/releases) - [Commits](https://github.com/anchore/go-make/compare/v0.5.0...v0.6.0) --- updated-dependencies: - dependency-name: github.com/anchore/go-make dependency-version: 0.6.0 dependency-type: direct:production update-type: version-update:semver-minor dependency-group: go-minor-patch ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> * fix(go-vulndb): merge in custom ranges when applicable (#3514) * fix(go-vulndb): merge in custom ranges when applicable Go OSV data has the standard OSV ranges, but also has custom ranges. Packages that do non-standard go versioning, for example by pushing a tag that starts with v2.x.y but not adding a /v2 in their module path, get relegated to the custom ranges field. In other words, there is one affected range for "strictly go versioning compliant versions" and another set of ranges for "version control tags that are not perfectly compliant". Previously, Grype just ignore this second bucket of version ranges, resulting in a meaningful number of false positives. Fix this by, if custom ranges are present, stripping out unbounded (e.g. > 0) ranges from the regular ranges and then unioning the two range sets. Signed-off-by: Will Murphy * cleanup: avoid double parsing ranges, clean up comments Signed-off-by: Will Murphy * fix up go vuln db transformer Includes using mapstructure to deserialize custom fields, and some readability refactors. Signed-off-by: Will Murphy --------- Signed-off-by: Will Murphy * fix: disable go stdlib CPE matching by default. (#3517) Previously, because GHSA does not cover the go stdlib, in order to avoid false negatives on the go stdlib, CPE matching had to be used. However, grype now includes the Go Vuln DB data it its database, which does include the stdlib as if a regular go module, so this CPE fallback can be disabled by default, fixing many false positives. Signed-off-by: Will Murphy * chore(deps): bump the go-minor-patch group across 1 directory with 2 updates (#3518) Bumps the go-minor-patch group with 2 updates in the / directory: [golang.org/x/text](https://github.com/golang/text) and [golang.org/x/tools](https://github.com/golang/tools). Updates `golang.org/x/text` from 0.37.0 to 0.38.0 - [Release notes](https://github.com/golang/text/releases) - [Commits](https://github.com/golang/text/compare/v0.37.0...v0.38.0) Updates `golang.org/x/tools` from 0.45.0 to 0.46.0 - [Release notes](https://github.com/golang/tools/releases) - [Commits](https://github.com/golang/tools/compare/v0.45.0...v0.46.0) --- updated-dependencies: - dependency-name: golang.org/x/text dependency-version: 0.38.0 dependency-type: direct:production update-type: version-update:semver-minor dependency-group: go-minor-patch - dependency-name: golang.org/x/tools dependency-version: 0.46.0 dependency-type: direct:production update-type: version-update:semver-minor dependency-group: go-minor-patch ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> * fix(hummingbird): mark hummingbird distro as rolling (#3521) Previous hummingbird work incorrectly marked only the search side (data.go) as having a rolling label. Therefore also mark hummingbird as a rolling distro at database creation time. Signed-off-by: Will Murphy * refactor release pipeline: TAG_TOKEN, skip-checks gate, go-make bump, dependabot/zizmor/gci cleanup (#3524) Signed-off-by: Alex Goodman * chore(deps): bump github.com/containerd/containerd/v2 (#3525) Bumps [github.com/containerd/containerd/v2](https://github.com/containerd/containerd) from 2.3.1 to 2.3.2. - [Release notes](https://github.com/containerd/containerd/releases) - [Changelog](https://github.com/containerd/containerd/blob/main/RELEASES.md) - [Commits](https://github.com/containerd/containerd/compare/v2.3.1...v2.3.2) --- updated-dependencies: - dependency-name: github.com/containerd/containerd/v2 dependency-version: 2.3.2 dependency-type: indirect ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> * fix(govulndb): mix floors from custom ranges as appropriate (#3522) Previously, lower and upper bounds on vulnerable windows would be ORed together if part of the range was reported in osv standard affected events and the other part was reported in OSV ecosystem-specific custom_ranges events. Fix the merge logic so that floors from the custom ranges have the correct effect. Signed-off-by: Will Murphy * fix(govulndb): only emit records for stdlib (#3527) * fix(govulndb): only emit records for stdlib For third party go packages, go vuln db has some very strange version ranges, and this has caused a number of false positive reports. Additionally, for most of them, Grype currently reports both a GHSA and a GO record, resulting in user confusion. However, there is one key class of record available in the go vuln db that is not in GHSA: records affecting the go stdlib itself (that is, the core parts of the langage that are statically linked into every go binary). Therefore, for now, emit only records relating to the go stdlib. Future enhancements can pull in additional data related to third party go packages in go vuln db. Signed-off-by: Will Murphy * Clean up some additional test fixtures Signed-off-by: Will Murphy --------- Signed-off-by: Will Murphy * Update go-make to v0.8.0 (#3528) Signed-off-by: Alex Goodman * chore(units): account for changes in Syft and fix stale listing file (#3532) Signed-off-by: Will Murphy * feat(govulndb): emit golang.org/x/net vulns from govlundb (#3534) These packages are not covered by GHSA and generally have well-formed versions, so emit them. Signed-off-by: Will Murphy * chore(deps): update anchore dependencies (#3498) Signed-off-by: anchore-oss-update-bot Co-authored-by: anchore-oss-update-bot * chore(deps): bump actions/checkout from 6.0.3 to 7.0.0 (#3535) Bumps [actions/checkout](https://github.com/actions/checkout) from 6.0.3 to 7.0.0. - [Release notes](https://github.com/actions/checkout/releases) - [Changelog](https://github.com/actions/checkout/blob/main/CHANGELOG.md) - [Commits](https://github.com/actions/checkout/compare/df4cb1c069e1874edd31b4311f1884172cec0e10...9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0) --- updated-dependencies: - dependency-name: actions/checkout dependency-version: 7.0.0 dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> * chore(deps): update quality gate database (#3543) Signed-off-by: anchore-oss-update-bot Co-authored-by: anchore-oss-update-bot * feat: populate package architecture for matching (#3504) * feat: populate package architecture for matching Enable matchers to filter on architecture-specific vulnerabilities or fixes. There are basically three changes here: First, wire up the package collection creator to populate architecture on the packages when an image is scanned or an sbom is loaded in Grype. Second, wire up the architecture package qualifier so that if a vulnerability or fix specifies that it is only applicable to a particular architecture, the matchers will respect this, and third, in the RPM matcher, specify `arch=src` when synthesizing the upstream package to search by (rather than letting it inherit the binary package's architecture). The qualifier logic is like this: If either the package or the DB record has a blank architecture, the filtering is a noop. If the package has a specific architecture and the DB record has 'src', the package is qualified, because a disclosure or fix in a src RPM for example is assumed to affect things built from it. If the DB record and the package both have specific binary architectures, but they don't match, e.g. x86_64 != aarch64, the package is not qualified and filters out. Architecture qualifiers are canonicalized at match time so, for example, an RPM that has x86_64 architecture is a qualified package on a vuln that affects "amd64" processors (assuming distro and everything else line up). This canonicalization is probably more important for future expansion since a given distro will probably use the same architecture strings as their own package manager. Hummingbird also has a special "binary-no-arch-specified" architecture that means, "this is not a src RPM". That is so that in Hummingbird data, if we have a package that says, 'upstream=glibc' in its PURL, and we have a glibc vuln against the binary but not the src RPM, we can fitler out this false positive. If a DB record has 'binary-no-arch-specified' as its architecture, any package that has any non-src architecture (or a blank architecture) is qualified, but src architecture is not. Signed-off-by: Will Murphy * Make architecture aliases data driven Signed-off-by: Will Murphy * handle noarch / all arch Signed-off-by: Will Murphy * Wire up end-to-end architecture specific matching for OL OracleLinux had some existing false positives where the x86_64 package was fixed in an earlier version of the RPM than the aarch64 version, resulting in false positives when Grype checked against the aarch64 version. Add a dbtest for this scenario and update the RPM matcher and OS transformer to fix this case. Signed-off-by: Will Murphy * move setting src arch on upstream to helper Signed-off-by: Will Murphy * exhaustive tests for package arch qualifier Signed-off-by: Will Murphy --------- Signed-off-by: Will Murphy * fix(zarf): swap warn messages for debug (#3545) Signed-off-by: Brandt Keller * chore(deps): bump anchore/workflows/.github/workflows/check-version-available.yaml (#3547) Bumps [anchore/workflows/.github/workflows/check-version-available.yaml](https://github.com/anchore/workflows) from 0.7.2 to 0.8.0. - [Release notes](https://github.com/anchore/workflows/releases) - [Commits](https://github.com/anchore/workflows/compare/b0c30a80409130d329aaa356fd64a34d8c0b3375...7212994dc8fc3a53fe9c8e766ab5b4ddd16ea3d4) --- updated-dependencies: - dependency-name: anchore/workflows/.github/workflows/check-version-available.yaml dependency-version: 0.8.0 dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> * chore(deps): bump zizmorcore/zizmor-action from 0.5.6 to 0.5.7 (#3549) Bumps [zizmorcore/zizmor-action](https://github.com/zizmorcore/zizmor-action) from 0.5.6 to 0.5.7. - [Release notes](https://github.com/zizmorcore/zizmor-action/releases) - [Commits](https://github.com/zizmorcore/zizmor-action/compare/5f14fd08f7cf1cb1609c1e344975f152c7ee938d...192e21d79ab29983730a13d1382995c2307fbcaa) --- updated-dependencies: - dependency-name: zizmorcore/zizmor-action dependency-version: 0.5.7 dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> * chore(deps): bump anchore/workflows/.github/workflows/codeql.yaml (#3548) Bumps [anchore/workflows/.github/workflows/codeql.yaml](https://github.com/anchore/workflows) from 0.7.2 to 0.8.0. - [Release notes](https://github.com/anchore/workflows/releases) - [Commits](https://github.com/anchore/workflows/compare/b0c30a80409130d329aaa356fd64a34d8c0b3375...7212994dc8fc3a53fe9c8e766ab5b4ddd16ea3d4) --- updated-dependencies: - dependency-name: anchore/workflows/.github/workflows/codeql.yaml dependency-version: 0.8.0 dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> * chore(deps): bump golang.org/x/tools from 0.46.0 to 0.47.0 (#3550) Bumps [golang.org/x/tools](https://github.com/golang/tools) from 0.46.0 to 0.47.0. - [Release notes](https://github.com/golang/tools/releases) - [Commits](https://github.com/golang/tools/compare/v0.46.0...v0.47.0) --- updated-dependencies: - dependency-name: golang.org/x/tools dependency-version: 0.47.0 dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> * chore(deps): bump actions/cache/restore in /.github/actions/bootstrap (#3556) Bumps [actions/cache/restore](https://github.com/actions/cache) from 5.0.5 to 6.1.0. - [Release notes](https://github.com/actions/cache/releases) - [Changelog](https://github.com/actions/cache/blob/main/RELEASES.md) - [Commits](https://github.com/actions/cache/compare/27d5ce7f107fe9357f9df03efb73ab90386fccae...55cc8345863c7cc4c66a329aec7e433d2d1c52a9) --- updated-dependencies: - dependency-name: actions/cache/restore dependency-version: 6.1.0 dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> * chore(deps): bump actions/cache from 5.0.5 to 6.1.0 (#3551) Bumps [actions/cache](https://github.com/actions/cache) from 5.0.5 to 6.1.0. - [Release notes](https://github.com/actions/cache/releases) - [Changelog](https://github.com/actions/cache/blob/main/RELEASES.md) - [Commits](https://github.com/actions/cache/compare/27d5ce7f107fe9357f9df03efb73ab90386fccae...55cc8345863c7cc4c66a329aec7e433d2d1c52a9) --- updated-dependencies: - dependency-name: actions/cache dependency-version: 6.1.0 dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> * chore(deps): bump anchore/go-make/.github/actions/setup (#3555) Bumps [anchore/go-make/.github/actions/setup](https://github.com/anchore/go-make) from 0.6.0 to 0.8.0. - [Release notes](https://github.com/anchore/go-make/releases) - [Commits](https://github.com/anchore/go-make/compare/39fe5f71112d4dceb3ff0a92a40f272f067fc457...430e2175bb166bfd138ef75466adbcc8ddab875f) --- updated-dependencies: - dependency-name: anchore/go-make/.github/actions/setup dependency-version: 0.8.0 dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> * chore(deps): bump actions/cache in /.github/actions/bootstrap (#3558) Bumps [actions/cache](https://github.com/actions/cache) from 5.0.5 to 6.1.0. - [Release notes](https://github.com/actions/cache/releases) - [Changelog](https://github.com/actions/cache/blob/main/RELEASES.md) - [Commits](https://github.com/actions/cache/compare/27d5ce7f107fe9357f9df03efb73ab90386fccae...55cc8345863c7cc4c66a329aec7e433d2d1c52a9) --- updated-dependencies: - dependency-name: actions/cache dependency-version: 6.1.0 dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> * chore(deps): bump gorm.io/gorm from 1.31.1 to 1.31.2 (#3557) Bumps [gorm.io/gorm](https://github.com/go-gorm/gorm) from 1.31.1 to 1.31.2. - [Release notes](https://github.com/go-gorm/gorm/releases) - [Commits](https://github.com/go-gorm/gorm/compare/v1.31.1...v1.31.2) --- updated-dependencies: - dependency-name: gorm.io/gorm dependency-version: 1.31.2 dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --------- Signed-off-by: Will Murphy Signed-off-by: anchore-oss-update-bot Signed-off-by: Brandt Keller Signed-off-by: Jeremy Spilman Signed-off-by: dependabot[bot] Signed-off-by: Weston Steimel Signed-off-by: Alex Goodman Co-authored-by: Will Murphy Co-authored-by: anchore-oss-update-bot Co-authored-by: anchore-oss-update-bot Co-authored-by: Brandt Keller <43887158+brandtkeller@users.noreply.github.com> Co-authored-by: Jeremy Spilman Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: Weston Steimel Co-authored-by: Alex Goodman Signed-off-by: Janit Lodha --- .binny.yaml | 2 +- .github/actions/bootstrap/action.yaml | 6 +- .github/dependabot.yaml | 25 - .github/workflows/codeql.yaml | 2 +- .github/workflows/release.yaml | 28 +- .github/workflows/scorecards.yaml | 4 +- .../workflows/validate-github-actions.yaml | 4 +- .github/workflows/validations.yaml | 24 +- .github/zizmor.yml | 7 +- .golangci.yaml | 9 +- .make/go.mod | 6 +- .make/go.sum | 12 +- .make/main.go | 28 + cmd/grype/cli/commands/root.go | 2 + cmd/grype/cli/commands/root_test.go | 6 +- cmd/grype/cli/options/match.go | 2 +- go.mod | 86 +- go.sum | 192 +++-- .../provider/unmarshal/os_vulnerability.go | 9 +- .../provider/unmarshal/osv_vulnerability.go | 4 +- .../provider/unmarshal/osvmodel/generate.go | 7 + .../unmarshal/osvmodel/generate/main.go | 814 ++++++++++++++++++ .../unmarshal/osvmodel/schema-v1.json | 502 +++++++++++ .../provider/unmarshal/osvmodel/schema-v1.tag | 1 + .../osvmodel/vulnerability_generated.go | 112 +++ grype/db/provider/workspace.go | 15 +- grype/db/v6/architecture_alias_store.go | 42 + grype/db/v6/architecture_alias_store_test.go | 31 + grype/db/v6/blobs.go | 9 +- .../build/transformers/csafvex/transform.go | 31 +- .../transformers/csafvex/transform_test.go | 24 +- .../db/v6/build/transformers/os/transform.go | 15 + .../build/transformers/os/transform_test.go | 41 + grype/db/v6/build/transformers/osv/helpers.go | 86 +- .../osv/testdata/GO-2020-0001.json | 77 ++ .../osv/testdata/GO-2022-0969.json | 123 +++ .../db/v6/build/transformers/osv/transform.go | 1 + .../build/transformers/osv/transform_alma.go | 17 +- .../transformers/osv/transform_alma_test.go | 35 +- .../transformers/osv/transform_bitnami.go | 11 +- .../osv/transform_bitnami_test.go | 35 +- .../transformers/osv/transform_go_vuln_db.go | 320 +++++++ .../osv/transform_go_vuln_db_test.go | 279 ++++++ .../transformers/osv/transform_rootio.go | 11 +- .../transformers/osv/transform_rootio_test.go | 12 +- grype/db/v6/build/writer.go | 51 +- grype/db/v6/build/writer_test.go | 73 ++ grype/db/v6/data.go | 21 + grype/db/v6/db.go | 7 +- grype/db/v6/models.go | 17 + grype/db/v6/store.go | 7 + grype/db/v6/vulnerability.go | 37 +- grype/db/v6/vulnerability_provider.go | 22 +- grype/distro/distro.go | 5 +- grype/distro/distro_test.go | 12 + .../packagemetadata/discover_type_names.go | 1 + grype/matcher/golang/govulndb_test.go | 147 ++++ grype/matcher/golang/matcher_test.go | 50 ++ .../golang/testdata/govulndb-go/db-lock.json | 11 + .../golang/testdata/govulndb-go/db.yaml | 5 + .../govulndb-go/govulndb/metadata.json | 19 + .../govulndb/results/go-2022-0969.json | 154 ++++ .../govulndb/results/go-2023-1840.json | 88 ++ .../internal/source_or_unspecified_arch.go | 35 - .../source_or_unspecified_arch_test.go | 67 -- grype/matcher/rpm/almalinux.go | 1 - grype/matcher/rpm/almalinux_test.go | 13 + grype/matcher/rpm/hummingbird_test.go | 21 +- grype/matcher/rpm/matcher.go | 16 +- grype/matcher/rpm/matcher_test.go | 20 + grype/matcher/rpm/oracle_arch_test.go | 51 ++ grype/matcher/rpm/rhel_eus.go | 4 +- grype/matcher/rpm/rhel_eus_test.go | 8 + grype/matcher/rpm/rhel_multi_rhsa_test.go | 140 +++ grype/matcher/rpm/sles_test.go | 6 + grype/matcher/rpm/testdata/oracle/db.yaml | 4 + .../rpm/testdata/oracle/oracle/metadata.json | 19 + .../oracle/results/ol@7/elsa-2022-4803.json | 58 ++ .../rpm/testdata/rhel-multi-rhsa/db.yaml | 7 + .../rhel-multi-rhsa/rhel/metadata.json | 18 + .../rhel/results/rhel@9/cve-2024-8088.json | 38 + grype/pkg/package.go | 47 +- grype/pkg/package_test.go | 26 +- grype/pkg/provider.go | 13 + grype/pkg/purl_provider_test.go | 7 + grype/pkg/qualifier/architecture/qualifier.go | 185 +++- .../qualifier/architecture/qualifier_test.go | 352 +++++++- grype/pkg/rpm_metadata.go | 6 + grype/pkg/upstream_package.go | 14 + grype/pkg/zarf_provider.go | 229 +++++ grype/pkg/zarf_provider_test.go | 252 ++++++ .../snapshot/TestCycloneDxPresenterDir.golden | 4 +- .../TestCycloneDxPresenterImage.golden | 4 +- grype/presenter/internal/test_helpers.go | 1 + .../snapshot/TestJsonDirsPresenter.golden | 5 +- .../snapshot/TestJsonImgsPresenter.golden | 5 +- grype/presenter/models/package.go | 26 +- grype/presenter/models/source.go | 5 + internal/dbtest/builder_test.go | 41 + internal/dbtest/package.go | 19 +- test/quality/test-db | 2 +- 101 files changed, 5048 insertions(+), 557 deletions(-) create mode 100644 grype/db/internal/provider/unmarshal/osvmodel/generate.go create mode 100644 grype/db/internal/provider/unmarshal/osvmodel/generate/main.go create mode 100644 grype/db/internal/provider/unmarshal/osvmodel/schema-v1.json create mode 100644 grype/db/internal/provider/unmarshal/osvmodel/schema-v1.tag create mode 100644 grype/db/internal/provider/unmarshal/osvmodel/vulnerability_generated.go create mode 100644 grype/db/v6/architecture_alias_store.go create mode 100644 grype/db/v6/architecture_alias_store_test.go create mode 100644 grype/db/v6/build/transformers/osv/testdata/GO-2020-0001.json create mode 100644 grype/db/v6/build/transformers/osv/testdata/GO-2022-0969.json create mode 100644 grype/db/v6/build/transformers/osv/transform_go_vuln_db.go create mode 100644 grype/db/v6/build/transformers/osv/transform_go_vuln_db_test.go create mode 100644 grype/matcher/golang/govulndb_test.go create mode 100644 grype/matcher/golang/testdata/govulndb-go/db-lock.json create mode 100644 grype/matcher/golang/testdata/govulndb-go/db.yaml create mode 100644 grype/matcher/golang/testdata/govulndb-go/govulndb/metadata.json create mode 100644 grype/matcher/golang/testdata/govulndb-go/govulndb/results/go-2022-0969.json create mode 100644 grype/matcher/golang/testdata/govulndb-go/govulndb/results/go-2023-1840.json delete mode 100644 grype/matcher/internal/source_or_unspecified_arch.go delete mode 100644 grype/matcher/internal/source_or_unspecified_arch_test.go create mode 100644 grype/matcher/rpm/oracle_arch_test.go create mode 100644 grype/matcher/rpm/rhel_multi_rhsa_test.go create mode 100644 grype/matcher/rpm/testdata/oracle/db.yaml create mode 100644 grype/matcher/rpm/testdata/oracle/oracle/metadata.json create mode 100644 grype/matcher/rpm/testdata/oracle/oracle/results/ol@7/elsa-2022-4803.json create mode 100644 grype/matcher/rpm/testdata/rhel-multi-rhsa/db.yaml create mode 100644 grype/matcher/rpm/testdata/rhel-multi-rhsa/rhel/metadata.json create mode 100644 grype/matcher/rpm/testdata/rhel-multi-rhsa/rhel/results/rhel@9/cve-2024-8088.json create mode 100644 grype/pkg/zarf_provider.go create mode 100644 grype/pkg/zarf_provider_test.go diff --git a/.binny.yaml b/.binny.yaml index adfe077d45d..f5403051bd9 100644 --- a/.binny.yaml +++ b/.binny.yaml @@ -7,7 +7,7 @@ tools: # used for integration tests - name: skopeo version: - want: v1.22.2 + want: v1.23.0 method: go-install with: module: github.com/containers/skopeo diff --git a/.github/actions/bootstrap/action.yaml b/.github/actions/bootstrap/action.yaml index a3195ec77dd..f67a7cc5317 100644 --- a/.github/actions/bootstrap/action.yaml +++ b/.github/actions/bootstrap/action.yaml @@ -29,7 +29,7 @@ inputs: runs: using: "composite" steps: - - uses: anchore/go-make/.github/actions/setup@9de27be11ed73e2f9d5406a836a492b7d8aa1225 # v0.5.0 + - uses: anchore/go-make/.github/actions/setup@430e2175bb166bfd138ef75466adbcc8ddab875f # v0.8.0 with: go-version: ${{ inputs.go-version }} cache-key-prefix: ${{ inputs.cache-key-prefix }} @@ -51,7 +51,7 @@ runs: # restore-only on non-default branches and forks to prevent untrusted runs from polluting the cache - name: Restore test fixture cache id: test-fixture-cache-restore - uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 + uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 if: ${{ inputs.cache-test-fixtures == 'true' && (github.ref != format('refs/heads/{0}', github.event.repository.default_branch) || github.event.repository.fork == true) }} with: path: | @@ -63,7 +63,7 @@ runs: # read+write only on the default branch of the upstream repo - name: Restore and save test fixture cache id: test-fixture-cache - uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 + uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 if: ${{ inputs.cache-test-fixtures == 'true' && github.ref == format('refs/heads/{0}', github.event.repository.default_branch) && github.event.repository.fork == false }} with: path: | diff --git a/.github/dependabot.yaml b/.github/dependabot.yaml index c7097b2b863..d5239004504 100644 --- a/.github/dependabot.yaml +++ b/.github/dependabot.yaml @@ -1,13 +1,4 @@ # Dependabot configuration -# -# Grouping behavior (see inline comments for details): -# - Minor + patch updates: grouped into a single PR per ecosystem -# - Major version bumps: individual PR per dependency -# - Security updates: individual PR per dependency -# -# Note: "patch" refers to semver version bumps (1.2.3 -> 1.2.4), not security fixes. -# Security updates are identified separately via GitHub's Advisory Database and -# can be any version bump (patch, minor, or major) that fixes a known CVE. version: 2 @@ -30,14 +21,6 @@ updates: - dependency-name: "github.com/aquasecurity/go-version" - dependency-name: "github.com/knqyf263/go-apk-version" - dependency-name: "github.com/knqyf263/go-deb-version" - groups: - go-minor-patch: - applies-to: version-updates # security updates get individual PRs - patterns: - - "*" - update-types: # major omitted, gets individual PRs - - "minor" - - "patch" - package-ecosystem: "github-actions" directories: @@ -51,11 +34,3 @@ updates: open-pull-requests-limit: 10 labels: - "dependencies" - groups: - actions-minor-patch: - applies-to: version-updates # security updates get individual PRs - patterns: - - "*" - update-types: # major omitted, gets individual PRs - - "minor" - - "patch" diff --git a/.github/workflows/codeql.yaml b/.github/workflows/codeql.yaml index 7a0a5654f40..022c2b9cb99 100644 --- a/.github/workflows/codeql.yaml +++ b/.github/workflows/codeql.yaml @@ -13,7 +13,7 @@ permissions: {} jobs: analyze: name: Analyze - uses: anchore/workflows/.github/workflows/codeql.yaml@b3e328b5ae31ba96297e2ed9a6124e5e6352a4c5 # v0.7.0 + uses: anchore/workflows/.github/workflows/codeql.yaml@7212994dc8fc3a53fe9c8e766ab5b4ddd16ea3d4 # v0.8.0 permissions: security-events: write packages: read diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml index 785e6e63d7f..55d6189f11d 100644 --- a/.github/workflows/release.yaml +++ b/.github/workflows/release.yaml @@ -13,20 +13,26 @@ on: version: description: tag the latest commit on main with the given version (prefixed with v) required: true + skip-checks: + description: skip the check-gate (release even if checks haven't passed on main) + type: boolean + default: false + required: false jobs: version-available: permissions: contents: read # for the most recent tags - uses: anchore/workflows/.github/workflows/check-version-available.yaml@b3e328b5ae31ba96297e2ed9a6124e5e6352a4c5 # v0.7.0 + uses: anchore/workflows/.github/workflows/check-version-available.yaml@7212994dc8fc3a53fe9c8e766ab5b4ddd16ea3d4 # v0.8.0 with: version: ${{ github.event.inputs.version }} check-gate: + if: ${{ !inputs.skip-checks }} permissions: contents: read checks: read # required for getting the status of specific check names - uses: anchore/workflows/.github/workflows/check-gate.yaml@b3e328b5ae31ba96297e2ed9a6124e5e6352a4c5 # v0.7.0 + uses: anchore/workflows/.github/workflows/check-gate.yaml@b0c30a80409130d329aaa356fd64a34d8c0b3375 # v0.7.2 with: # these are checks that should be run on pull-request and merges to main. # we do NOT want to kick off a release if these have not been verified on main. @@ -39,6 +45,14 @@ jobs: # not all actions are guaranteed to be idempotent. release: needs: [check-gate, version-available] + # run even when check-gate is skipped, but never when version-available + # failed/was skipped, nor when check-gate failed or was cancelled. note: + # always() disables the implicit success() gate on ALL needs, so the + # version-available requirement must be re-asserted explicitly here. + if: >- + ${{ always() + && needs.version-available.result == 'success' + && !contains(fromJSON('["failure", "cancelled"]'), needs.check-gate.result) }} environment: release runs-on: ubuntu-24.04 permissions: @@ -46,7 +60,7 @@ jobs: packages: write id-token: write steps: - - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd #v6.0.2 + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 #v7.0.0 with: fetch-depth: 0 persist-credentials: true @@ -73,8 +87,8 @@ jobs: - name: Build & publish release artifacts run: make ci-release env: - # for pushing tags (requires write access to content, but not packages) - DEPLOY_KEY: ${{ secrets.DEPLOY_KEY }} + # for pushing tags (does not inherit workflow permissions) + TAG_TOKEN: ${{ secrets.TAG_TOKEN }} RELEASE_VERSION: ${{ github.event.inputs.version }} # for mac signing and notarization... QUILL_SIGN_P12: ${{ secrets.ANCHORE_APPLE_DEVELOPER_ID_CERT_CHAIN }} @@ -95,7 +109,9 @@ jobs: release-install-script: needs: [release] if: ${{ needs.release.result == 'success' }} - uses: anchore/workflows/.github/workflows/release-install-script.yaml@b3e328b5ae31ba96297e2ed9a6124e5e6352a4c5 # v0.7.0 + permissions: + contents: read + uses: anchore/workflows/.github/workflows/release-install-script.yaml@b0c30a80409130d329aaa356fd64a34d8c0b3375 # v0.7.2 with: tag: ${{ github.event.inputs.version }} secrets: diff --git a/.github/workflows/scorecards.yaml b/.github/workflows/scorecards.yaml index c3273b064b5..807fb18170f 100644 --- a/.github/workflows/scorecards.yaml +++ b/.github/workflows/scorecards.yaml @@ -26,7 +26,7 @@ jobs: steps: - name: "Checkout code" - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: persist-credentials: false @@ -44,6 +44,6 @@ jobs: # Upload the results to GitHub's code scanning dashboard. - name: "Upload to code-scanning" - uses: github/codeql-action/upload-sarif@7211b7c8077ea37d8641b6271f6a365a22a5fbfa # v4.36.0 + uses: github/codeql-action/upload-sarif@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # v4.36.2 with: sarif_file: results.sarif diff --git a/.github/workflows/validate-github-actions.yaml b/.github/workflows/validate-github-actions.yaml index 9810f60f552..6aaa5b82cce 100644 --- a/.github/workflows/validate-github-actions.yaml +++ b/.github/workflows/validate-github-actions.yaml @@ -20,12 +20,12 @@ jobs: contents: read security-events: write # for uploading SARIF results steps: - - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: persist-credentials: false - name: "Run zizmor" - uses: zizmorcore/zizmor-action@5f14fd08f7cf1cb1609c1e344975f152c7ee938d # v0.5.6 + uses: zizmorcore/zizmor-action@192e21d79ab29983730a13d1382995c2307fbcaa # v0.5.7 with: # there is a pass/fail gate as a repo ruleset (if there is no ruleset configured then the action will pass by default) advanced-security: true diff --git a/.github/workflows/validations.yaml b/.github/workflows/validations.yaml index ec3e148f921..b0077b37eaa 100644 --- a/.github/workflows/validations.yaml +++ b/.github/workflows/validations.yaml @@ -17,7 +17,7 @@ jobs: permissions: contents: read steps: - - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: persist-credentials: false @@ -34,7 +34,7 @@ jobs: permissions: contents: read steps: - - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: persist-credentials: false @@ -53,7 +53,7 @@ jobs: permissions: contents: read steps: - - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: submodules: true persist-credentials: false @@ -114,7 +114,7 @@ jobs: permissions: contents: read steps: - - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: persist-credentials: false @@ -124,7 +124,7 @@ jobs: cache-test-fixtures: true - name: Restore integration test cache - uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae #v5.0.5 + uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 #v6.1.0 with: path: ${{ github.workspace }}/test/integration/testdata/cache key: ${{ runner.os }}-integration-test-cache-${{ hashFiles('test/integration/testdata/cache.fingerprint') }} @@ -138,7 +138,7 @@ jobs: permissions: contents: read steps: - - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: persist-credentials: false @@ -171,7 +171,7 @@ jobs: permissions: contents: read steps: - - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd #v6.0.2 + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 #v7.0.0 with: persist-credentials: false @@ -193,7 +193,7 @@ jobs: - name: Restore install.sh test image cache id: install-test-image-cache - uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae #v5.0.5 + uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 #v6.1.0 with: path: ${{ github.workspace }}/test/install/cache key: ${{ runner.os }}-install-test-image-cache-${{ hashFiles('test/install/cache.fingerprint') }} @@ -220,7 +220,7 @@ jobs: - name: Install Cosign uses: sigstore/cosign-installer@6f9f17788090df1f26f669e9d70d6ae9567deba6 #v4.1.2 - - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd #v6.0.2 + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 #v7.0.0 with: persist-credentials: false @@ -242,7 +242,7 @@ jobs: - name: Restore docker image cache for compare testing id: mac-compare-testing-cache - uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae #v5.0.5 + uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 #v6.1.0 with: path: image.tar key: ${{ runner.os }}-${{ hashFiles('test/compare/mac.sh') }} @@ -259,7 +259,7 @@ jobs: permissions: contents: read steps: - - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd #v6.0.2 + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 #v7.0.0 with: persist-credentials: false @@ -269,7 +269,7 @@ jobs: cache-test-fixtures: true - name: Restore CLI test-fixture cache - uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae #v5.0.5 + uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 #v6.1.0 with: path: ${{ github.workspace }}/test/cli/testdata/cache key: ${{ runner.os }}-cli-test-cache-${{ hashFiles('test/cli/testdata/cache.fingerprint') }} diff --git a/.github/zizmor.yml b/.github/zizmor.yml index c01c279c392..550e33a7cc5 100644 --- a/.github/zizmor.yml +++ b/.github/zizmor.yml @@ -1,6 +1 @@ -rules: - unpinned-uses: - config: - policies: - # anchore/workflows is an internal repository; using @main is acceptable - anchore/*: any +rules: {} diff --git a/.golangci.yaml b/.golangci.yaml index abc84ac47df..b84307fbeba 100644 --- a/.golangci.yaml +++ b/.golangci.yaml @@ -86,8 +86,15 @@ issues: formatters: enable: + - gci - gofmt - - goimports + settings: + gci: + # See https://golangci-lint.run/docs/formatters/configuration/#gci + sections: + - standard # Standard section: captures all standard packages. + - default # Default section: contains all imports that could not be matched to another section type. + - prefix(github.com/anchore) exclusions: generated: lax paths: diff --git a/.make/go.mod b/.make/go.mod index 327118e4d9b..6fb91346716 100644 --- a/.make/go.mod +++ b/.make/go.mod @@ -2,11 +2,11 @@ module github.com/anchore/grype/.make go 1.25.0 -require github.com/anchore/go-make v0.5.0 +require github.com/anchore/go-make v0.8.0 require ( github.com/bmatcuk/doublestar/v4 v4.10.0 // indirect github.com/goccy/go-yaml v1.19.2 // indirect - golang.org/x/mod v0.35.0 // indirect - golang.org/x/sys v0.44.0 // indirect + golang.org/x/mod v0.37.0 // indirect + golang.org/x/sys v0.46.0 // indirect ) diff --git a/.make/go.sum b/.make/go.sum index 76efae7306c..72d002cfcae 100644 --- a/.make/go.sum +++ b/.make/go.sum @@ -1,10 +1,10 @@ -github.com/anchore/go-make v0.5.0 h1:VGlwqVhzowFb+9w/gaWUIid/YXvQZReBWKcj4LaZ3dM= -github.com/anchore/go-make v0.5.0/go.mod h1:Nc/tkwQHW1d1Vi8+0rtS/vSrH6pxieaUQXLdrctn+8g= +github.com/anchore/go-make v0.8.0 h1:3XAD4WNHIbqY4kwFyCQJ7kKiYLzSzjB0UwzDVwRqlUY= +github.com/anchore/go-make v0.8.0/go.mod h1:4M6TnArb5w693VyWsgr5dCWrk2BLNu/ed4JUcsrzS34= github.com/bmatcuk/doublestar/v4 v4.10.0 h1:zU9WiOla1YA122oLM6i4EXvGW62DvKZVxIe6TYWexEs= github.com/bmatcuk/doublestar/v4 v4.10.0/go.mod h1:xBQ8jztBU6kakFMg+8WGxn0c6z1fTSPVIjEY1Wr7jzc= github.com/goccy/go-yaml v1.19.2 h1:PmFC1S6h8ljIz6gMRBopkjP1TVT7xuwrButHID66PoM= github.com/goccy/go-yaml v1.19.2/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA= -golang.org/x/mod v0.35.0 h1:Ww1D637e6Pg+Zb2KrWfHQUnH2dQRLBQyAtpr/haaJeM= -golang.org/x/mod v0.35.0/go.mod h1:+GwiRhIInF8wPm+4AoT6L0FA1QWAad3OMdTRx4tFYlU= -golang.org/x/sys v0.44.0 h1:ildZl3J4uzeKP07r2F++Op7E9B29JRUy+a27EibtBTQ= -golang.org/x/sys v0.44.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/mod v0.37.0 h1:vF1DjpVEshcIqoEaauuHebaLk1O1forxjxBaVn884JQ= +golang.org/x/mod v0.37.0/go.mod h1:m8S8VeM9r4dzDwjrKO0a1sZP3YjeMamRRlD+fmR2Q/0= +golang.org/x/sys v0.46.0 h1:noSf2Fq6F8DBgS+LysIkx7rIExoNHJsxOAtPp4rthXw= +golang.org/x/sys v0.46.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= diff --git a/.make/main.go b/.make/main.go index 1832807a109..7f3091b736f 100644 --- a/.make/main.go +++ b/.make/main.go @@ -36,6 +36,7 @@ func main() { qualityTask(), generateTask(), + updateOSVModelTask(), checkJSONSchemaDriftTask(), checkDBSchemaDriftTask(), checkSyftVersionIsReleaseTask(), @@ -145,6 +146,7 @@ func generateTask() Task { "generate:json-schema", "generate:db-schema", "generate:codename-data", + "generate:osv-model", ), Tasks: []Task{ { @@ -172,6 +174,14 @@ func generateTask() Task { Run("go generate ./grype/db") }, }, + { + Name: "generate:osv-model", + Description: "regenerate the OSV model from the pinned upstream JSON schema " + + "(use `update:osv-model` to also fetch latest from github.com/ossf/osv-schema)", + Run: func() { + Run("go generate ./grype/db/internal/provider/unmarshal/osvmodel/...") + }, + }, }, } } @@ -210,6 +220,24 @@ func checkDBSchemaDriftTask() Task { } } +// updateOSVModelTask fetches the latest v1 OSV schema from upstream +// (github.com/ossf/osv-schema), writes schema-v1.json + schema-v1.tag, and +// regenerates vulnerability_generated.go. This is the cron-driven update +// counterpart to the offline `generate:osv-model`. Producing a diff is the +// expected outcome when upstream has moved; oss-release wraps this task to +// open a PR with that diff. +func updateOSVModelTask() Task { + return Task{ + Name: "update:osv-model", + Description: "fetch latest v1 OSV schema from github.com/ossf/osv-schema and regenerate the model", + Run: func() { + file.InDir("grype/db/internal/provider/unmarshal/osvmodel/generate", func() { + Run("go run . --pull") + }) + }, + } +} + // checkSyftVersionIsReleaseTask ensures go.mod pins a released syft tag (not a // commit hash or pseudo-version). Gates releases so we don't ship grype against // an unreleased syft. diff --git a/cmd/grype/cli/commands/root.go b/cmd/grype/cli/commands/root.go index ea908a2a737..0f1dcbd6716 100644 --- a/cmd/grype/cli/commands/root.go +++ b/cmd/grype/cli/commands/root.go @@ -73,6 +73,7 @@ You can also explicitly specify the scheme to use: {{.appName}} PURL read a single package PURL directly (e.g. pkg:apk/openssl@3.2.1?distro=alpine-3.20.3) {{.appName}} cpes:path/to/cpes/file read a newline separated file of package CPEs from a path on disk {{.appName}} CPE read a single CPE directly (e.g. cpe:2.3:a:openssl:openssl:3.0.14:*:*:*:*:*) + {{.appName}} zarf:path/to/package.tar.zst scan all SBOMs within a Zarf package archive You can also pipe in Syft JSON directly: syft yourimage:tag -o json | {{.appName}} @@ -113,6 +114,7 @@ var ignoreLinuxKernelHeaders = []match.IgnoreRule{ {Package: match.IgnoreRulePackage{Name: "kernel-headers", UpstreamName: "kernel", Type: string(syftPkg.RpmPkg)}, MatchType: match.ExactIndirectMatch}, {Package: match.IgnoreRulePackage{Name: "linux(-.*)?-headers-.*", UpstreamName: "linux.*", Type: string(syftPkg.DebPkg)}, MatchType: match.ExactIndirectMatch}, {Package: match.IgnoreRulePackage{Name: "linux-libc-dev", UpstreamName: "linux", Type: string(syftPkg.DebPkg)}, MatchType: match.ExactIndirectMatch}, + {Package: match.IgnoreRulePackage{Name: "linux-kbuild-.*", UpstreamName: "linux.*", Type: string(syftPkg.DebPkg)}, MatchType: match.ExactIndirectMatch}, } //nolint:funlen diff --git a/cmd/grype/cli/commands/root_test.go b/cmd/grype/cli/commands/root_test.go index ba3e362ac1f..4e5b5e99bf6 100644 --- a/cmd/grype/cli/commands/root_test.go +++ b/cmd/grype/cli/commands/root_test.go @@ -112,7 +112,7 @@ func Test_getMatcherConfig(t *testing.T) { Javascript: javascript.MatcherConfig{}, Golang: golang.MatcherConfig{ UseCPEs: false, - AlwaysUseCPEForStdlib: true, + AlwaysUseCPEForStdlib: false, AllowMainModulePseudoVersionComparison: false, }, Hex: hex.MatcherConfig{}, @@ -147,7 +147,7 @@ func Test_getMatcherConfig(t *testing.T) { Javascript: javascript.MatcherConfig{}, Golang: golang.MatcherConfig{ UseCPEs: false, - AlwaysUseCPEForStdlib: true, + AlwaysUseCPEForStdlib: false, AllowMainModulePseudoVersionComparison: false, }, Hex: hex.MatcherConfig{}, @@ -182,7 +182,7 @@ func Test_getMatcherConfig(t *testing.T) { Javascript: javascript.MatcherConfig{}, Golang: golang.MatcherConfig{ UseCPEs: false, - AlwaysUseCPEForStdlib: true, + AlwaysUseCPEForStdlib: false, AllowMainModulePseudoVersionComparison: false, }, Hex: hex.MatcherConfig{}, diff --git a/cmd/grype/cli/options/match.go b/cmd/grype/cli/options/match.go index b21becb72b7..daa8dd6f9f5 100644 --- a/cmd/grype/cli/options/match.go +++ b/cmd/grype/cli/options/match.go @@ -93,7 +93,7 @@ func defaultGolangConfig() golangConfig { matcherConfig: matcherConfig{ UseCPEs: false, }, - AlwaysUseCPEForStdlib: true, + AlwaysUseCPEForStdlib: false, AllowMainModulePseudoVersionComparison: false, } } diff --git a/go.mod b/go.mod index 989ef782aa8..861cb8a3fed 100644 --- a/go.mod +++ b/go.mod @@ -9,16 +9,16 @@ require ( github.com/OneOfOne/xxhash v1.2.8 github.com/acarl005/stripansi v0.0.0-20180116102854-5a71ef0e047d github.com/adrg/xdg v0.5.3 - github.com/anchore/bubbly v0.2.0 - github.com/anchore/clio v0.1.0 - github.com/anchore/fangs v0.1.0 - github.com/anchore/go-collections v0.1.0 - github.com/anchore/go-homedir v0.1.0 - github.com/anchore/go-logger v0.1.0 + github.com/anchore/bubbly v0.2.1 + github.com/anchore/clio v0.1.1 + github.com/anchore/fangs v0.1.1 + github.com/anchore/go-collections v0.1.1 + github.com/anchore/go-homedir v0.1.1 + github.com/anchore/go-logger v0.1.1 github.com/anchore/go-version v1.2.2-0.20210903204242-51efa5b487c4 github.com/anchore/packageurl-go v0.2.0 - github.com/anchore/stereoscope v0.2.0 - github.com/anchore/syft v1.45.0 + github.com/anchore/stereoscope v0.2.2 + github.com/anchore/syft v1.46.0 github.com/aquasecurity/go-pep440-version v0.0.1 github.com/araddon/dateparse v0.0.0-20210429162001-6b43995a97de github.com/bitnami/go-version v0.0.0-20250505154626-452e8c5ee607 @@ -30,15 +30,14 @@ require ( github.com/dustin/go-humanize v1.0.1 github.com/facebookincubator/nvdtools v0.1.5 github.com/gabriel-vasile/mimetype v1.4.13 - github.com/gkampitakis/go-snaps v0.5.21 + github.com/gkampitakis/go-snaps v0.5.22 github.com/glebarez/sqlite v1.11.0 github.com/go-test/deep v1.1.1 github.com/go-viper/mapstructure/v2 v2.5.0 github.com/gocsaf/csaf/v3 v3.5.1 github.com/gohugoio/hashstructure v0.6.0 github.com/google/go-cmp v0.7.0 - github.com/google/go-containerregistry v0.21.6 - github.com/google/osv-scanner v1.9.2 + github.com/google/go-containerregistry v0.21.7 github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510 github.com/google/uuid v1.6.0 github.com/gookit/color v1.6.1 @@ -72,11 +71,11 @@ require ( github.com/wagoodman/go-progress v0.0.0-20260303201901-10176f79b2c0 github.com/xi2/xz v0.0.0-20171230120015-48954b6210f8 golang.org/x/exp v0.0.0-20260410095643-746e56fc9e2f - golang.org/x/text v0.37.0 + golang.org/x/text v0.38.0 golang.org/x/time v0.15.0 - golang.org/x/tools v0.45.0 + golang.org/x/tools v0.47.0 gopkg.in/yaml.v3 v3.0.1 - gorm.io/gorm v1.31.1 + gorm.io/gorm v1.31.2 ) require ( @@ -104,15 +103,15 @@ require ( github.com/Masterminds/goutils v1.1.1 // indirect github.com/Microsoft/go-winio v0.6.3-0.20251027160822-ad3df93bed29 // indirect github.com/Microsoft/hcsshim v0.15.0-rc.1 // indirect - github.com/ProtonMail/go-crypto v1.4.0 // indirect + github.com/ProtonMail/go-crypto v1.4.1 // indirect github.com/STARRY-S/zip v0.2.3 // indirect github.com/acobaugh/osrelease v0.1.0 // indirect github.com/agext/levenshtein v1.2.3 // indirect - github.com/anchore/go-lzo v0.1.0 // indirect - github.com/anchore/go-macholibre v0.1.0 // indirect + github.com/anchore/go-lzo v0.1.1 // indirect + github.com/anchore/go-macholibre v0.1.1 // indirect github.com/anchore/go-rpmdb v0.1.0 // indirect - github.com/anchore/go-struct-converter v0.1.0 // indirect - github.com/anchore/go-sync v0.1.0 // indirect + github.com/anchore/go-struct-converter v0.2.0-rc2 // indirect + github.com/anchore/go-sync v0.1.1 // indirect github.com/andybalholm/brotli v1.2.0 // indirect github.com/apparentlymart/go-textseg/v15 v15.0.0 // indirect github.com/aquasecurity/go-version v0.0.1 // indirect @@ -146,18 +145,18 @@ require ( github.com/buger/jsonparser v1.1.2 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect github.com/charmbracelet/bubbles v1.0.0 // indirect - github.com/charmbracelet/colorprofile v0.4.1 // indirect + github.com/charmbracelet/colorprofile v0.4.3 // indirect github.com/charmbracelet/harmonica v0.2.0 // indirect github.com/charmbracelet/x/ansi v0.11.6 // indirect github.com/charmbracelet/x/cellbuf v0.0.15 // indirect github.com/charmbracelet/x/term v0.2.2 // indirect - github.com/clipperhouse/displaywidth v0.10.0 // indirect - github.com/clipperhouse/uax29/v2 v2.6.0 // indirect + github.com/clipperhouse/displaywidth v0.11.0 // indirect + github.com/clipperhouse/uax29/v2 v2.7.0 // indirect github.com/cloudflare/circl v1.6.3 // indirect github.com/cncf/xds/go v0.0.0-20251210132809-ee656c7534f5 // indirect github.com/containerd/cgroups/v3 v3.1.3 // indirect github.com/containerd/containerd/api v1.11.1 // indirect - github.com/containerd/containerd/v2 v2.3.1 // indirect + github.com/containerd/containerd/v2 v2.3.2 // indirect github.com/containerd/continuity v0.5.0 // indirect github.com/containerd/errdefs v1.0.0 // indirect github.com/containerd/errdefs/pkg v0.3.0 // indirect @@ -170,9 +169,9 @@ require ( github.com/cyphar/filepath-securejoin v0.6.1 // indirect github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect github.com/deitch/magic v0.0.0-20240306090643-c67ab88f10cb // indirect - github.com/diskfs/go-diskfs v1.7.0 // indirect + github.com/diskfs/go-diskfs v1.9.3 // indirect github.com/distribution/reference v0.6.0 // indirect - github.com/docker/cli v29.4.3+incompatible // indirect + github.com/docker/cli v29.5.3+incompatible // indirect github.com/docker/docker-credential-helpers v0.9.5 // indirect github.com/docker/go-connections v0.7.0 // indirect github.com/docker/go-units v0.5.0 // indirect @@ -186,8 +185,8 @@ require ( github.com/felixge/fgprof v0.9.5 // indirect github.com/felixge/httpsnoop v1.0.4 // indirect github.com/fsnotify/fsnotify v1.9.0 // indirect - github.com/github/go-spdx/v2 v2.4.0 // indirect - github.com/gkampitakis/ciinfo v0.3.2 // indirect + github.com/github/go-spdx/v2 v2.7.0 // indirect + github.com/gkampitakis/ciinfo v0.3.4 // indirect github.com/glebarez/go-sqlite v1.22.0 // indirect github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 // indirect github.com/go-git/go-billy/v5 v5.9.0 // indirect @@ -204,7 +203,7 @@ require ( github.com/google/s2a-go v0.1.9 // indirect github.com/googleapis/enterprise-certificate-proxy v0.3.14 // indirect github.com/googleapis/gax-go/v2 v2.17.0 // indirect - github.com/gpustack/gguf-parser-go v0.24.0 // indirect + github.com/gpustack/gguf-parser-go v0.24.1 // indirect github.com/hashicorp/aws-sdk-go-base/v2 v2.0.0-beta.72 // indirect github.com/hashicorp/errwrap v1.1.0 // indirect github.com/hashicorp/go-version v1.8.0 // indirect @@ -225,11 +224,11 @@ require ( github.com/kr/pretty v0.3.1 // indirect github.com/kr/text v0.2.0 // indirect github.com/lucasb-eyer/go-colorful v1.3.0 // indirect - github.com/maruel/natural v1.1.1 // indirect + github.com/maruel/natural v1.3.0 // indirect github.com/mattn/go-colorable v0.1.14 // indirect github.com/mattn/go-isatty v0.0.20 // indirect github.com/mattn/go-localereader v0.0.2-0.20220822084749-2491eb6c1c75 // indirect - github.com/mattn/go-runewidth v0.0.19 // indirect + github.com/mattn/go-runewidth v0.0.21 // indirect github.com/mgutz/ansi v0.0.0-20200706080929-d51e80ef957d // indirect github.com/mikelolasagasti/xz v1.0.1 // indirect github.com/minio/minlz v1.0.1 // indirect @@ -266,13 +265,15 @@ require ( github.com/pborman/indent v1.2.1 // indirect github.com/pelletier/go-toml v1.9.5 // indirect github.com/pelletier/go-toml/v2 v2.3.1 // indirect - github.com/pierrec/lz4/v4 v4.1.22 // indirect + github.com/pierrec/lz4/v4 v4.1.26 // indirect + github.com/piprate/json-gold v0.7.0 // indirect github.com/pjbgf/sha1cd v0.6.0 // indirect github.com/pkg/errors v0.9.1 // indirect github.com/pkg/profile v1.7.0 // indirect github.com/pkg/xattr v0.4.12 // indirect github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10 // indirect github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect + github.com/pquerna/cachecontrol v0.0.0-20180517163645-1555304b9b35 // indirect github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect github.com/rivo/uniseg v0.4.7 // indirect github.com/rogpeppe/go-internal v1.14.1 // indirect @@ -287,17 +288,18 @@ require ( github.com/sorairolake/lzip-go v0.3.8 // indirect github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8 // indirect github.com/spdx/gordf v0.0.0-20250128162952-000978ccd6fb // indirect - github.com/spdx/tools-golang v0.5.7 // indirect + github.com/spdx/tools-golang v0.6.0-rc4 // indirect github.com/spf13/cast v1.10.0 // indirect github.com/spf13/pflag v1.0.10 // indirect github.com/spf13/viper v1.21.0 // indirect github.com/spiffe/go-spiffe/v2 v2.6.0 // indirect github.com/stretchr/objx v0.5.2 // indirect github.com/subosito/gotenv v1.6.0 // indirect - github.com/sylabs/sif/v2 v2.24.0 // indirect + github.com/sylabs/sif/v2 v2.24.1 // indirect github.com/sylabs/squashfs v1.0.6 // indirect + github.com/tailscale/hujson v0.0.0-20260302212456-ecc657c15afd // indirect github.com/therootcompany/xz v1.0.1 // indirect - github.com/tidwall/gjson v1.18.0 // indirect + github.com/tidwall/gjson v1.19.0 // indirect github.com/tidwall/match v1.1.1 // indirect github.com/tidwall/pretty v1.2.1 // indirect github.com/tidwall/sjson v1.2.5 // indirect @@ -321,13 +323,13 @@ require ( go.yaml.in/yaml/v3 v3.0.4 // indirect go.yaml.in/yaml/v4 v4.0.0-rc.2 // indirect go4.org v0.0.0-20230225012048-214862532bf5 // indirect - golang.org/x/crypto v0.51.0 // indirect - golang.org/x/mod v0.36.0 // indirect - golang.org/x/net v0.54.0 // indirect + golang.org/x/crypto v0.53.0 // indirect + golang.org/x/mod v0.37.0 // indirect + golang.org/x/net v0.56.0 // indirect golang.org/x/oauth2 v0.36.0 // indirect - golang.org/x/sync v0.20.0 // indirect - golang.org/x/sys v0.44.0 // indirect - golang.org/x/term v0.43.0 // indirect + golang.org/x/sync v0.21.0 // indirect + golang.org/x/sys v0.46.0 // indirect + golang.org/x/term v0.44.0 // indirect golang.org/x/xerrors v0.0.0-20240903120638-7835f813f4da // indirect gonum.org/v1/gonum v0.17.0 // indirect google.golang.org/api v0.271.0 // indirect @@ -337,8 +339,8 @@ require ( google.golang.org/grpc v1.80.0 // indirect google.golang.org/protobuf v1.36.12-0.20260120151049-f2248ac996af // indirect gopkg.in/warnings.v0 v0.1.2 // indirect - modernc.org/libc v1.70.0 // indirect + modernc.org/libc v1.72.3 // indirect modernc.org/mathutil v1.7.1 // indirect modernc.org/memory v1.11.0 // indirect - modernc.org/sqlite v1.46.2 // indirect + modernc.org/sqlite v1.51.0 // indirect ) diff --git a/go.sum b/go.sum index a837752a4a5..53e9d5f6c7c 100644 --- a/go.sum +++ b/go.sum @@ -110,8 +110,8 @@ github.com/Microsoft/hcsshim v0.15.0-rc.1/go.mod h1:HWvvUPIy9HF6LotILj1G4VyS065r github.com/OneOfOne/xxhash v1.2.2/go.mod h1:HSdplMjZKSmBqAxg5vPj2TmRDmfkzw+cTzAElWljhcU= github.com/OneOfOne/xxhash v1.2.8 h1:31czK/TI9sNkxIKfaUfGlU47BAxQ0ztGgd9vPyqimf8= github.com/OneOfOne/xxhash v1.2.8/go.mod h1:eZbhyaAYD41SGSSsnmcpxVoRiQ/MPUTjUdIIOT9Um7Q= -github.com/ProtonMail/go-crypto v1.4.0 h1:Zq/pbM3F5DFgJiMouxEdSVY44MVoQNEKp5d5QxIQceQ= -github.com/ProtonMail/go-crypto v1.4.0/go.mod h1:e1OaTyu5SYVrO9gKOEhTc+5UcXtTUa+P3uLudwcgPqo= +github.com/ProtonMail/go-crypto v1.4.1 h1:9RfcZHqEQUvP8RzecWEUafnZVtEvrBVL9BiF67IQOfM= +github.com/ProtonMail/go-crypto v1.4.1/go.mod h1:e1OaTyu5SYVrO9gKOEhTc+5UcXtTUa+P3uLudwcgPqo= github.com/STARRY-S/zip v0.2.3 h1:luE4dMvRPDOWQdeDdUxUoZkzUIpTccdKdhHHsQJ1fm4= github.com/STARRY-S/zip v0.2.3/go.mod h1:lqJ9JdeRipyOQJrYSOtpNAiaesFO6zVDsE8GIGFaoSk= github.com/acarl005/stripansi v0.0.0-20180116102854-5a71ef0e047d h1:licZJFw2RwpHMqeKTCYkitsPqHNxTmd4SNR5r94FGM8= @@ -126,36 +126,36 @@ github.com/alecthomas/template v0.0.0-20160405071501-a0175ee3bccc/go.mod h1:LOuy github.com/alecthomas/template v0.0.0-20190718012654-fb15b899a751/go.mod h1:LOuyumcjzFXgccqObfd/Ljyb9UuFJ6TxHnclSeseNhc= github.com/alecthomas/units v0.0.0-20151022065526-2efee857e7cf/go.mod h1:ybxpYRFXyAe+OPACYpWeL0wqObRcbAqCMya13uyzqw0= github.com/alecthomas/units v0.0.0-20190717042225-c3de453c63f4/go.mod h1:ybxpYRFXyAe+OPACYpWeL0wqObRcbAqCMya13uyzqw0= -github.com/anchore/bubbly v0.2.0 h1:VE6nLlWsYV+bTOqqCzS46NirrVmvHah3UQDtFcjzgHU= -github.com/anchore/bubbly v0.2.0/go.mod h1:zRg1mfRCCe4vq1t9Ibu24YurErmnp9LAVCbpnpsb1pQ= -github.com/anchore/clio v0.1.0 h1:lrlSgD38V0T6TLjOG5Kn3miEXCQyYr6ZlwTSnW1WIJg= -github.com/anchore/clio v0.1.0/go.mod h1:z4l6oHaU8x/yQ3BnES4Ekh4eLqFSYlj1jQpk7waPkJs= -github.com/anchore/fangs v0.1.0 h1:EidxQN2XDRWn8q2n9pCVZXrOEnvpV1CuPLiWMyWG/vE= -github.com/anchore/fangs v0.1.0/go.mod h1:5YocMj6a0MC+KiBXBP0/ocOfc54DvlsOcpkAxmPWgx0= -github.com/anchore/go-collections v0.1.0 h1:vh2LE/j6HLKC27pENkas+J+h4BK4nOte9b7pDjlhY9w= -github.com/anchore/go-collections v0.1.0/go.mod h1:1aiktV46ATCkuVg0O573ZrH56BUawTECPETbZyBcqT8= -github.com/anchore/go-homedir v0.1.0 h1:WP+lNLOz2og8hzCkY7IccyFNInVKsrjogzV19G5G8p0= -github.com/anchore/go-homedir v0.1.0/go.mod h1:9B0DGhbmMAJVGGEbrlb+PkORM5eDFCEOtZ3xQ22qaLA= -github.com/anchore/go-logger v0.1.0 h1:3pE9Sli4SE1I/mtV+2pZXzim+aWj83BRaneVXeMgD3A= -github.com/anchore/go-logger v0.1.0/go.mod h1:0l63X5VyHqfWQwsNaTmkgXErsfohze3NAFCXFdhSjdQ= -github.com/anchore/go-lzo v0.1.0 h1:NgAacnzqPeGH49Ky19QKLBZEuFRqtTG9cdaucc3Vncs= -github.com/anchore/go-lzo v0.1.0/go.mod h1:3kLx0bve2oN1iDwgM1U5zGku1Tfbdb0No5qp1eL1fIk= -github.com/anchore/go-macholibre v0.1.0 h1:qHbdusBZNcZM/uuKf1Psa9xxAFSoyRTps8GW9gpJgsg= -github.com/anchore/go-macholibre v0.1.0/go.mod h1:eu0gbwaZ+ocVFJLePdmPPDKU8MboV1MKsUCr36Ckd5s= +github.com/anchore/bubbly v0.2.1 h1:b3ZNBefY8ks5DI4SQ7BXSbBIsN6eoaTD6s1su7NuCBo= +github.com/anchore/bubbly v0.2.1/go.mod h1:o6a9aH3/fCn+mb5g1T1tBbQZ4hGR0yO5/uD1d2Vtago= +github.com/anchore/clio v0.1.1 h1:9bkYnaFbpT0J3G0qSHyQqa/lpK5zV0C9Gtgh82H/jj0= +github.com/anchore/clio v0.1.1/go.mod h1:CFPIopJlakJbBpKtAL/EPpj4W9hgBO7PfFc8N2FAtlE= +github.com/anchore/fangs v0.1.1 h1:NBTHocmWM2/0gNhebpoE/wEB0Z0nxh2lESww2o/Uf5M= +github.com/anchore/fangs v0.1.1/go.mod h1:82oZuk4+RynOvAoxnXKmI1zO5xk/kbOw+5t4fAAPSsk= +github.com/anchore/go-collections v0.1.1 h1:kGlmSehs+ctTBZr+98F2+taHeolHy+XDx1Nczl8GZc8= +github.com/anchore/go-collections v0.1.1/go.mod h1:zgDbPSNDpfU47tVo7aW+2suG7+vXB1Sq1qtDhRJknQ0= +github.com/anchore/go-homedir v0.1.1 h1:KpaO5BPrVT7cVMcEZ9KfFfEi0iuo3DtagPGl0C744X0= +github.com/anchore/go-homedir v0.1.1/go.mod h1:9B0DGhbmMAJVGGEbrlb+PkORM5eDFCEOtZ3xQ22qaLA= +github.com/anchore/go-logger v0.1.1 h1:HPBNmQVBdYliwaFVUL7oJ3UvSlaoXAMj/ZagAIYaCpA= +github.com/anchore/go-logger v0.1.1/go.mod h1:ekWuh5BkZVwyXnyEd4fwL29N4pNLssgRsqh2+kFN/b8= +github.com/anchore/go-lzo v0.1.1 h1:IwL/fvkdtlIrYIXck6WxZ3nb8WjjHziYYmGxlooyOnM= +github.com/anchore/go-lzo v0.1.1/go.mod h1:3kLx0bve2oN1iDwgM1U5zGku1Tfbdb0No5qp1eL1fIk= +github.com/anchore/go-macholibre v0.1.1 h1:sTBHi3S1WVIOCamc6NyT8Sn9mBmD30DCTfjfVm4k6/M= +github.com/anchore/go-macholibre v0.1.1/go.mod h1:YNq2610RlvGCK0Za+Klz/OCMtUaEnwwAGA/VfUAQaro= github.com/anchore/go-rpmdb v0.1.0 h1:Q8dc208/HYzCqhx0L1zurfm1UPil24hlo9NjkdFmLdE= github.com/anchore/go-rpmdb v0.1.0/go.mod h1:eQVa6QFGzKy0qMcnW2pez0XBczvgwSjw9vA23qifEyU= -github.com/anchore/go-struct-converter v0.1.0 h1:2rDRssAl6mgKBSLNiVCMADgZRhoqtw9dedlWa0OhD30= -github.com/anchore/go-struct-converter v0.1.0/go.mod h1:rYqSE9HbjzpHTI74vwPvae4ZVYZd1lue2ta6xHPdblA= -github.com/anchore/go-sync v0.1.0 h1:1TEZM7jISrvtoBMOF79xP0caQKASAtgW1yKqc0EjyZg= -github.com/anchore/go-sync v0.1.0/go.mod h1:Iposeub0kHipoTei1icj4Tys0SJN+cCdxEnkS7bZUFs= +github.com/anchore/go-struct-converter v0.2.0-rc2 h1:q+859fW2/jbHJHB2etbNfRlFwYpknyvbqqk1hUdamQ4= +github.com/anchore/go-struct-converter v0.2.0-rc2/go.mod h1:cDBA5vhcR62nXWo8QH9/Kk2807o65ISaHPNPX66L+Uw= +github.com/anchore/go-sync v0.1.1 h1:91SZ+YqUIIHmf2jPAYZPuHqM/ZqnK1pVJDWtET6+AJE= +github.com/anchore/go-sync v0.1.1/go.mod h1:3haGsk2BnaoVhsfqae8Kd0FKIAILE1Hw69P4Xo5iVBw= github.com/anchore/go-version v1.2.2-0.20210903204242-51efa5b487c4 h1:rmZG77uXgE+o2gozGEBoUMpX27lsku+xrMwlmBZJtbg= github.com/anchore/go-version v1.2.2-0.20210903204242-51efa5b487c4/go.mod h1:Bkc+JYWjMCF8OyZ340IMSIi2Ebf3uwByOk6ho4wne1E= github.com/anchore/packageurl-go v0.2.0 h1:CkrM4RMUwrEGAiE1OVlxaZNzWj0TuHRey7o4T/EAErk= github.com/anchore/packageurl-go v0.2.0/go.mod h1:2JCgOQMIsqZ7TmliXG4PnUthPJAKE3mWQbsW2XHjAOE= -github.com/anchore/stereoscope v0.2.0 h1:8haOu2ugLymmxvyfrZR7OTBFiRFRBh5LlNUtRrSRoxI= -github.com/anchore/stereoscope v0.2.0/go.mod h1:PYx3fD4lvBVsYoQ/fBdauhZ5hmkRrJgw1B73svKx7/U= -github.com/anchore/syft v1.45.0 h1:8z0NVoGJiAYfQONQX4jId0eyz0L63+5oLAb/fvSsvnU= -github.com/anchore/syft v1.45.0/go.mod h1:lacdM9Ff/XLxKgN6R1xeVlwnLDu3u2Vy/lyFHpYI0Ek= +github.com/anchore/stereoscope v0.2.2 h1:SGTLGoF6GHmKEn9Bb6LYpzzgz9nhvMZgN/c4fTSQjYY= +github.com/anchore/stereoscope v0.2.2/go.mod h1:ylJXJKebLctP7u9ewguB1d0zUETJxvAA7r2DiuljDTM= +github.com/anchore/syft v1.46.0 h1:qIXMOJMlznWsWgzOT6xzK8aDhpOG7wnkbA0BS2UKF0c= +github.com/anchore/syft v1.46.0/go.mod h1:LwBimjMV20nC1zAsN5jzpGTg/6f0X4Acvvx1WymA53g= github.com/andreyvit/diff v0.0.0-20170406064948-c7f18ee00883/go.mod h1:rCTlJbsFo29Kk6CurOXKm700vrz8f0KW0JNfpkRJY/8= github.com/andybalholm/brotli v1.2.0 h1:ukwgCxwYrmACq68yiUqwIWnGY0cTPox/M94sVwToPjQ= github.com/andybalholm/brotli v1.2.0/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY= @@ -260,8 +260,8 @@ github.com/charmbracelet/bubbles v1.0.0 h1:12J8/ak/uCZEMQ6KU7pcfwceyjLlWsDLAxB5f github.com/charmbracelet/bubbles v1.0.0/go.mod h1:9d/Zd5GdnauMI5ivUIVisuEm3ave1XwXtD1ckyV6r3E= github.com/charmbracelet/bubbletea v1.3.10 h1:otUDHWMMzQSB0Pkc87rm691KZ3SWa4KUlvF9nRvCICw= github.com/charmbracelet/bubbletea v1.3.10/go.mod h1:ORQfo0fk8U+po9VaNvnV95UPWA1BitP1E0N6xJPlHr4= -github.com/charmbracelet/colorprofile v0.4.1 h1:a1lO03qTrSIRaK8c3JRxJDZOvhvIeSco3ej+ngLk1kk= -github.com/charmbracelet/colorprofile v0.4.1/go.mod h1:U1d9Dljmdf9DLegaJ0nGZNJvoXAhayhmidOdcBwAvKk= +github.com/charmbracelet/colorprofile v0.4.3 h1:QPa1IWkYI+AOB+fE+mg/5/4HRMZcaXex9t5KX76i20Q= +github.com/charmbracelet/colorprofile v0.4.3/go.mod h1:/zT4BhpD5aGFpqQQqw7a+VtHCzu+zrQtt1zhMt9mR4Q= github.com/charmbracelet/harmonica v0.2.0 h1:8NxJWRWg/bzKqqEaaeFNipOu77YR5t8aSwG4pgaUBiQ= github.com/charmbracelet/harmonica v0.2.0/go.mod h1:KSri/1RMQOZLbw7AHqgcBycp8pgJnQMYYT8QZRqZ1Ao= github.com/charmbracelet/lipgloss v1.1.0 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoFqR/noCY= @@ -284,10 +284,10 @@ github.com/chzyer/test v1.0.0/go.mod h1:2JlltgoNkt4TW/z9V/IzDdFaMTM2JPIi26O1pF38 github.com/circonus-labs/circonus-gometrics v2.3.1+incompatible/go.mod h1:nmEj6Dob7S7YxXgwXpfOuvO54S+tGdZdw9fuRZt25Ag= github.com/circonus-labs/circonusllhist v0.1.3/go.mod h1:kMXHVDlOchFAehlya5ePtbp5jckzBHf4XRpQvBOLI+I= github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw= -github.com/clipperhouse/displaywidth v0.10.0 h1:GhBG8WuerxjFQQYeuZAeVTuyxuX+UraiZGD4HJQ3Y8g= -github.com/clipperhouse/displaywidth v0.10.0/go.mod h1:XqJajYsaiEwkxOj4bowCTMcT1SgvHo9flfF3jQasdbs= -github.com/clipperhouse/uax29/v2 v2.6.0 h1:z0cDbUV+aPASdFb2/ndFnS9ts/WNXgTNNGFoKXuhpos= -github.com/clipperhouse/uax29/v2 v2.6.0/go.mod h1:Wn1g7MK6OoeDT0vL+Q0SQLDz/KpfsVRgg6W7ihQeh4g= +github.com/clipperhouse/displaywidth v0.11.0 h1:lBc6kY44VFw+TDx4I8opi/EtL9m20WSEFgwIwO+UVM8= +github.com/clipperhouse/displaywidth v0.11.0/go.mod h1:bkrFNkf81G8HyVqmKGxsPufD3JhNl3dSqnGhOoSD/o0= +github.com/clipperhouse/uax29/v2 v2.7.0 h1:+gs4oBZ2gPfVrKPthwbMzWZDaAFPGYK72F0NJv2v7Vk= +github.com/clipperhouse/uax29/v2 v2.7.0/go.mod h1:EFJ2TJMRUaplDxHKj1qAEhCtQPW2tJSwu5BF98AuoVM= github.com/cloudflare/circl v1.6.3 h1:9GPOhQGF9MCYUeXyMYlqTR6a5gTrgR/fBLXvUgtVcg8= github.com/cloudflare/circl v1.6.3/go.mod h1:2eXP6Qfat4O/Yhh8BznvKnJ+uzEoTQ6jVKJRn81BiS4= github.com/cncf/udpa/go v0.0.0-20191209042840-269d4d468f6f/go.mod h1:M8M6+tZqaGXZJjfX53e64911xZQV5JYwmTeXPW+k8Sc= @@ -306,8 +306,8 @@ github.com/containerd/cgroups/v3 v3.1.3 h1:eUNflyMddm18+yrDmZPn3jI7C5hJ9ahABE5q6 github.com/containerd/cgroups/v3 v3.1.3/go.mod h1:PKZ2AcWmSBsY/tJUVhtS/rluX0b1uq1GmPO1ElCmbOw= github.com/containerd/containerd/api v1.11.1 h1:h8nfoDW9+fNsC/9TwiAHj8B1GzXKtR4eFtkhi/X5RLU= github.com/containerd/containerd/api v1.11.1/go.mod h1:CaQFRu+N1MtbgL6JDOJLUB1hCKESU1lD6MuTJhgtdlw= -github.com/containerd/containerd/v2 v2.3.1 h1:4dVXBdlvotRBlaP2TmNbY/EGc06KJrMDDUqQdxX/HOk= -github.com/containerd/containerd/v2 v2.3.1/go.mod h1:xVoxGPWZBwwph8DF2IbDhriLKdHfjdpO0b3wFP9wQ1I= +github.com/containerd/containerd/v2 v2.3.2 h1:eLven1YxRMkeiKu7IcMrPKE+gn8sGR1DqHbbshMEvWM= +github.com/containerd/containerd/v2 v2.3.2/go.mod h1:rHKGm3VW6wNrINb3x8mNT+w7qYXFVElTt/8HTuxVhD4= github.com/containerd/continuity v0.5.0 h1:7a85HZpCSs+1Zps0Ee3DPSuAWY+0SJM1JNM51nlEVDg= github.com/containerd/continuity v0.5.0/go.mod h1:/lNJvtJKUQStBzpVQ1+rasXO1LAWtUQssk28EZvJ3nE= github.com/containerd/errdefs v1.0.0 h1:tg5yIfIlQIrxYtu9ajqY42W3lpS19XqdxRQeEwYG8PI= @@ -342,16 +342,16 @@ github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8Yc github.com/deitch/magic v0.0.0-20240306090643-c67ab88f10cb h1:4W/2rQ3wzEimF5s+J6OY3ODiQtJZ5W1sForSgogVXkY= github.com/deitch/magic v0.0.0-20240306090643-c67ab88f10cb/go.mod h1:B3tI9iGHi4imdLi4Asdha1Sc6feLMTfPLXh9IUYmysk= github.com/dgrijalva/jwt-go/v4 v4.0.0-preview1/go.mod h1:+hnT3ywWDTAFrW5aE+u2Sa/wT555ZqwoCS+pk3p6ry4= -github.com/diskfs/go-diskfs v1.7.0 h1:vonWmt5CMowXwUc79jWyGrf2DIMeoOjkLlMnQYGVOs8= -github.com/diskfs/go-diskfs v1.7.0/go.mod h1:LhQyXqOugWFRahYUSw47NyZJPezFzB9UELwhpszLP/k= +github.com/diskfs/go-diskfs v1.9.3 h1:cLciNCeZ4QAXVxyPJDr1ZJ9N9CCG3rQlQ/z/Cs/cNDM= +github.com/diskfs/go-diskfs v1.9.3/go.mod h1:TePJORO83Adh5pb2SqsxAwaP0fofFxKLkxctiS/9OQc= github.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5QvfrDyIgxBk= github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E= github.com/djherbis/times v1.6.0 h1:w2ctJ92J8fBvWPxugmXIv7Nz7Q3iDMKNx9v5ocVH20c= github.com/djherbis/times v1.6.0/go.mod h1:gOHeRAz2h+VJNZ5Gmc/o7iD9k4wW7NMVqieYCY99oc0= github.com/dlclark/regexp2 v1.11.0 h1:G/nrcoOa7ZXlpoa/91N3X7mM3r8eIlMBBJZvsz/mxKI= github.com/dlclark/regexp2 v1.11.0/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8= -github.com/docker/cli v29.4.3+incompatible h1:u+UliYm2J/rYrIh2FqHQg32neRG8GjbvNuwQRTzGspU= -github.com/docker/cli v29.4.3+incompatible/go.mod h1:JLrzqnKDaYBop7H2jaqPtU4hHvMKP+vjCwu2uszcLI8= +github.com/docker/cli v29.5.3+incompatible h1:nbEFfz774vBwQ5KRYv7c/AghjReqnGISvrRhzjV0evs= +github.com/docker/cli v29.5.3+incompatible/go.mod h1:JLrzqnKDaYBop7H2jaqPtU4hHvMKP+vjCwu2uszcLI8= github.com/docker/docker v28.5.2+incompatible h1:DBX0Y0zAjZbSrm1uzOkdr1onVghKaftjlSWt4AFexzM= github.com/docker/docker v28.5.2+incompatible/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk= github.com/docker/docker-credential-helpers v0.9.5 h1:EFNN8DHvaiK8zVqFA2DT6BjXE0GzfLOZ38ggPTKePkY= @@ -369,8 +369,8 @@ github.com/elazarl/goproxy v1.7.2 h1:Y2o6urb7Eule09PjlhQRGNsqRfPmYI3KKQLFpCAV3+o github.com/elazarl/goproxy v1.7.2/go.mod h1:82vkLNir0ALaW14Rc399OTTjyNREgmdL2cVoIbS6XaE= github.com/elliotchance/phpserialize v1.4.0 h1:cAp/9+KSnEbUC8oYCE32n2n84BeW8HOY3HMDI8hG2OY= github.com/elliotchance/phpserialize v1.4.0/go.mod h1:gt7XX9+ETUcLXbtTKEuyrqW3lcLUAeS/AnGZ2e49TZs= -github.com/elliotwutingfeng/asciiset v0.0.0-20230602022725-51bbb787efab h1:h1UgjJdAAhj+uPL68n7XASS6bU+07ZX1WJvVS2eyoeY= -github.com/elliotwutingfeng/asciiset v0.0.0-20230602022725-51bbb787efab/go.mod h1:GLo/8fDswSAniFG+BFIaiSPcK610jyzgEhWYPQwuQdw= +github.com/elliotwutingfeng/asciiset v0.0.0-20260129054604-cfde2086bc57 h1:x5yxNrq8XffV/OoNUeFPM6hxHVi5OTspSTBxr/9pemg= +github.com/elliotwutingfeng/asciiset v0.0.0-20260129054604-cfde2086bc57/go.mod h1:GLo/8fDswSAniFG+BFIaiSPcK610jyzgEhWYPQwuQdw= github.com/emirpasic/gods v1.18.1 h1:FXtiHYKDGKCW2KzwZKx0iC0PQmdlorYgdFG9jPXJ1Bc= github.com/emirpasic/gods v1.18.1/go.mod h1:8tpGGwCnJ5H4r6BWwaV6OrWmMoPhUl5jm/FMNAnJvWQ= github.com/envoyproxy/go-control-plane v0.9.0/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4= @@ -419,12 +419,12 @@ github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8 github.com/gabriel-vasile/mimetype v1.4.13 h1:46nXokslUBsAJE/wMsp5gtO500a4F3Nkz9Ufpk2AcUM= github.com/gabriel-vasile/mimetype v1.4.13/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s= github.com/ghodss/yaml v1.0.0/go.mod h1:4dBDuWmgqj2HViK6kFavaiC9ZROes6MMH2rRYeMEF04= -github.com/github/go-spdx/v2 v2.4.0 h1:+4IwVwJJbm3rzvrQ6P1nI9BDMcy3la4RchRy5uehV/M= -github.com/github/go-spdx/v2 v2.4.0/go.mod h1:/5rwgS0txhGtRdUZwc02bTglzg6HK3FfuEbECKlK2Sg= -github.com/gkampitakis/ciinfo v0.3.2 h1:JcuOPk8ZU7nZQjdUhctuhQofk7BGHuIy0c9Ez8BNhXs= -github.com/gkampitakis/ciinfo v0.3.2/go.mod h1:1NIwaOcFChN4fa/B0hEBdAb6npDlFL8Bwx4dfRLRqAo= -github.com/gkampitakis/go-snaps v0.5.21 h1:SvhSFeZviQXwlT+dnGyAIATVehkhqRVW6qfQZhCZH+Y= -github.com/gkampitakis/go-snaps v0.5.21/go.mod h1:gC3YqxQTPyIXvQrw/Vpt3a8VqR1MO8sVpZFWN4DGwNs= +github.com/github/go-spdx/v2 v2.7.0 h1:GzfXx4wFdlilARxmFRXW/mgUy3A4vSqZocCMFV6XFdQ= +github.com/github/go-spdx/v2 v2.7.0/go.mod h1:Ftc45YYG1WzpzwEPKRVm9Jv8vDqOrN4gWoCkK+bHer0= +github.com/gkampitakis/ciinfo v0.3.4 h1:5eBSibVuSMbb/H6Elc0IIEFbkzCJi3lm94n0+U7Z0KY= +github.com/gkampitakis/ciinfo v0.3.4/go.mod h1:1NIwaOcFChN4fa/B0hEBdAb6npDlFL8Bwx4dfRLRqAo= +github.com/gkampitakis/go-snaps v0.5.22 h1:xg9omphRnbDnimMCl1KqznC4krlxOGpkB0vDSfX2P7M= +github.com/gkampitakis/go-snaps v0.5.22/go.mod h1:uy3lVzCCRRsAwYqSocyw5fY8xRLCYEfqoOJNxr8HonM= github.com/glebarez/go-sqlite v1.22.0 h1:uAcMJhaA6r3LHMTFgP0SifzgXg46yJkgxqyuyec+ruQ= github.com/glebarez/go-sqlite v1.22.0/go.mod h1:PlBIdHe0+aUEFn+r2/uthrWq4FxbzugL0L8Li6yQJbc= github.com/glebarez/sqlite v1.11.0 h1:wSG0irqzP6VurnMEpFGer5Li19RpIRi2qvQz++w0GMw= @@ -527,8 +527,8 @@ github.com/google/go-cmp v0.5.6/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/ github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= -github.com/google/go-containerregistry v0.21.6 h1:T+yqQIlJXKrM98Om4DlW3GoWQAmhZuLMwoDOvVrtiUM= -github.com/google/go-containerregistry v0.21.6/go.mod h1:U7MMSBIJynke2MVQrQk19NP9k/uQsGz/h0amIFSHMbo= +github.com/google/go-containerregistry v0.21.7 h1:/vPFuVXDjtFREsVArW+0h1CIl5urnOhzei4X2DMW9IU= +github.com/google/go-containerregistry v0.21.7/go.mod h1:kjSbt7/zMsKLWfnHrIvKvhXHUw91jbe9DNjPPJ32gXE= github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= github.com/google/licensecheck v0.3.1 h1:QoxgoDkaeC4nFrtGN1jV7IPmDCHFNIVh54e5hSt6sPs= github.com/google/licensecheck v0.3.1/go.mod h1:ORkR35t/JjW+emNKtfJDII0zlciG9JgbT7SmsohlHmY= @@ -539,8 +539,6 @@ github.com/google/martian/v3 v3.1.0/go.mod h1:y5Zk1BBys9G+gd6Jrk0W3cC1+ELVxBWuIG github.com/google/martian/v3 v3.2.1/go.mod h1:oBOf6HBosgwRXnUGWUB05QECsc6uvmMiJ3+6W4l/CUk= github.com/google/martian/v3 v3.3.3 h1:DIhPTQrbPkgs2yJYdXU/eNACCG5DVQjySNRNlflZ9Fc= github.com/google/martian/v3 v3.3.3/go.mod h1:iEPrYcgCF7jA9OtScMFQyAlZZ4YXTKEtJ1E6RWzmBA0= -github.com/google/osv-scanner v1.9.2 h1:N5Arl9SA75afbjmX8mKURgOIaKyuK3NUjCaxDlj1KHI= -github.com/google/osv-scanner v1.9.2/go.mod h1:ZTL8Dp9z/7Jr9kkQSOGqo8z6Csqt83qMIr58aZVx+pM= github.com/google/pprof v0.0.0-20181206194817-3ea8567a2e57/go.mod h1:zfwlbNMJ+OItoe0UupaVj+oy1omPYYDuagoSzA8v9mc= github.com/google/pprof v0.0.0-20190515194954-54271f7e092f/go.mod h1:zfwlbNMJ+OItoe0UupaVj+oy1omPYYDuagoSzA8v9mc= github.com/google/pprof v0.0.0-20191218002539-d4f498aebedc/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM= @@ -580,8 +578,8 @@ github.com/gookit/assert v0.1.1/go.mod h1:jS5bmIVQZTIwk42uXl4lyj4iaaxx32tqH16CFj github.com/gookit/color v1.2.5/go.mod h1:AhIE+pS6D4Ql0SQWbBeXPHw7gY0/sjHoA4s/n1KB7xg= github.com/gookit/color v1.6.1 h1:KoTnDxJPRgrL0SoX0f8rCFg2zI0t4E3GZZBMo2nN8LU= github.com/gookit/color v1.6.1/go.mod h1:9ACFc7/1IpHGBW8RwuDm/0YEnhg3dwwXpoMsmtyHfjs= -github.com/gpustack/gguf-parser-go v0.24.0 h1:tdJceXYp9e5RhE9RwVYIuUpir72Jz2D68NEtDXkKCKc= -github.com/gpustack/gguf-parser-go v0.24.0/go.mod h1:y4TwTtDqFWTK+xvprOjRUh+dowgU2TKCX37vRKvGiZ0= +github.com/gpustack/gguf-parser-go v0.24.1 h1:nTYtL8HFK6ZhB90RKBu4oX2b3ZHpJLrMmKRfL9w9Cyc= +github.com/gpustack/gguf-parser-go v0.24.1/go.mod h1:y4TwTtDqFWTK+xvprOjRUh+dowgU2TKCX37vRKvGiZ0= github.com/grpc-ecosystem/grpc-gateway v1.16.0 h1:gmcG1KaJ57LophUzW0Hy8NmPhnMZb4M0+kPpLofRdBo= github.com/grpc-ecosystem/grpc-gateway v1.16.0/go.mod h1:BDjrQk3hbvj6Nolgz8mAMFbcEtjT1g+wF4CSlocrBnw= github.com/grpc-ecosystem/grpc-gateway/v2 v2.28.0 h1:HWRh5R2+9EifMyIHV7ZV+MIZqgz+PMpZ14Jynv3O2Zs= @@ -705,8 +703,8 @@ github.com/magiconair/properties v1.8.5/go.mod h1:y3VJvCyxH9uVvJTWEGAELF3aiYNyPK github.com/magiconair/properties v1.8.10 h1:s31yESBquKXCV9a/ScB3ESkOjUYYv+X0rg8SYxI99mE= github.com/magiconair/properties v1.8.10/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0= github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc= -github.com/maruel/natural v1.1.1 h1:Hja7XhhmvEFhcByqDoHz9QZbkWey+COd9xWfCfn1ioo= -github.com/maruel/natural v1.1.1/go.mod h1:v+Rfd79xlw1AgVBjbO0BEQmptqb5HvL/k9GRHB7ZKEg= +github.com/maruel/natural v1.3.0 h1:VsmCsBmEyrR46RomtgHs5hbKADGRVtliHTyCOLFBpsg= +github.com/maruel/natural v1.3.0/go.mod h1:v+Rfd79xlw1AgVBjbO0BEQmptqb5HvL/k9GRHB7ZKEg= github.com/masahiro331/go-mvn-version v0.0.0-20250131095131-f4974fa13b8a h1:eLvAzVoRfHEOl64OxFhepPf3vj7SKvXY/tFc3BS0b7s= github.com/masahiro331/go-mvn-version v0.0.0-20250131095131-f4974fa13b8a/go.mod h1:jZ3F25l7DbD7l7DcA8aj7eo1EZ84nbzcQHBB4lCSrI8= github.com/mattn/go-colorable v0.0.9/go.mod h1:9vuHe8Xs5qXnSaW/c/ABM9alt+Vo+STaOChaDxuIBZU= @@ -727,8 +725,10 @@ github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D github.com/mattn/go-localereader v0.0.2-0.20220822084749-2491eb6c1c75 h1:P8UmIzZMYDR+NGImiFvErt6VWfIRPuGM+vyjiEdkmIw= github.com/mattn/go-localereader v0.0.2-0.20220822084749-2491eb6c1c75/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88= github.com/mattn/go-runewidth v0.0.10/go.mod h1:RAqKPSqVFrSLVXbA8x7dzmKdmGzieGRCM46jaSJTDAk= -github.com/mattn/go-runewidth v0.0.19 h1:v++JhqYnZuu5jSKrk9RbgF5v4CGUjqRfBm05byFGLdw= -github.com/mattn/go-runewidth v0.0.19/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs= +github.com/mattn/go-runewidth v0.0.21 h1:jJKAZiQH+2mIinzCJIaIG9Be1+0NR+5sz/lYEEjdM8w= +github.com/mattn/go-runewidth v0.0.21/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs= +github.com/mattn/go-sqlite3 v1.14.23 h1:gbShiuAP1W5j9UOksQ06aiiqPMxYecovVGwmTxWtuw0= +github.com/mattn/go-sqlite3 v1.14.23/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= github.com/matttproud/golang_protobuf_extensions v1.0.1/go.mod h1:D8He9yQNgCq6Z5Ld7szi9bcBfOoFv/3dc6xSMkL2PC0= github.com/mgutz/ansi v0.0.0-20200706080929-d51e80ef957d h1:5PJl274Y63IEHC+7izoQE9x6ikvDFZS2mDVS3drnohI= github.com/mgutz/ansi v0.0.0-20200706080929-d51e80ef957d/go.mod h1:01TrycV0kFyexm33Z7vhZRXopbI8J3TDReVlkTgMUxE= @@ -837,8 +837,10 @@ github.com/pelletier/go-toml v1.9.5 h1:4yBQzkHv+7BHq2PQUZF3Mx0IYxG7LsP222s7Agd3v github.com/pelletier/go-toml v1.9.5/go.mod h1:u1nR/EPcESfeI/szUZKdtJ0xRNbUoANCkoOuaOx1Y+c= github.com/pelletier/go-toml/v2 v2.3.1 h1:MYEvvGnQjeNkRF1qUuGolNtNExTDwct51yp7olPtrEc= github.com/pelletier/go-toml/v2 v2.3.1/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY= -github.com/pierrec/lz4/v4 v4.1.22 h1:cKFw6uJDK+/gfw5BcDL0JL5aBsAFdsIT18eRtLj7VIU= -github.com/pierrec/lz4/v4 v4.1.22/go.mod h1:gZWDp/Ze/IJXGXf23ltt2EXimqmTUXEy0GFuRQyBid4= +github.com/pierrec/lz4/v4 v4.1.26 h1:GrpZw1gZttORinvzBdXPUXATeqlJjqUG/D87TKMnhjY= +github.com/pierrec/lz4/v4 v4.1.26/go.mod h1:EoQMVJgeeEOMsCqCzqFm2O0cJvljX2nGZjcRIPL34O4= +github.com/piprate/json-gold v0.7.0 h1:bEMirgA5y8Z2loTQfxyIFfY+EflxH1CTP6r/KIlcJNw= +github.com/piprate/json-gold v0.7.0/go.mod h1:RVhE35veDX19r5gfUAR+IYHkAUuPwJO8Ie/qVeFaIzw= github.com/pjbgf/sha1cd v0.6.0 h1:3WJ8Wz8gvDz29quX1OcEmkAlUg9diU4GxJHqs0/XiwU= github.com/pjbgf/sha1cd v0.6.0/go.mod h1:lhpGlyHLpQZoxMv8HcgXvZEhcGs0PG/vsZnEJ7H0iCM= github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA= @@ -858,6 +860,8 @@ github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRI github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/posener/complete v1.1.1/go.mod h1:em0nMJCgc9GFtwrmVmEMR/ZL6WyhyjMBndrE9hABlRI= github.com/posener/complete v1.2.3/go.mod h1:WZIdtGGp+qx0sLrYKtIRAruyNpv6hFCicSgv7Sy7s/s= +github.com/pquerna/cachecontrol v0.0.0-20180517163645-1555304b9b35 h1:J9b7z+QKAmPf4YLrFg6oQUotqHQeUNWwkvo7jZp1GLU= +github.com/pquerna/cachecontrol v0.0.0-20180517163645-1555304b9b35/go.mod h1:prYjPmNq4d1NPVmpShWobRqXY3q7Vp+80DqgxxUrUIA= github.com/prometheus/client_golang v0.9.1/go.mod h1:7SWBe2y4D6OKWSNQJUaRYU/AaXPKyh/dDVn+NZz0KFw= github.com/prometheus/client_golang v1.0.0/go.mod h1:db9x61etRT2tGnBNRi70OPL5FsnadC4Ky3P0J6CfImo= github.com/prometheus/client_golang v1.4.0/go.mod h1:e9GMxYsXl05ICDXkRhurwBS4Q3OK1iX/F2sw+iXX5zU= @@ -929,8 +933,8 @@ github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8/go.mod h1:3n1Cw github.com/spaolacci/murmur3 v0.0.0-20180118202830-f09979ecbc72/go.mod h1:JwIasOWyU6f++ZhiEuf87xNszmSA2myDM2Kzu9HwQUA= github.com/spdx/gordf v0.0.0-20250128162952-000978ccd6fb h1:7G2Czq97VORM5xNRrD8tSQdhoXPRs8s+Otlc7st9TS0= github.com/spdx/gordf v0.0.0-20250128162952-000978ccd6fb/go.mod h1:uKWaldnbMnjsSAXRurWqqrdyZen1R7kxl8TkmWk2OyM= -github.com/spdx/tools-golang v0.5.7 h1:+sWcKGnhwp3vLdMqPcLdA6QK679vd86cK9hQWH3AwCg= -github.com/spdx/tools-golang v0.5.7/go.mod h1:jg7w0LOpoNAw6OxKEzCoqPC2GCTj45LyTlVmXubDsYw= +github.com/spdx/tools-golang v0.6.0-rc4 h1:2GkvNr0DcnJHY9BDm3OYHo229jZS/h4qYDK+tHYXPOo= +github.com/spdx/tools-golang v0.6.0-rc4/go.mod h1:ruCHu3shgy7bVbZ7gtEU4Gq4fI08n2SdXtgV5PoN/OM= github.com/spf13/afero v1.3.3/go.mod h1:5KUK8ByomD5Ti5Artl0RtHeI5pTF7MIDuXL3yY520V4= github.com/spf13/afero v1.6.0/go.mod h1:Ai8FlHk4v/PARR026UzYexafAt9roJ7LcLMAmO6Z93I= github.com/spf13/afero v1.15.0 h1:b/YBCLWAJdFWJTN9cLhiXXcD7mzKn9Dm86dNnfyQw1I= @@ -972,17 +976,19 @@ github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD github.com/subosito/gotenv v1.2.0/go.mod h1:N0PQaV/YGNqwC0u51sEeR/aUtSLEXKX9iv69rRypqCw= github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8= github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU= -github.com/sylabs/sif/v2 v2.24.0 h1:1wB5uMDUQYjk8AckTySaDcP9YnpMb1LyDRr1Jt9A10w= -github.com/sylabs/sif/v2 v2.24.0/go.mod h1:DbXWqWZ1hdLSU+K9ipdds5AmZeHWsyxCOj/oQakBa88= +github.com/sylabs/sif/v2 v2.24.1 h1:OhTOfTwBaGXfbWYWXK9C6Pojma3A1bTaNJ6CEhyuKok= +github.com/sylabs/sif/v2 v2.24.1/go.mod h1:PRq9MoP0g+p0qE5ZRGrOhyVAUdHrPsAVmXHgoiW6VU0= github.com/sylabs/squashfs v1.0.6 h1:PvJcDzxr+vIm2kH56mEMbaOzvGu79gK7P7IX+R7BDZI= github.com/sylabs/squashfs v1.0.6/go.mod h1:DlDeUawVXLWAsSRa085Eo0ZenGzAB32JdAUFaB0LZfE= +github.com/tailscale/hujson v0.0.0-20260302212456-ecc657c15afd h1:Rf9uhF1+VJ7ZHqxrG8pJ6YacmHvVCmByDmGbAWCc/gA= +github.com/tailscale/hujson v0.0.0-20260302212456-ecc657c15afd/go.mod h1:EbW0wDK/qEUYI0A5bqq0C2kF8JTQwWONmGDBbzsxxHo= github.com/terminalstatic/go-xsd-validate v0.1.6 h1:TenYeQ3eY631qNi1/cTmLH/s2slHPRKTTHT+XSHkepo= github.com/terminalstatic/go-xsd-validate v0.1.6/go.mod h1:18lsvYFofBflqCrvo1umpABZ99+GneNTw2kEEc8UPJw= github.com/therootcompany/xz v1.0.1 h1:CmOtsn1CbtmyYiusbfmhmkpAAETj0wBIH6kCYaX+xzw= github.com/therootcompany/xz v1.0.1/go.mod h1:3K3UH1yCKgBneZYhuQUvJ9HPD19UEXEI0BWbMn8qNMY= github.com/tidwall/gjson v1.14.2/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk= -github.com/tidwall/gjson v1.18.0 h1:FIDeeyB800efLX89e5a8Y0BNH+LOngJyGrIWxG2FKQY= -github.com/tidwall/gjson v1.18.0/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk= +github.com/tidwall/gjson v1.19.0 h1:xwxm7n691Uf3u5OFjzngavjGTh55KX5q/9w9xHW88JU= +github.com/tidwall/gjson v1.19.0/go.mod h1:V37/opeE/JbLUOfH0QTXiNez2l0RUjYUhpT4szFQAfc= github.com/tidwall/match v1.1.1 h1:+Ho715JplO36QYgwN9PGYNhgZvoUSc9X2c80KVTi+GA= github.com/tidwall/match v1.1.1/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JTxsfmM= github.com/tidwall/pretty v1.2.0/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU= @@ -1099,8 +1105,8 @@ golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPh golang.org/x/crypto v0.0.0-20210817164053-32db794688a5/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= golang.org/x/crypto v0.0.0-20220622213112-05595931fe9d/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4= -golang.org/x/crypto v0.51.0 h1:IBPXwPfKxY7cWQZ38ZCIRPI50YLeevDLlLnyC5wRGTI= -golang.org/x/crypto v0.51.0/go.mod h1:8AdwkbraGNABw2kOX6YFPs3WM22XqI4EXEd8g+x7Oc8= +golang.org/x/crypto v0.53.0 h1:QZ4Muo8THX6CizN2vPPd5fBGHyogrdK9fG4wLPFUsto= +golang.org/x/crypto v0.53.0/go.mod h1:DNLU434OwVakk9PzuwV8w62mAJpRJL3vsgcfp4Qnsio= golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= golang.org/x/exp v0.0.0-20190306152737-a1d7652674e8/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= golang.org/x/exp v0.0.0-20190510132918-efd6b22b2522/go.mod h1:ZjyILWgesfNpC6sMxTJOJm9Kp84zZh5NQWvqDGG3Qr8= @@ -1141,8 +1147,8 @@ golang.org/x/mod v0.4.2/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/mod v0.5.0/go.mod h1:5OXOZSfqPIIbmVBIIKWRFfZjPR0E5r58TLhUjH0a2Ro= golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= -golang.org/x/mod v0.36.0 h1:JJjpVx6myfUsUdAzZuOSTTmRE0PfZeNWzzvKrP7amb4= -golang.org/x/mod v0.36.0/go.mod h1:moc6ELqsWcOw5Ef3xVprK5ul/MvtVvkIXLziUOICjUQ= +golang.org/x/mod v0.37.0 h1:vF1DjpVEshcIqoEaauuHebaLk1O1forxjxBaVn884JQ= +golang.org/x/mod v0.37.0/go.mod h1:m8S8VeM9r4dzDwjrKO0a1sZP3YjeMamRRlD+fmR2Q/0= golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20181023162649-9b4f9f5ad519/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= @@ -1188,8 +1194,8 @@ golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qx golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= golang.org/x/net v0.7.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= -golang.org/x/net v0.54.0 h1:2zJIZAxAHV/OHCDTCOHAYehQzLfSXuf/5SoL/Dv6w/w= -golang.org/x/net v0.54.0/go.mod h1:Sj4oj8jK6XmHpBZU/zWHw3BV3abl4Kvi+Ut7cQcY+cQ= +golang.org/x/net v0.56.0 h1:Rw8j/hFzGvJUZwNBXnAtf5sVDVt+65SK2C7IxCxZt5o= +golang.org/x/net v0.56.0/go.mod h1:D3Ku6r+V6JROoZK144D2XfMHFcMq/0zSfLelVTCFKec= golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= golang.org/x/oauth2 v0.0.0-20190226205417-e64efc72b421/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= golang.org/x/oauth2 v0.0.0-20190604053449-0f29369cfe45/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= @@ -1222,8 +1228,8 @@ golang.org/x/sync v0.0.0-20201207232520-09787c993a3a/go.mod h1:RxMgew5VJxzue5/jJ golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4= -golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= +golang.org/x/sync v0.21.0 h1:HLII4xRRTtCRkxYp4HNFF0Js/Og6q2i++KXbg0gHCwM= +golang.org/x/sync v0.21.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= golang.org/x/sys v0.0.0-20180823144017-11551d06cbcc/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20180905080454-ebe1bf3edb33/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= @@ -1298,13 +1304,13 @@ golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBc golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.44.0 h1:ildZl3J4uzeKP07r2F++Op7E9B29JRUy+a27EibtBTQ= -golang.org/x/sys v0.44.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/sys v0.46.0 h1:noSf2Fq6F8DBgS+LysIkx7rIExoNHJsxOAtPp4rthXw= +golang.org/x/sys v0.46.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= -golang.org/x/term v0.43.0 h1:S4RLU2sB31O/NCl+zFN9Aru9A/Cq2aqKpTZJ6B+DwT4= -golang.org/x/term v0.43.0/go.mod h1:lrhlHNdQJHO+1qVYiHfFKVuVioJIheAc3fBSMFYEIsk= +golang.org/x/term v0.44.0 h1:0rLvDRCtNj0gZkyIXhCyOb2OAzEhLVqc4B+hrsBhrmc= +golang.org/x/term v0.44.0/go.mod h1:7ze4MdzUzLXpSAoFP1H0bOI9aXDqveSvatT5vKcFh2Y= golang.org/x/text v0.0.0-20170915032832-14c0d48ead0c/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.1-0.20180807135948-17ff2d5776d2/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= @@ -1316,8 +1322,8 @@ golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= golang.org/x/text v0.11.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE= -golang.org/x/text v0.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc= -golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38= +golang.org/x/text v0.38.0 h1:sXmwo9DwP3OK9EZ7PqAdaooSGozfl/3a6/xJcbzPRhE= +golang.org/x/text v0.38.0/go.mod h1:YXZt3QhHUKYT53r2lLKFIVi6Ao1jdzrTR/KQ09qyxF4= golang.org/x/time v0.0.0-20181108054448-85acf8d2951c/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= golang.org/x/time v0.0.0-20190308202827-9d24e82272b4/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= golang.org/x/time v0.0.0-20191024005414-555d28b269f0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= @@ -1379,8 +1385,8 @@ golang.org/x/tools v0.1.4/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk= golang.org/x/tools v0.1.5/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk= golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= -golang.org/x/tools v0.45.0 h1:18qN3FAooORvApf5XjCXgsuayZOEtXf6JK18I3+ONa8= -golang.org/x/tools v0.45.0/go.mod h1:LuUGqqaXcXMEFEruIVJVm5mgDD8vww/z/SR1gQ4uE/0= +golang.org/x/tools v0.47.0 h1:7Kn5x/d1svx/PzryTsqeoZN4TZwqeH5pGWjefhLi/1Q= +golang.org/x/tools v0.47.0/go.mod h1:dFHnyTvFWY212G+h7ZY4Vsp/K3U4/7W9TyVaAul8uCA= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= @@ -1565,8 +1571,10 @@ gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= -gorm.io/gorm v1.31.1 h1:7CA8FTFz/gRfgqgpeKIBcervUn3xSyPUmr6B2WXJ7kg= -gorm.io/gorm v1.31.1/go.mod h1:XyQVbO2k6YkOis7C2437jSit3SsDK72s7n7rsSHd+Gs= +gorm.io/driver/sqlite v1.6.0 h1:WHRRrIiulaPiPFmDcod6prc4l2VGVWHz80KspNsxSfQ= +gorm.io/driver/sqlite v1.6.0/go.mod h1:AO9V1qIQddBESngQUKWL9yoH93HIeA1X6V633rBwyT8= +gorm.io/gorm v1.31.2 h1:3o8FXNo9v9S858gil+3LlZA1LkCOzgb4g5BL64FgaCo= +gorm.io/gorm v1.31.2/go.mod h1:XyQVbO2k6YkOis7C2437jSit3SsDK72s7n7rsSHd+Gs= gotest.tools/v3 v3.5.2 h1:7koQfIKdy+I8UTetycgUqXWSDwpgv193Ka+qRsmBY8Q= gotest.tools/v3 v3.5.2/go.mod h1:LtdLGcnqToBH83WByAAi/wiwSFCArdFIUV/xxN4pcjA= honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= @@ -1576,10 +1584,10 @@ honnef.co/go/tools v0.0.0-20190523083050-ea95bdfd59fc/go.mod h1:rf3lG4BRIbNafJWh honnef.co/go/tools v0.0.1-2019.2.3/go.mod h1:a3bituU0lyd329TUQxRnasdCoJDkEUEAqEt0JzvZhAg= honnef.co/go/tools v0.0.1-2020.1.3/go.mod h1:X/FiERA/W4tHapMX5mGpAtMSVEeEUOyHaw9vFzvIQ3k= honnef.co/go/tools v0.0.1-2020.1.4/go.mod h1:X/FiERA/W4tHapMX5mGpAtMSVEeEUOyHaw9vFzvIQ3k= -modernc.org/cc/v4 v4.27.1 h1:9W30zRlYrefrDV2JE2O8VDtJ1yPGownxciz5rrbQZis= -modernc.org/cc/v4 v4.27.1/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0= -modernc.org/ccgo/v4 v4.32.0 h1:hjG66bI/kqIPX1b2yT6fr/jt+QedtP2fqojG2VrFuVw= -modernc.org/ccgo/v4 v4.32.0/go.mod h1:6F08EBCx5uQc38kMGl+0Nm0oWczoo1c7cgpzEry7Uc0= +modernc.org/cc/v4 v4.28.2 h1:3tQ0lf2ADtoby2EtSP+J7IE2SHwEJdP8ioR59wx7XpY= +modernc.org/cc/v4 v4.28.2/go.mod h1:OnovgIhbbMXMu1aISnJ0wvVD1KnW+cAUJkIrAWh+kVI= +modernc.org/ccgo/v4 v4.34.0 h1:yRLPFZieg532OT4rp4JFNIVcquwalMX26G95WQDqwCQ= +modernc.org/ccgo/v4 v4.34.0/go.mod h1:AS5WYMyBakQ+fhsHhtP8mWB82KTGPkNNJDGfGQCe0/A= modernc.org/fileutil v1.4.0 h1:j6ZzNTftVS054gi281TyLjHPp6CPHr2KCxEXjEbD6SM= modernc.org/fileutil v1.4.0/go.mod h1:EqdKFDxiByqxLk8ozOxObDSfcVOv/54xDs/DUHdvCUU= modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI= @@ -1588,18 +1596,18 @@ modernc.org/gc/v3 v3.1.2 h1:ZtDCnhonXSZexk/AYsegNRV1lJGgaNZJuKjJSWKyEqo= modernc.org/gc/v3 v3.1.2/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY= modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks= modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI= -modernc.org/libc v1.70.0 h1:U58NawXqXbgpZ/dcdS9kMshu08aiA6b7gusEusqzNkw= -modernc.org/libc v1.70.0/go.mod h1:OVmxFGP1CI/Z4L3E0Q3Mf1PDE0BucwMkcXjjLntvHJo= +modernc.org/libc v1.72.3 h1:ZnDF4tXn4NBXFutMMQC4vtbTFSXhhKzR73fv0beZEAU= +modernc.org/libc v1.72.3/go.mod h1:dn0dZNnnn1clLyvRxLxYExxiKRZIRENOfqQ8XEeg4Qs= modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU= modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg= modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI= modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw= -modernc.org/opt v0.1.4 h1:2kNGMRiUjrp4LcaPuLY2PzUfqM/w9N23quVwhKt5Qm8= -modernc.org/opt v0.1.4/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns= +modernc.org/opt v0.2.0 h1:tGyef5ApycA7FSEOMraay9SaTk5zmbx7Tu+cJs4QKZg= +modernc.org/opt v0.2.0/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns= modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w= modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE= -modernc.org/sqlite v1.46.2 h1:gkXQ6R0+AjxFC/fTDaeIVLbNLNrRoOK7YYVz5BKhTcE= -modernc.org/sqlite v1.46.2/go.mod h1:hWjRO6Tj/5Ik8ieqxQybiEOUXy0NJFNp2tpvVpKlvig= +modernc.org/sqlite v1.51.0 h1:aH/MMSoayAIhozZ7uJbVTT9QO/VhzBf0J9tymmmuC/U= +modernc.org/sqlite v1.51.0/go.mod h1:tcNzv5p84E0skkmJn038y+hWJbLQXQqEnQfeh5r2JLM= modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0= modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A= modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y= diff --git a/grype/db/internal/provider/unmarshal/os_vulnerability.go b/grype/db/internal/provider/unmarshal/os_vulnerability.go index c4f59f7dfa7..f44d1c791fd 100644 --- a/grype/db/internal/provider/unmarshal/os_vulnerability.go +++ b/grype/db/internal/provider/unmarshal/os_vulnerability.go @@ -22,9 +22,12 @@ type OSFixedIn struct { } `json:"AdvisorySummary"` NoAdvisory bool `json:"NoAdvisory"` } `json:"VendorAdvisory"` - Version string `json:"Version"` - VersionFormat string `json:"VersionFormat"` - VulnerableRange string `json:"VulnerableRange"` + Version string `json:"Version"` + VersionFormat string `json:"VersionFormat"` + // Arch is set only when an advisory ships a different fix per architecture (e.g. an x86_64 and + // aarch64 build respun at different revisions); nil/absent means the fix applies to all arches. + Arch *string `json:"Arch,omitempty"` + VulnerableRange string `json:"VulnerableRange"` Available struct { Date string `json:"Date,omitempty"` Kind string `json:"Kind,omitempty"` diff --git a/grype/db/internal/provider/unmarshal/osv_vulnerability.go b/grype/db/internal/provider/unmarshal/osv_vulnerability.go index 0fa38e21be0..9e90372ca5a 100644 --- a/grype/db/internal/provider/unmarshal/osv_vulnerability.go +++ b/grype/db/internal/provider/unmarshal/osv_vulnerability.go @@ -3,10 +3,10 @@ package unmarshal import ( "io" - "github.com/google/osv-scanner/pkg/models" + "github.com/anchore/grype/grype/db/internal/provider/unmarshal/osvmodel" ) -type OSVVulnerability = models.Vulnerability +type OSVVulnerability = osvmodel.Vulnerability func OSVVulnerabilityEntries(reader io.Reader) ([]OSVVulnerability, error) { return unmarshalSingleOrMulti[OSVVulnerability](reader) diff --git a/grype/db/internal/provider/unmarshal/osvmodel/generate.go b/grype/db/internal/provider/unmarshal/osvmodel/generate.go new file mode 100644 index 00000000000..201d430928c --- /dev/null +++ b/grype/db/internal/provider/unmarshal/osvmodel/generate.go @@ -0,0 +1,7 @@ +// Package osvmodel is grype's representation of an OSV record. +// +// See vulnerability_generated.go for the actual types. That file is +// regenerated from the pinned schema-v1.json by ./generate/main.go. +package osvmodel + +//go:generate go run ./generate diff --git a/grype/db/internal/provider/unmarshal/osvmodel/generate/main.go b/grype/db/internal/provider/unmarshal/osvmodel/generate/main.go new file mode 100644 index 00000000000..d6301e69ab3 --- /dev/null +++ b/grype/db/internal/provider/unmarshal/osvmodel/generate/main.go @@ -0,0 +1,814 @@ +// This program regenerates the osvmodel package from a pinned JSON Schema. +// +// It writes into the parent osvmodel directory: +// - schema-v1.json pinned upstream schema +// - schema-v1.tag the upstream tag the pinned schema came from +// - vulnerability_generated.go Go model emitted from that schema +// +// Run via `make generate:osv-model` (regenerates from the committed pin) or +// `make update:osv-model` (fetches latest v1 upstream, then regenerates). +// +// Design: this is a small, general JSON-Schema → Go translator with opinions +// baked in. It has no OSV-specific knowledge. Schema changes that stay +// within the JSON Schema features we handle are absorbed automatically; +// anything outside that subset panics with a message naming the schema +// fragment that surprised us, so the cron PR fails loudly and a human knows +// exactly what to extend. +// +// The opinions: +// +// 1. Value types, never pointers. Optional fields get `,omitempty`; +// absent/zero distinctions are not preserved. +// 2. encoding/json tags only. No yaml, mapstructure, omitzero. +// 3. Required vs optional: properties listed in `required` get no +// `,omitempty`; everything else gets it. +// 4. No validation methods generated. +// 5. Field naming: snake_case → PascalCase, with an initialism table +// (id → ID, url → URL, cvss → CVSS, etc.). +// 6. Type naming: $def keys become PascalCase. Inline objects/enums get +// a synthesized name of . +// 7. `format: "date-time"` → time.Time (no Timestamp alias). +// 8. `"type": ["X", "null"]` → "X" (null is dropped). +// 9. `oneOf` where every branch is an object schema with properties → +// flattened into a single struct with all branches' properties (all +// optional). Other oneOf/allOf is stripped — it's validation, not +// structure. +// 10. Object with no properties → `map[string]any` inline at use sites. +// String $defs with no enum → inlined as `string` at use sites. +package main + +import ( + "bytes" + "encoding/json" + "errors" + "flag" + "fmt" + "io" + "net/http" + "os" + "path/filepath" + "runtime" + "sort" + "strings" + "time" + + "github.com/dave/jennifer/jen" +) + +const ( + releasesAPI = "https://api.github.com/repos/ossf/osv-schema/releases/latest" + schemaURLFmt = "https://raw.githubusercontent.com/ossf/osv-schema/%s/validation/schema.json" + pinnedFile = "schema-v1.json" + pinnedTagFile = "schema-v1.tag" + generatedFile = "vulnerability_generated.go" + pkgName = "osvmodel" + requirePrefix = "v1." +) + +// JSON Schema type constants (avoid string literals repeated through the walker). +const ( + jsObject = "object" + jsArray = "array" + jsString = "string" + jsInteger = "integer" + jsNumber = "number" + jsBoolean = "boolean" + fmtDateTim = "date-time" +) + +// ============================================================================ +// Schema +// ============================================================================ + +// Schema is the subset of JSON Schema we read. +type Schema struct { + Type json.RawMessage `json:"type,omitempty"` + Title string `json:"title,omitempty"` + Format string `json:"format,omitempty"` + Enum []string `json:"enum,omitempty"` + Properties map[string]*Schema `json:"properties,omitempty"` + Required []string `json:"required,omitempty"` + Items *Schema `json:"items,omitempty"` + Ref string `json:"$ref,omitempty"` + OneOf []*Schema `json:"oneOf,omitempty"` + Defs map[string]*Schema `json:"$defs,omitempty"` + Description string `json:"description,omitempty"` +} + +// jsonType returns the JSON-Schema "type" as a string, picking the first +// non-null entry from a union. Returns "" if no type is set. +func (s *Schema) jsonType() string { + if len(s.Type) == 0 { + return "" + } + var single string + if err := json.Unmarshal(s.Type, &single); err == nil { + return single + } + var multi []string + if err := json.Unmarshal(s.Type, &multi); err == nil { + for _, t := range multi { + if t != "null" { + return t + } + } + } + return "" +} + +// ============================================================================ +// Generation entry point +// ============================================================================ + +func main() { + if err := run(); err != nil { + fmt.Fprintf(os.Stderr, "error: %v\n", err) + os.Exit(1) + } +} + +func run() error { + pull := flag.Bool("pull", false, + "fetch the latest v1 schema from upstream and overwrite the pinned schema-v1.json before regenerating") + rootName := flag.String("root", "Vulnerability", + "name to use for the top-level struct (defaults to the schema title if empty)") + flag.Parse() + + tag, schemaBytes, err := loadSchema(*pull) + if err != nil { + return err + } + if schemaBytes == nil { + return nil // soft refusal path + } + + var root Schema + if err := json.Unmarshal(schemaBytes, &root); err != nil { + return fmt.Errorf("parse schema JSON: %w", err) + } + collapseOneOfsInPlace(&root) + + out, err := emit(&root, tag, *rootName) + if err != nil { + return fmt.Errorf("emit Go model: %w", err) + } + if err := atomicWrite(filepath.Join(packageDir(), generatedFile), out); err != nil { + return fmt.Errorf("write %s: %w", generatedFile, err) + } + fmt.Printf("regenerated %s from osv-schema %s\n", generatedFile, tag) + return nil +} + +// packageDir returns the absolute path to the osvmodel package. +func packageDir() string { + _, thisFile, _, ok := runtime.Caller(0) + if !ok { + panic("runtime.Caller(0) failed; can't locate generator source") + } + return filepath.Dir(filepath.Dir(thisFile)) +} + +// ============================================================================ +// Schema loading (pinned vs fetched) +// ============================================================================ + +func loadSchema(pull bool) (string, []byte, error) { + if !pull { + data, err := os.ReadFile(filepath.Join(packageDir(), pinnedFile)) + if err != nil { + return "", nil, fmt.Errorf("read pinned %s: %w (run with --pull to fetch upstream)", pinnedFile, err) + } + tagBytes, err := os.ReadFile(filepath.Join(packageDir(), pinnedTagFile)) + if err != nil { + return "", nil, fmt.Errorf("read pinned %s: %w (run with --pull to fetch upstream)", pinnedTagFile, err) + } + return strings.TrimSpace(string(tagBytes)), data, nil + } + + tag, err := latestReleaseTag() + if err != nil { + return "", nil, fmt.Errorf("fetch latest release tag: %w", err) + } + if !strings.HasPrefix(tag, requirePrefix) { + fmt.Fprintf(os.Stderr, + "upstream cut %s; refusing to overwrite the %s* track.\n"+ + " manual steps to handle a major bump:\n"+ + " 1. copy schema-v1.json to schema-v1-final.json (preserve old track)\n"+ + " 2. start a schema-v2.json + vulnerability_v2_generated.go track\n", + tag, requirePrefix) + return tag, nil, nil + } + + body, err := fetchBytes(fmt.Sprintf(schemaURLFmt, tag)) + if err != nil { + return "", nil, fmt.Errorf("fetch schema at %s: %w", tag, err) + } + if err := atomicWrite(filepath.Join(packageDir(), pinnedFile), body); err != nil { + return "", nil, fmt.Errorf("write %s: %w", pinnedFile, err) + } + if err := atomicWrite(filepath.Join(packageDir(), pinnedTagFile), []byte(tag+"\n")); err != nil { + return "", nil, fmt.Errorf("write %s: %w", pinnedTagFile, err) + } + return tag, body, nil +} + +func latestReleaseTag() (string, error) { + req, _ := http.NewRequest(http.MethodGet, releasesAPI, nil) + req.Header.Set("Accept", "application/vnd.github+json") + if t := os.Getenv("GITHUB_TOKEN"); t != "" { + req.Header.Set("Authorization", "Bearer "+t) + } + resp, err := (&http.Client{Timeout: 30 * time.Second}).Do(req) + if err != nil { + return "", err + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + b, _ := io.ReadAll(io.LimitReader(resp.Body, 2048)) + return "", fmt.Errorf("%s: %s: %s", releasesAPI, resp.Status, b) + } + var r struct { + TagName string `json:"tag_name"` + } + if err := json.NewDecoder(resp.Body).Decode(&r); err != nil { + return "", err + } + if r.TagName == "" { + return "", errors.New("no tag_name in release response") + } + return r.TagName, nil +} + +func fetchBytes(url string) ([]byte, error) { + resp, err := (&http.Client{Timeout: 30 * time.Second}).Get(url) + if err != nil { + return nil, err + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("%s: %s", url, resp.Status) + } + return io.ReadAll(resp.Body) +} + +func atomicWrite(path string, data []byte) error { + tmp := path + ".tmp" + if err := os.WriteFile(tmp, data, 0o600); err != nil { + return err + } + return os.Rename(tmp, path) +} + +// ============================================================================ +// Walker + registry +// +// The generator has two phases: +// 1. Discover. Walk the schema, collect every fragment that needs a named +// Go type. $def entries get names from their keys; inline objects and +// inline enums get names synthesized from . +// 2. Emit. For each registered type, emit Go via jen. Field types +// reference other registered types by name. +// ============================================================================ + +type registry struct { + root *entry // top-level type (from schema title) + defs map[string]*entry // ref → entry, e.g. "#/$defs/severity" → Severity + defOrder []string // sorted $def keys, for stable emission + inline []*entry // inline types in discovery order + seen map[*Schema]*entry + usedNames map[string]bool +} + +type entry struct { + name string + schema *Schema +} + +func newRegistry() *registry { + return ®istry{ + defs: map[string]*entry{}, + seen: map[*Schema]*entry{}, + usedNames: map[string]bool{}, + } +} + +func (r *registry) reserve(name string) string { + if !r.usedNames[name] { + r.usedNames[name] = true + return name + } + for i := 2; ; i++ { + candidate := fmt.Sprintf("%s%d", name, i) + if !r.usedNames[candidate] { + r.usedNames[candidate] = true + return candidate + } + } +} + +// registerDef registers a $def under "#/$defs/". +func (r *registry) registerDef(key string, s *Schema) { + name := r.reserve(goTypeName(key)) + e := &entry{name: name, schema: s} + r.defs["#/$defs/"+key] = e + r.seen[s] = e + r.defOrder = append(r.defOrder, key) +} + +// registerRoot registers the root schema under the chosen name (CLI flag, +// falling back to the schema's title, then "Root"). +func (r *registry) registerRoot(s *Schema, rootName string) { + name := rootName + if name == "" { + name = s.Title + } + if name == "" { + name = "Root" + } + name = r.reserve(goTypeName(name)) + e := &entry{name: name, schema: s} + r.root = e + r.seen[s] = e +} + +// registerInline registers a previously-unseen inline object/enum schema. +// preferred is the name we'd like to use (field name, kept short); fallback +// is the parent-prefixed name to fall back to if `preferred` is already in +// use. Returns the assigned Go name. +func (r *registry) registerInline(s *Schema, preferred, fallback string) string { + if existing, ok := r.seen[s]; ok { + return existing.name + } + candidate := goTypeName(preferred) + if r.usedNames[candidate] { + candidate = goTypeName(fallback) + } + name := r.reserve(candidate) + e := &entry{name: name, schema: s} + r.seen[s] = e + r.inline = append(r.inline, e) + return name +} + +// pruneUnreachable drops $def entries that aren't referenced from the root +// (transitively). The OSV schema has $defs that exist only as validation +// constraints (e.g. an `ecosystemName` enum that no field $refs — fields +// $ref a different `ecosystemWithSuffix` regex pattern). Emitting them as +// orphan Go types adds noise without giving callers anything to use. +func (r *registry) pruneUnreachable() { + live := map[*entry]bool{r.root: true} + for _, e := range r.inline { + live[e] = true + } + // Walk the schemas of every live entry; mark every $def it references. + queue := append([]*entry{r.root}, r.inline...) + for len(queue) > 0 { + e := queue[0] + queue = queue[1:] + r.walkRefs(e.schema, func(target *entry) { + if !live[target] { + live[target] = true + queue = append(queue, target) + } + }) + } + // Filter defOrder to live entries only. + keptOrder := make([]string, 0, len(r.defOrder)) + for _, k := range r.defOrder { + if live[r.defs["#/$defs/"+k]] { + keptOrder = append(keptOrder, k) + } + } + r.defOrder = keptOrder +} + +// walkRefs visits every $ref-able schema reachable from s and invokes fn +// with the referenced entry. Used by pruneUnreachable to compute liveness. +func (r *registry) walkRefs(s *Schema, fn func(*entry)) { + if s == nil { + return + } + if s.Ref != "" { + if e, ok := r.defs[s.Ref]; ok { + fn(e) + } + } + for _, p := range s.Properties { + r.walkRefs(p, fn) + } + if s.Items != nil { + r.walkRefs(s.Items, fn) + } +} + +// discover walks every registered type's schema, calling registerInline for +// inline objects/enums that need names. It runs until quiescent: each new +// inline type registered may itself contain further inline types. +func (r *registry) discover() { + visit := func(e *entry) { r.walkForInline(e.schema, e.name) } + visit(r.root) + for _, k := range r.defOrder { + visit(r.defs["#/$defs/"+k]) + } + // inline may grow while we iterate; range over indices. + for i := 0; i < len(r.inline); i++ { + visit(r.inline[i]) + } +} + +// walkForInline walks one schema's fields, registering inline children. +// parent is the Go name of the containing type; we use it as the fallback +// name component when a field's preferred name collides. +func (r *registry) walkForInline(s *Schema, parent string) { + switch s.jsonType() { + case jsObject: + for _, fname := range sortedKeys(s.Properties) { + r.discoverField(s.Properties[fname], fname, parent) + } + case jsArray: + // an array $def is transparent: the element takes the def's own name + // and refs resolve to []. Walk the element's children under + // the def name so inline enums read as Type (e.g. SeverityType), + // not EntryType. The element itself is emitted under the def name + // in emitType, so we don't register a separate type for it here. + if s.Items != nil { + r.walkForInline(s.Items, parent) + } + } +} + +// discoverField walks one property's schema, registering any inline type it +// needs. field is the property's name; parent is the containing Go type +// name (used for collision-fallback naming). +// +// Naming policy: inline objects prefer just the field name (short); on +// collision, fall back to . Inline enums always use +// because property names like "type" repeat across schemas. +// When the property is an array of inline objects, the element type uses +// the singularized field name (so `affected.ranges` items are named +// `Range`, not `Ranges`). +func (r *registry) discoverField(s *Schema, field, parent string) { + switch { + case s.Ref != "": + // refs resolve at use site via r.defs — nothing to register here + case len(s.Enum) > 0 && s.jsonType() == jsString: + // Enum: always parent-prefixed (avoids cross-parent collisions on + // generic field names like "type"). + r.registerInline(s, parent+goTypeName(field), parent+goTypeName(field)) + case s.jsonType() == jsObject && len(s.Properties) > 0: + name := r.registerInline(s, field, parent+goTypeName(field)) + r.walkForInline(s, name) + case s.jsonType() == jsArray && s.Items != nil: + // The element of an array property is conceptually singular, so + // singularize the field name for the element type. + r.discoverField(s.Items, singularize(field), parent) + } +} + +// ============================================================================ +// Emission +// ============================================================================ + +func emit(root *Schema, tag, rootName string) ([]byte, error) { + r := newRegistry() + + // Pre-register every $def so refs resolve during inline discovery. + for _, k := range sortedKeys(root.Defs) { + r.registerDef(k, root.Defs[k]) + } + r.registerRoot(root, rootName) + r.discover() + r.pruneUnreachable() + + f := jen.NewFile(pkgName) + f.HeaderComment("Code generated by ./generate/main.go from osv-schema " + tag + ". DO NOT EDIT.") + f.HeaderComment("Regenerate via: make generate:osv-model") + f.HeaderComment("Source: github.com/ossf/osv-schema@" + tag + " (validation/schema.json)") + f.HeaderComment("") + f.HeaderComment("Package osvmodel is grype's representation of an OSV record.") + + // Emit root, then $defs (alphabetical), then inline (discovery order). + emitType(f, r.root, r) + for _, k := range r.defOrder { + emitType(f, r.defs["#/$defs/"+k], r) + } + for _, e := range r.inline { + emitType(f, e, r) + } + + var buf bytes.Buffer + if err := f.Render(&buf); err != nil { + return nil, err + } + return buf.Bytes(), nil +} + +// emitType writes the Go type declaration for a single entry. Schemas that +// add no information at the type level (a string $def with no enum; a +// free-form object $def with no properties; a date-time-shaped string $def) +// are skipped — at use sites we substitute the underlying primitive directly. +// Emitting orphan aliases makes the generated file longer without giving +// callers anything to use. +func emitType(f *jen.File, e *entry, r *registry) { + s := e.schema + switch { + case len(s.Enum) > 0: + emitEnum(f, e.name, s) + case s.jsonType() == jsObject && len(s.Properties) > 0: + emitStruct(f, e.name, s, r) + case s.jsonType() == jsArray && namedArrayElement(s): + // transparent array def: emit the element under the def's name (refs + // resolve to []), so `severity` becomes a Severity element + // struct used as []Severity — consistent with every other collection. + if len(s.Items.Enum) > 0 { + emitEnum(f, e.name, s.Items) + } else { + emitStruct(f, e.name, s.Items, r) + } + case s.jsonType() == jsArray && s.Items != nil: + // primitive-element array def: a named slice alias (no element type). + f.Type().Id(e.name).Index().Add(goType(s.Items, r)) + // no string/object aliases (inlined at use sites) + } +} + +func emitStruct(f *jen.File, name string, s *Schema, r *registry) { + required := map[string]bool{} + for _, p := range s.Required { + required[p] = true + } + fields := make([]jen.Code, 0, len(s.Properties)) + for _, propName := range sortedKeys(s.Properties) { + tag := propName + if !required[propName] { + tag += ",omitempty" + } + fields = append(fields, + jen.Id(goFieldName(propName)). + Add(goType(s.Properties[propName], r)). + Tag(map[string]string{"json": tag}), + ) + } + f.Type().Id(name).Struct(fields...) +} + +func emitEnum(f *jen.File, name string, s *Schema) { + f.Type().Id(name).String() + values := append([]string{}, s.Enum...) + sort.Strings(values) + prefix := strings.TrimSuffix(name, "Type") + f.Const().DefsFunc(func(g *jen.Group) { + for _, v := range values { + g.Id(prefix + enumValueSuffix(v)).Id(name).Op("=").Lit(v) + } + }) +} + +// goType returns the jen.Code for the Go type of a schema fragment (a field +// type, array element, etc). For refs and inline named types, it returns an +// identifier. For primitives, it returns the primitive type directly. +func goType(s *Schema, r *registry) jen.Code { + if s.Ref != "" { + e, ok := r.defs[s.Ref] + if !ok { + panic(fmt.Sprintf("unknown $ref %s — upstream may have changed shape", s.Ref)) + } + return refType(e) + } + if e, ok := r.seen[s]; ok { + return jen.Id(e.name) + } + + switch s.jsonType() { + case jsString: + if s.Format == fmtDateTim { + return jen.Qual("time", "Time") + } + return jen.String() + case jsInteger: + return jen.Int() + case jsNumber: + return jen.Float64() + case jsBoolean: + return jen.Bool() + case jsArray: + if s.Items == nil { + return jen.Index().Any() + } + return jen.Index().Add(goType(s.Items, r)) + case jsObject: + // inline free-form object → map[string]any + return jen.Map(jen.String()).Any() + } + // Unknown / underspecified — fall back to any. + return jen.Any() +} + +// refType returns the Go type for a $ref target. For "primitive-ish" $defs +// (a plain string alias, or an array alias whose element is a primitive) we +// emit the underlying type inline at use sites — callers don't see a +// typed-string wrapper unless the def actually carries an enum. +func refType(e *entry) jen.Code { + s := e.schema + if s.jsonType() == jsString && len(s.Enum) == 0 && s.Format != fmtDateTim { + return jen.String() + } + if s.jsonType() == jsString && s.Format == fmtDateTim { + return jen.Qual("time", "Time") + } + if s.jsonType() == jsArray && namedArrayElement(s) { + // transparent array def: the element is named after the def and is + // referenced as a slice (e.g. severity → []Severity). + return jen.Index().Add(jen.Id(e.name)) + } + return jen.Id(e.name) +} + +// namedArrayElement reports whether an array schema's element warrants its own +// named Go type (an object with properties or an enum), as opposed to a +// primitive that's inlined. Such array $defs are emitted transparently: the +// element takes the def's name and refs resolve to []. +func namedArrayElement(s *Schema) bool { + if s.Items == nil { + return false + } + it := s.Items + return len(it.Enum) > 0 || (it.jsonType() == jsObject && len(it.Properties) > 0) +} + +// ============================================================================ +// oneOf merging (one-shot in-place pre-pass) +// ============================================================================ + +// collapseOneOfsInPlace walks the schema and, wherever a node's oneOf branches +// are all object schemas with properties, merges those properties into the +// node and clears the oneOf. Mutating in place keeps schema-pointer identity +// stable across the discover/emit phases — the registry uses pointers as keys. +// Non-mergeable oneOfs are left alone (we'll panic on them if they actually +// affect emission, which means a human needs to look). +func collapseOneOfsInPlace(s *Schema) { + if s == nil { + return + } + // Recurse first so nested branches collapse before we look at this one. + for _, p := range s.Properties { + collapseOneOfsInPlace(p) + } + if s.Items != nil { + collapseOneOfsInPlace(s.Items) + } + for _, d := range s.Defs { + collapseOneOfsInPlace(d) + } + for _, b := range s.OneOf { + collapseOneOfsInPlace(b) + } + + if len(s.OneOf) == 0 { + return + } + for _, b := range s.OneOf { + if b.jsonType() != jsObject || len(b.Properties) == 0 { + return // not all object-with-properties; leave oneOf untouched + } + } + if s.Properties == nil { + s.Properties = map[string]*Schema{} + } + for _, b := range s.OneOf { + for k, v := range b.Properties { + if _, exists := s.Properties[k]; !exists { + s.Properties[k] = v + } + } + } + s.OneOf = nil + if s.jsonType() == "" { + s.Type = json.RawMessage(`"object"`) + } +} + +// ============================================================================ +// Naming +// ============================================================================ + +// initialisms records identifier fragments that should keep canonical casing +// rather than being title-cased. Add new ones as needed. +var initialisms = map[string]string{ + "id": "ID", + "url": "URL", + "cvss": "CVSS", + "cpe": "CPE", + "cpes": "CPEs", + "osv": "OSV", + "semver": "SemVer", // not strictly an initialism, but a recognized compound +} + +// goTypeName converts a $def key or schema title into a Go type identifier. +func goTypeName(s string) string { + return pascalCase(s) +} + +// goFieldName converts a JSON property name into a Go field identifier. +func goFieldName(s string) string { + return pascalCase(s) +} + +// pascalCase splits on _ and non-letter-digit boundaries, then title-cases +// each part (applying the initialisms table). For ALL-CAPS parts the tail +// is lowercased so "SEMVER" → "Semver" (and via the initialism table, +// "SemVer"); MixedCase parts are left alone so "RangeType" stays +// "RangeType" instead of becoming "Rangetype". +func pascalCase(s string) string { + parts := splitIdent(s) + for i, p := range parts { + if p == "" { + continue + } + if up, ok := initialisms[strings.ToLower(p)]; ok { + parts[i] = up + continue + } + head := strings.ToUpper(p[:1]) + tail := p[1:] + if isAllUpper(p) { + tail = strings.ToLower(tail) + } + parts[i] = head + tail + } + return strings.Join(parts, "") +} + +// isAllUpper reports whether s contains no lowercase letters (digits and +// other characters are tolerated). +func isAllUpper(s string) bool { + for _, c := range s { + if c >= 'a' && c <= 'z' { + return false + } + } + return true +} + +// splitIdent breaks a string on _, -, space, and dot. Used to normalize +// $def keys ("affected_package"), enum values ("CVSS_V2"), and titles +// ("Open Source Vulnerability") into PascalCase-able parts. +func splitIdent(s string) []string { + var parts []string + cur := strings.Builder{} + flush := func() { + if cur.Len() > 0 { + parts = append(parts, cur.String()) + cur.Reset() + } + } + for _, c := range s { + switch c { + case '_', '-', ' ', '.', '/': + flush() + default: + cur.WriteRune(c) + } + } + flush() + return parts +} + +// enumValueSuffix returns the suffix appended to the enum type name to form +// a constant identifier. pascalCase handles the casing (with initialism +// awareness: "CVSS_V2" → "CVSSV2", "SEMVER" → "SemVer", "ECOSYSTEM" → +// "Ecosystem"). Mixed-case inputs like "Ubuntu" stay as-is. +func enumValueSuffix(v string) string { + return pascalCase(v) +} + +// singularize trims a trailing "s" from a plural-looking identifier so the +// element type of an array reads naturally ("ranges" → "Range"). The rule +// is intentionally narrow: only strips a single trailing 's', and only when +// it's not preceded by another 's' (avoiding mangling words ending in "ss"). +// Good enough for OSV-style schemas where field names are simple plurals. +func singularize(s string) string { + if len(s) < 2 { + return s + } + if s[len(s)-1] != 's' { + return s + } + if s[len(s)-2] == 's' { + return s + } + return s[:len(s)-1] +} + +// ============================================================================ +// Helpers +// ============================================================================ + +func sortedKeys[V any](m map[string]V) []string { + keys := make([]string, 0, len(m)) + for k := range m { + keys = append(keys, k) + } + sort.Strings(keys) + return keys +} diff --git a/grype/db/internal/provider/unmarshal/osvmodel/schema-v1.json b/grype/db/internal/provider/unmarshal/osvmodel/schema-v1.json new file mode 100644 index 00000000000..1a3999ad97a --- /dev/null +++ b/grype/db/internal/provider/unmarshal/osvmodel/schema-v1.json @@ -0,0 +1,502 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://raw.githubusercontent.com/ossf/osv-schema/main/validation/schema.json", + "title": "Open Source Vulnerability", + "description": "A schema for describing a vulnerability in an open source package. See also https://ossf.github.io/osv-schema/", + "type": "object", + "properties": { + "schema_version": { + "type": "string" + }, + "id": { + "$ref": "#/$defs/prefix" + }, + "modified": { + "$ref": "#/$defs/timestamp" + }, + "published": { + "$ref": "#/$defs/timestamp" + }, + "withdrawn": { + "$ref": "#/$defs/timestamp" + }, + "aliases": { + "type": [ + "array", + "null" + ], + "items": { + "type": "string" + } + }, + "related": { + "type": "array", + "items": { + "type": "string" + } + }, + "upstream": { + "type": "array", + "items": { + "type": "string" + } + }, + "summary": { + "type": "string" + }, + "details": { + "type": "string" + }, + "severity": { + "$ref": "#/$defs/severity" + }, + "affected": { + "type": [ + "array", + "null" + ], + "items": { + "type": "object", + "properties": { + "package": { + "type": "object", + "properties": { + "ecosystem": { + "$ref": "#/$defs/ecosystemWithSuffix" + }, + "name": { + "type": "string" + }, + "purl": { + "type": "string" + } + }, + "required": [ + "ecosystem", + "name" + ] + }, + "severity": { + "$ref": "#/$defs/severity" + }, + "ranges": { + "type": "array", + "items": { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "GIT", + "SEMVER", + "ECOSYSTEM" + ] + }, + "repo": { + "type": "string" + }, + "events": { + "title": "events must contain an introduced object and may contain fixed, last_affected or limit objects", + "type": "array", + "contains": { + "required": [ + "introduced" + ] + }, + "items": { + "type": "object", + "oneOf": [ + { + "type": "object", + "properties": { + "introduced": { + "type": "string" + } + }, + "required": [ + "introduced" + ] + }, + { + "type": "object", + "properties": { + "fixed": { + "type": "string" + } + }, + "required": [ + "fixed" + ] + }, + { + "type": "object", + "properties": { + "last_affected": { + "type": "string" + } + }, + "required": [ + "last_affected" + ] + }, + { + "type": "object", + "properties": { + "limit": { + "type": "string" + } + }, + "required": [ + "limit" + ] + } + ] + }, + "minItems": 1 + }, + "database_specific": { + "type": "object" + } + }, + "allOf": [ + { + "title": "GIT ranges require a repo", + "if": { + "properties": { + "type": { + "const": "GIT" + } + } + }, + "then": { + "required": [ + "repo" + ] + } + }, + { + "title": "last_affected and fixed events are mutually exclusive", + "if": { + "properties": { + "events": { + "contains": { + "required": [ + "last_affected" + ] + } + } + } + }, + "then": { + "not": { + "properties": { + "events": { + "contains": { + "required": [ + "fixed" + ] + } + } + } + } + } + } + ], + "required": [ + "type", + "events" + ] + } + }, + "versions": { + "type": "array", + "items": { + "type": "string" + } + }, + "ecosystem_specific": { + "type": "object" + }, + "database_specific": { + "type": "object" + } + } + } + }, + "references": { + "type": [ + "array", + "null" + ], + "items": { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "ADVISORY", + "ARTICLE", + "DETECTION", + "DISCUSSION", + "REPORT", + "FIX", + "INTRODUCED", + "GIT", + "PACKAGE", + "EVIDENCE", + "WEB" + ] + }, + "url": { + "type": "string", + "format": "uri" + } + }, + "required": [ + "type", + "url" + ] + } + }, + "credits": { + "type": "array", + "items": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "contact": { + "type": "array", + "items": { + "type": "string" + } + }, + "type": { + "type": "string", + "enum": [ + "FINDER", + "REPORTER", + "ANALYST", + "COORDINATOR", + "REMEDIATION_DEVELOPER", + "REMEDIATION_REVIEWER", + "REMEDIATION_VERIFIER", + "TOOL", + "SPONSOR", + "OTHER" + ] + } + }, + "required": [ + "name" + ] + } + }, + "database_specific": { + "type": "object" + } + }, + "required": [ + "id", + "modified" + ], + "allOf": [ + { + "if": { + "required": [ + "severity" + ] + }, + "then": { + "properties": { + "affected": { + "items": { + "properties": { + "severity": { + "type": "null" + } + } + } + } + } + } + } + ], + "$defs": { + "ecosystemName": { + "type": "string", + "title": "Currently supported ecosystems", + "description": "These ecosystems are also documented at https://ossf.github.io/osv-schema/#affectedpackage-field", + "enum": [ + "AlmaLinux", + "Alpaquita", + "Alpine", + "Android", + "BellSoft Hardened Containers", + "Bioconductor", + "Bitnami", + "Chainguard", + "CleanStart", + "ConanCenter", + "CRAN", + "crates.io", + "Debian", + "Docker Hardened Images", + "Echo", + "FreeBSD", + "GHC", + "GitHub Actions", + "Go", + "Hackage", + "Hex", + "Julia", + "Kubernetes", + "Linux", + "Mageia", + "Maven", + "MinimOS", + "npm", + "NuGet", + "opam", + "openEuler", + "openSUSE", + "OSS-Fuzz", + "Packagist", + "Photon OS", + "Pub", + "PyPI", + "Red Hat", + "Rocky Linux", + "Root", + "RubyGems", + "SUSE", + "SwiftURL", + "Ubuntu", + "VSCode", + "Wolfi" + ] + }, + "ecosystemSuffix": { + "type": "string", + "pattern": ":.+" + }, + "ecosystemWithSuffix": { + "type": "string", + "title": "Currently supported ecosystems", + "description": "These ecosystems are also documented at https://ossf.github.io/osv-schema/#affectedpackage-field", + "pattern": "^(AlmaLinux|Alpaquita|Alpine|Android|BellSoft Hardened Containers|Bioconductor|Bitnami|Chainguard|CleanStart|ConanCenter|CRAN|crates\\.io|Debian|Docker Hardened Images|Echo|FreeBSD|GHC|GitHub Actions|Go|Hackage|Hex|Julia|Kubernetes|Linux|Mageia|Maven|MinimOS|npm|NuGet|opam|openEuler|openSUSE|OSS-Fuzz|Packagist|Photon OS|Pub|PyPI|Red Hat|Rocky Linux|Root|RubyGems|SUSE|SwiftURL|Ubuntu|VSCode|Wolfi|GIT)(:.+)?$" + }, + "prefix": { + "type": "string", + "title": "Currently supported home database identifier prefixes", + "description": "These home databases are also documented at https://ossf.github.io/osv-schema/#id-modified-fields", + "pattern": "^(ASB-A|PUB-A|ALPINE|ALSA|ALBA|ALEA|BELL|BIT|CGA|CLEANSTART|CURL|CVE|DEBIAN|DHI|DRUPAL|DSA|DLA|ELA|DTSA|ECHO|EEF|FreeBSD|GHSA|GO|GSD|HSEC|JLSEC|KUBE|LBSEC|LSN|MAL|MINI|MGASA|OESA|OSEC|OSV|openSUSE-SU|PHSA|PSF|PYSEC|RHBA|RHEA|RHSA|RLSA|RXSA|RSEC|ROOT|RUSTSEC|SUSE-[SRFO]U|UBUNTU|USN|V8)-" + }, + "severity": { + "type": [ + "array", + "null" + ], + "items": { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "CVSS_V2", + "CVSS_V3", + "CVSS_V4", + "Ubuntu" + ] + }, + "score": { + "type": "string" + } + }, + "allOf": [ + { + "if": { + "properties": { + "type": { + "const": "CVSS_V2" + } + } + }, + "then": { + "properties": { + "score": { + "pattern": "^((AV:[NAL]|AC:[LMH]|Au:[MSN]|[CIA]:[NPC]|E:(U|POC|F|H|ND)|RL:(OF|TF|W|U|ND)|RC:(UC|UR|C|ND)|CDP:(N|L|LM|MH|H|ND)|TD:(N|L|M|H|ND)|[CIA]R:(L|M|H|ND))/)*(AV:[NAL]|AC:[LMH]|Au:[MSN]|[CIA]:[NPC]|E:(U|POC|F|H|ND)|RL:(OF|TF|W|U|ND)|RC:(UC|UR|C|ND)|CDP:(N|L|LM|MH|H|ND)|TD:(N|L|M|H|ND)|[CIA]R:(L|M|H|ND))$" + } + } + } + }, + { + "if": { + "properties": { + "type": { + "const": "CVSS_V3" + } + } + }, + "then": { + "properties": { + "score": { + "pattern": "^CVSS:3[.][01]/((AV:[NALP]|AC:[LH]|PR:[NLH]|UI:[NR]|S:[UC]|[CIA]:[NLH]|E:[XUPFH]|RL:[XOTWU]|RC:[XURC]|[CIA]R:[XLMH]|MAV:[XNALP]|MAC:[XLH]|MPR:[XNLH]|MUI:[XNR]|MS:[XUC]|M[CIA]:[XNLH])/)*(AV:[NALP]|AC:[LH]|PR:[NLH]|UI:[NR]|S:[UC]|[CIA]:[NLH]|E:[XUPFH]|RL:[XOTWU]|RC:[XURC]|[CIA]R:[XLMH]|MAV:[XNALP]|MAC:[XLH]|MPR:[XNLH]|MUI:[XNR]|MS:[XUC]|M[CIA]:[XNLH])$" + } + } + } + }, + { + "if": { + "properties": { + "type": { + "const": "CVSS_V4" + } + } + }, + "then": { + "properties": { + "score": { + "pattern": "^CVSS:4[.]0/AV:[NALP]/AC:[LH]/AT:[NP]/PR:[NLH]/UI:[NPA]/VC:[HLN]/VI:[HLN]/VA:[HLN]/SC:[HLN]/SI:[HLN]/SA:[HLN](/E:[XAPU])?(/CR:[XHML])?(/IR:[XHML])?(/AR:[XHML])?(/MAV:[XNALP])?(/MAC:[XLH])?(/MAT:[XNP])?(/MPR:[XNLH])?(/MUI:[XNPA])?(/MVC:[XNLH])?(/MVI:[XNLH])?(/MVA:[XNLH])?(/MSC:[XNLH])?(/MSI:[XNLHS])?(/MSA:[XNLHS])?(/S:[XNP])?(/AU:[XNY])?(/R:[XAUI])?(/V:[XDC])?(/RE:[XLMH])?(/U:(X|Clear|Green|Amber|Red))?$" + } + } + } + }, + { + "if": { + "properties": { + "type": { + "const": "Ubuntu" + } + } + }, + "then": { + "properties": { + "score": { + "enum": [ + "negligible", + "low", + "medium", + "high", + "critical" + ] + } + } + } + } + ], + "required": [ + "type", + "score" + ] + } + }, + "timestamp": { + "type": "string", + "format": "date-time", + "pattern": "[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}(\\.[0-9]+)?Z" + } + }, + "additionalProperties": false +} diff --git a/grype/db/internal/provider/unmarshal/osvmodel/schema-v1.tag b/grype/db/internal/provider/unmarshal/osvmodel/schema-v1.tag new file mode 100644 index 00000000000..535cc4dda67 --- /dev/null +++ b/grype/db/internal/provider/unmarshal/osvmodel/schema-v1.tag @@ -0,0 +1 @@ +v1.7.5 diff --git a/grype/db/internal/provider/unmarshal/osvmodel/vulnerability_generated.go b/grype/db/internal/provider/unmarshal/osvmodel/vulnerability_generated.go new file mode 100644 index 00000000000..a4115547e04 --- /dev/null +++ b/grype/db/internal/provider/unmarshal/osvmodel/vulnerability_generated.go @@ -0,0 +1,112 @@ +// Code generated by ./generate/main.go from osv-schema v1.7.5. DO NOT EDIT. +// Regenerate via: make generate:osv-model +// Source: github.com/ossf/osv-schema@v1.7.5 (validation/schema.json) +// +// Package osvmodel is grype's representation of an OSV record. + +package osvmodel + +import "time" + +type Vulnerability struct { + Affected []Affected `json:"affected,omitempty"` + Aliases []string `json:"aliases,omitempty"` + Credits []Credit `json:"credits,omitempty"` + DatabaseSpecific map[string]any `json:"database_specific,omitempty"` + Details string `json:"details,omitempty"` + ID string `json:"id"` + Modified time.Time `json:"modified"` + Published time.Time `json:"published,omitempty"` + References []Reference `json:"references,omitempty"` + Related []string `json:"related,omitempty"` + SchemaVersion string `json:"schema_version,omitempty"` + Severity []Severity `json:"severity,omitempty"` + Summary string `json:"summary,omitempty"` + Upstream []string `json:"upstream,omitempty"` + Withdrawn time.Time `json:"withdrawn,omitempty"` +} +type Severity struct { + Score string `json:"score"` + Type SeverityType `json:"type"` +} +type Affected struct { + DatabaseSpecific map[string]any `json:"database_specific,omitempty"` + EcosystemSpecific map[string]any `json:"ecosystem_specific,omitempty"` + Package Package `json:"package,omitempty"` + Ranges []Range `json:"ranges,omitempty"` + Severity []Severity `json:"severity,omitempty"` + Versions []string `json:"versions,omitempty"` +} +type Package struct { + Ecosystem string `json:"ecosystem"` + Name string `json:"name"` + Purl string `json:"purl,omitempty"` +} +type Range struct { + DatabaseSpecific map[string]any `json:"database_specific,omitempty"` + Events []Event `json:"events"` + Repo string `json:"repo,omitempty"` + Type RangeType `json:"type"` +} +type Event struct { + Fixed string `json:"fixed,omitempty"` + Introduced string `json:"introduced,omitempty"` + LastAffected string `json:"last_affected,omitempty"` + Limit string `json:"limit,omitempty"` +} +type RangeType string + +const ( + RangeEcosystem RangeType = "ECOSYSTEM" + RangeGit RangeType = "GIT" + RangeSemVer RangeType = "SEMVER" +) + +type Credit struct { + Contact []string `json:"contact,omitempty"` + Name string `json:"name"` + Type CreditType `json:"type,omitempty"` +} +type CreditType string + +const ( + CreditAnalyst CreditType = "ANALYST" + CreditCoordinator CreditType = "COORDINATOR" + CreditFinder CreditType = "FINDER" + CreditOther CreditType = "OTHER" + CreditRemediationDeveloper CreditType = "REMEDIATION_DEVELOPER" + CreditRemediationReviewer CreditType = "REMEDIATION_REVIEWER" + CreditRemediationVerifier CreditType = "REMEDIATION_VERIFIER" + CreditReporter CreditType = "REPORTER" + CreditSponsor CreditType = "SPONSOR" + CreditTool CreditType = "TOOL" +) + +type Reference struct { + Type ReferenceType `json:"type"` + URL string `json:"url"` +} +type ReferenceType string + +const ( + ReferenceAdvisory ReferenceType = "ADVISORY" + ReferenceArticle ReferenceType = "ARTICLE" + ReferenceDetection ReferenceType = "DETECTION" + ReferenceDiscussion ReferenceType = "DISCUSSION" + ReferenceEvidence ReferenceType = "EVIDENCE" + ReferenceFix ReferenceType = "FIX" + ReferenceGit ReferenceType = "GIT" + ReferenceIntroduced ReferenceType = "INTRODUCED" + ReferencePackage ReferenceType = "PACKAGE" + ReferenceReport ReferenceType = "REPORT" + ReferenceWeb ReferenceType = "WEB" +) + +type SeverityType string + +const ( + SeverityCVSSV2 SeverityType = "CVSS_V2" + SeverityCVSSV3 SeverityType = "CVSS_V3" + SeverityCVSSV4 SeverityType = "CVSS_V4" + SeverityUbuntu SeverityType = "Ubuntu" +) diff --git a/grype/db/provider/workspace.go b/grype/db/provider/workspace.go index 664b5dea5ec..0e835ee53eb 100644 --- a/grype/db/provider/workspace.go +++ b/grype/db/provider/workspace.go @@ -56,16 +56,10 @@ func (w Workspace) ReadState() (*State, error) { return ReadState(w.StatePath()) } -// EnsureListingFile checks if a listing.xxh64 file exists in the results directory. -// If it doesn't exist, generates one by hashing all result files. +// EnsureListingFile (re)generates the results directory's listing.xxh64 from the +// result files currently on disk. Regenerate unconditionally to make committing a mismatched +// file less likely. func (w Workspace) EnsureListingFile() error { - listingPath := w.ListingPath() - - // check if listing file already exists - if _, err := os.Stat(listingPath); err == nil { - return nil - } - resultsDir := w.ResultsPath() // check if results directory exists @@ -74,8 +68,7 @@ func (w Workspace) EnsureListingFile() error { return nil } - // generate listing file - return GenerateListingFile(resultsDir, listingPath) + return GenerateListingFile(resultsDir, w.ListingPath()) } // GenerateListingFile creates a listing.xxh64 file with hashes of all result files. diff --git a/grype/db/v6/architecture_alias_store.go b/grype/db/v6/architecture_alias_store.go new file mode 100644 index 00000000000..b2001c74583 --- /dev/null +++ b/grype/db/v6/architecture_alias_store.go @@ -0,0 +1,42 @@ +package v6 + +import ( + "fmt" + + "gorm.io/gorm" +) + +type ArchitectureAliasStoreReader interface { + // GetArchitectureAliases returns the architecture alias table (alias spelling -> canonical + // token), used by the architecture qualifier to fold dialect spellings at match time. + GetArchitectureAliases() (map[string]string, error) +} + +type architectureAliasStore struct { + db *gorm.DB +} + +func newArchitectureAliasStore(db *gorm.DB) *architectureAliasStore { + return &architectureAliasStore{db: db} +} + +// GetArchitectureAliases returns the architecture alias table as a map of alias spelling to +// canonical token. A database built before this table existed has no such table; that is not +// an error — an empty map is returned, which the architecture qualifier reads as "fall back to +// the built-in default aliases". +func (s *architectureAliasStore) GetArchitectureAliases() (map[string]string, error) { + if !s.db.Migrator().HasTable(&ArchitectureAlias{}) { + return map[string]string{}, nil + } + + var rows []ArchitectureAlias + if err := s.db.Find(&rows).Error; err != nil { + return nil, fmt.Errorf("unable to read architecture aliases: %w", err) + } + + out := make(map[string]string, len(rows)) + for _, r := range rows { + out[r.Alias] = r.Canonical + } + return out, nil +} diff --git a/grype/db/v6/architecture_alias_store_test.go b/grype/db/v6/architecture_alias_store_test.go new file mode 100644 index 00000000000..308b1c8a69c --- /dev/null +++ b/grype/db/v6/architecture_alias_store_test.go @@ -0,0 +1,31 @@ +package v6 + +import ( + "testing" + + "github.com/stretchr/testify/require" + + "github.com/anchore/grype/grype/pkg/qualifier/architecture" +) + +func TestArchitectureAliasStore_GetArchitectureAliases_SeededDefaults(t *testing.T) { + // setupTestStore opens an empty+writable DB, which seeds InitialData (including the + // architecture aliases). The read-back must equal the build-time default table. + s := setupTestStore(t) + + got, err := s.GetArchitectureAliases() + require.NoError(t, err) + require.Equal(t, architecture.DefaultAliases(), got) +} + +func TestArchitectureAliasStore_GetArchitectureAliases_MissingTableIsEmptyNotError(t *testing.T) { + // a database built before the architecture_aliases table existed has no such table; that + // must read as an empty map (the qualifier's signal to fall back to built-in defaults), + // never an error. + s := setupTestStore(t) + require.NoError(t, s.db.Migrator().DropTable(&ArchitectureAlias{})) + + got, err := s.GetArchitectureAliases() + require.NoError(t, err) + require.Empty(t, got) +} diff --git a/grype/db/v6/blobs.go b/grype/db/v6/blobs.go index 3aa826f6c2d..0a65871f6fb 100644 --- a/grype/db/v6/blobs.go +++ b/grype/db/v6/blobs.go @@ -150,10 +150,11 @@ type PackageQualifiers struct { // Architecture is the architecture of the affected RPM, copied from the source PURL's // `arch` qualifier when present (e.g. "src", "x86_64") or set to a synthesized sentinel // like "binary-no-arch-specified" when a binary RPM was disclosed without an explicit - // arch. Upstream-indirected RPM matches drop entries tagged with any arch other than - // "src", so providers that disclose at binary granularity (e.g. hummingbird CSAF VEX) - // avoid FP-matching unrelated sibling binaries built from the same source. See - // pkg/qualifier/architecture for the constants. + // arch. At match time the architecture qualifier compares this value against the scanned + // package's arch (see pkg/qualifier/architecture, Satisfied): a "binary-no-arch-specified" + // entry matches any binary package but rejects the rpm matcher's synthesized "src" upstream, + // so providers that disclose at binary granularity (e.g. hummingbird CSAF VEX) avoid + // FP-matching unrelated sibling binaries built from the same source. Architecture *string `json:"architecture,omitempty"` // RootIO indicates that the vulnerability applies only to Root IO packages (packages with Root IO fixes). diff --git a/grype/db/v6/build/transformers/csafvex/transform.go b/grype/db/v6/build/transformers/csafvex/transform.go index 9b5cbd8d2d8..ade54a91e5b 100644 --- a/grype/db/v6/build/transformers/csafvex/transform.go +++ b/grype/db/v6/build/transformers/csafvex/transform.go @@ -486,8 +486,12 @@ func getOperatingSystem(productID string, idx *productIndex) *db.OperatingSystem return osFromCPE(cpe) } +// rollingLabel marks a rolling-release distro row, matching what the os transformer emits +// for archlinux. +const rollingLabel = "rolling" + // osFromCPE extracts OS name and version from a CPE string. -// Example: "cpe:/a:redhat:hummingbird:1" → name=hummingbird, majorVersion=1 +// Example: "cpe:/a:redhat:hummingbird:1" → name=hummingbird, labelVersion=rolling // Example: "cpe:/o:redhat:enterprise_linux:9" → name=redhat:enterprise_linux, majorVersion=9 func osFromCPE(cpe string) *db.OperatingSystem { // handle both cpe:/ and cpe:2.3: formats @@ -516,6 +520,13 @@ func osFromCPE(cpe string) *db.OperatingSystem { Name: strings.ToLower(osName), } + // for rolling distros the CPE version is a product-generation marker, not an OS release; + // label the row "rolling" instead of recording a major version + if isRollingDistro(os.Name) { + os.LabelVersion = rollingLabel + return os + } + if len(parts) > 3 && parts[3] != "" && parts[3] != "*" { versionParts := strings.SplitN(parts[3], ".", 2) os.MajorVersion = versionParts[0] @@ -527,6 +538,24 @@ func osFromCPE(cpe string) *db.OperatingSystem { return os } +// isRollingDistro reports whether the named distro is an unconditional rolling release per +// db.KnownOperatingSystemSpecifierOverrides (the source of truth). Version-conditional rolling +// overrides (e.g. alpine's _alpha pattern) are excluded since they depend on version data. +func isRollingDistro(name string) bool { + for _, o := range db.KnownOperatingSystemSpecifierOverrides() { + if !o.Rolling { + continue + } + if o.Version != "" || o.VersionPattern != "" || o.Codename != "" { + continue + } + if strings.EqualFold(o.Alias, name) { + return true + } + } + return false +} + func versionTypeFromPURL(purl *packageurl.PackageURL) string { return strings.ToLower(version.ParseFormat(purl.Type).String()) } diff --git a/grype/db/v6/build/transformers/csafvex/transform_test.go b/grype/db/v6/build/transformers/csafvex/transform_test.go index 342a0e2e342..ddcec4d930e 100644 --- a/grype/db/v6/build/transformers/csafvex/transform_test.go +++ b/grype/db/v6/build/transformers/csafvex/transform_test.go @@ -184,7 +184,8 @@ func TestTransform_FixedAndNotAffected(t *testing.T) { require.Equal(t, "rpm", aph.Package.Ecosystem) require.NotNil(t, aph.OperatingSystem) require.Equal(t, "hummingbird", aph.OperatingSystem.Name) - require.Equal(t, "1", aph.OperatingSystem.MajorVersion) + require.Empty(t, aph.OperatingSystem.MajorVersion, "hummingbird is rolling; no major version") + require.Equal(t, "rolling", aph.OperatingSystem.LabelVersion) require.Len(t, aph.BlobValue.Ranges, 1) require.Equal(t, "< 1.2.3-1.hum1", aph.BlobValue.Ranges[0].Version.Constraint) require.Equal(t, "rpm", aph.BlobValue.Ranges[0].Version.Type) @@ -272,13 +273,14 @@ func TestTransform_OSFromCPE(t *testing.T) { wantName string wantMajor string wantMinor string + wantLabel string wantNil bool }{ { - name: "hummingbird URI format", + name: "hummingbird URI format (rolling, version → rolling label)", cpe: "cpe:/a:redhat:hummingbird:1", wantName: "hummingbird", - wantMajor: "1", + wantLabel: "rolling", }, { name: "RHEL URI format", @@ -304,9 +306,10 @@ func TestTransform_OSFromCPE(t *testing.T) { wantNil: true, }, { - name: "CPE with wildcard version", - cpe: "cpe:/a:redhat:hummingbird:*", - wantName: "hummingbird", + name: "CPE with wildcard version", + cpe: "cpe:/a:redhat:hummingbird:*", + wantName: "hummingbird", + wantLabel: "rolling", }, } @@ -320,6 +323,7 @@ func TestTransform_OSFromCPE(t *testing.T) { require.NotNil(t, got) require.Equal(t, tt.wantName, got.Name) require.Equal(t, tt.wantMajor, got.MajorVersion) + require.Equal(t, tt.wantLabel, got.LabelVersion) if tt.wantMinor != "" { require.Equal(t, tt.wantMinor, got.MinorVersion) } @@ -579,10 +583,12 @@ func TestTransform_DropsSrcWhenSameNameBinaryPresent(t *testing.T) { seen = append(seen, emitted{name: aph.Package.Name, os: osName, osMajor: osMajor, arch: arch}) } + // hummingbird is rolling: both platforms emit a version-less OS row. The per-platform + // dedup still keys off the relationship platform IDs, not the emitted OS version. want := []emitted{ - {name: "glibc", os: "hummingbird", osMajor: "1", arch: architecture.ArchBinaryNoArchSpecified}, - {name: "glibc-common", os: "hummingbird", osMajor: "1", arch: architecture.ArchBinaryNoArchSpecified}, - {name: "glibc", os: "hummingbird", osMajor: "2", arch: architecture.ArchSource}, + {name: "glibc", os: "hummingbird", osMajor: "", arch: architecture.ArchBinaryNoArchSpecified}, + {name: "glibc-common", os: "hummingbird", osMajor: "", arch: architecture.ArchBinaryNoArchSpecified}, + {name: "glibc", os: "hummingbird", osMajor: "", arch: architecture.ArchSource}, } require.ElementsMatch(t, want, seen, "hummingbird-1:glibc.src should be dropped (sibling binary present); hummingbird-2:glibc.src should survive (no sibling binary on that platform)") diff --git a/grype/db/v6/build/transformers/os/transform.go b/grype/db/v6/build/transformers/os/transform.go index 330f39c59d4..48023c9aa57 100644 --- a/grype/db/v6/build/transformers/os/transform.go +++ b/grype/db/v6/build/transformers/os/transform.go @@ -110,6 +110,13 @@ func getPackages(vuln unmarshal.OSVulnerability) ([]db.AffectedPackageHandle, [] qualifiers = &db.PackageQualifiers{ RpmModularity: &module, } + // when the advisory scoped this fix to a specific architecture, carry it so the + // architecture qualifier only applies the fix to packages of that arch (see + // pkg/qualifier/architecture). Absent arch means the fix applies to all arches. + if group.arch != "" { + arch := group.arch + qualifiers.Architecture = &arch + } } aph := db.AffectedPackageHandle{ @@ -271,6 +278,7 @@ type groupIndex struct { hasModule bool module string format string + arch string } func groupFixedIns(vuln unmarshal.OSVulnerability) map[groupIndex][]unmarshal.OSFixedIn { @@ -282,6 +290,10 @@ func groupFixedIns(vuln unmarshal.OSVulnerability) map[groupIndex][]unmarshal.OS if fixedIn.Module != nil { mod = *fixedIn.Module } + var arch string + if fixedIn.Arch != nil { + arch = *fixedIn.Arch + } g := groupIndex{ name: fixedIn.Name, id: oi.id, @@ -292,6 +304,9 @@ func groupFixedIns(vuln unmarshal.OSVulnerability) map[groupIndex][]unmarshal.OS hasModule: fixedIn.Module != nil, module: mod, format: fixedIn.VersionFormat, + // arch splits a per-arch fix into its own affected package handle so the architecture + // qualifier can scope it; empty means the fix applies to all arches. + arch: arch, } grouped[g] = append(grouped[g], fixedIn) diff --git a/grype/db/v6/build/transformers/os/transform_test.go b/grype/db/v6/build/transformers/os/transform_test.go index 5148ff6fb13..c6fb1dcd271 100644 --- a/grype/db/v6/build/transformers/os/transform_test.go +++ b/grype/db/v6/build/transformers/os/transform_test.go @@ -2005,3 +2005,44 @@ func timeRef(ti time.Time) *time.Time { func strRef(s string) *string { return &s } + +// Test_getPackages_perArchFix pins that an Oracle-style advisory with a different fix per +// architecture (the ELSA-2022-4803 false-positive shape) yields one affected package handle per +// arch, each carrying its own Architecture qualifier and fix version, while an arch-less FixedIn +// stays arch-agnostic. Without per-arch handles, the higher aarch64 revision would over-match a +// patched x86_64 package. +func Test_getPackages_perArchFix(t *testing.T) { + vuln := unmarshal.OSVulnerability{} + vuln.Vulnerability.Name = "ELSA-2022-4803" + vuln.Vulnerability.NamespaceName = "ol:7" + vuln.Vulnerability.FixedIn = []unmarshal.OSFixedIn{ + {Name: "rsyslog", NamespaceName: "ol:7", Version: "0:8.24.0-57.0.1.el7_9.3", VersionFormat: "rpm", Arch: strRef("x86_64")}, + {Name: "rsyslog", NamespaceName: "ol:7", Version: "0:8.24.0-57.0.4.el7_9.3", VersionFormat: "rpm", Arch: strRef("aarch64")}, + {Name: "zlib", NamespaceName: "ol:7", Version: "0:1.2.7-21.el7", VersionFormat: "rpm"}, // arch-less: applies to all + } + + affected, unaffected := getPackages(vuln) + require.Empty(t, unaffected) + + type got struct { + name string + arch string // "" when no architecture qualifier + fixVers string + } + var results []got + for _, aph := range affected { + g := got{name: aph.Package.Name} + if aph.BlobValue.Qualifiers != nil && aph.BlobValue.Qualifiers.Architecture != nil { + g.arch = *aph.BlobValue.Qualifiers.Architecture + } + require.Len(t, aph.BlobValue.Ranges, 1) + g.fixVers = aph.BlobValue.Ranges[0].Fix.Version + results = append(results, g) + } + + assert.ElementsMatch(t, []got{ + {name: "rsyslog", arch: "x86_64", fixVers: "0:8.24.0-57.0.1.el7_9.3"}, + {name: "rsyslog", arch: "aarch64", fixVers: "0:8.24.0-57.0.4.el7_9.3"}, + {name: "zlib", arch: "", fixVers: "0:1.2.7-21.el7"}, + }, results) +} diff --git a/grype/db/v6/build/transformers/osv/helpers.go b/grype/db/v6/build/transformers/osv/helpers.go index 519570c85db..05b9e8469ba 100644 --- a/grype/db/v6/build/transformers/osv/helpers.go +++ b/grype/db/v6/build/transformers/osv/helpers.go @@ -5,9 +5,8 @@ import ( "regexp" "strings" - "github.com/google/osv-scanner/pkg/models" - "github.com/anchore/grype/grype/db/internal/provider/unmarshal" + "github.com/anchore/grype/grype/db/internal/provider/unmarshal/osvmodel" "github.com/anchore/grype/grype/db/internal/versionutil" db "github.com/anchore/grype/grype/db/v6" "github.com/anchore/grype/grype/db/v6/build/transformers/internal" @@ -64,61 +63,54 @@ import ( // "semver") — the caller decides this per-provider, since the same OSV // RangeType maps differently across providers. See each strategy's own // rangeType() function in transform_.go. -func getGrypeRangesFromRange(r models.Range, rangeType string) []db.Range { // nolint: gocognit,funlen - var ranges []db.Range - if len(r.Events) == 0 { - return nil - } +func getGrypeRangesFromRange(r osvmodel.Range, rangeType string) []db.Range { + return eventsToRanges(r.Events, extractFixAvailability(r), rangeType) +} +// eventsToRanges converts an OSV event stream into grype ranges, one per window: +// `introduced` opens a window, `fixed`/`last_affected` closes it, a trailing +// unclosed `introduced` becomes open-ended ">= X". `fixes` keys fix-availability +// dates by fixed version; `rangeType` is the caller's version format. +// +// OSV treats "one range, N windows" and "N ranges, one window each" alike, so +// callers may pre-flatten ranges into one slice. +func eventsToRanges(events []osvmodel.Event, fixes map[string]db.FixAvailability, rangeType string) []db.Range { + var ranges []db.Range var constraint string - updateConstraint := func(c string) { + + and := func(c string) { if constraint == "" { constraint = c } else { constraint = versionutil.AndConstraints(constraint, c) } } + emit := func(fix *db.Fix) { + ranges = append(ranges, db.Range{ + Fix: fix, + Version: db.Version{Type: rangeType, Constraint: normalizeConstraint(constraint, rangeType)}, + }) + constraint = "" + } - fixByVersion := extractFixAvailability(r) - - for _, e := range r.Events { + for _, e := range events { switch { case e.Introduced != "" && e.Introduced != "0": constraint = fmt.Sprintf(">= %s", e.Introduced) case e.LastAffected != "": - updateConstraint(fmt.Sprintf("<= %s", e.LastAffected)) - ranges = append(ranges, db.Range{ - Version: db.Version{ - Type: rangeType, - Constraint: normalizeConstraint(constraint, rangeType), - }, - }) - constraint = "" + and(fmt.Sprintf("<= %s", e.LastAffected)) + emit(nil) case e.Fixed != "": + and(fmt.Sprintf("< %s", e.Fixed)) var detail *db.FixDetail - if f, ok := fixByVersion[e.Fixed]; ok { + if f, ok := fixes[e.Fixed]; ok { detail = &db.FixDetail{Available: &f} } - updateConstraint(fmt.Sprintf("< %s", e.Fixed)) - ranges = append(ranges, db.Range{ - Fix: normalizeFix(e.Fixed, detail), - Version: db.Version{ - Type: rangeType, - Constraint: normalizeConstraint(constraint, rangeType), - }, - }) - constraint = "" + emit(normalizeFix(e.Fixed, detail)) } } - - // Trailing "introduced" with no upper bound. if constraint != "" { - ranges = append(ranges, db.Range{ - Version: db.Version{ - Type: rangeType, - Constraint: normalizeConstraint(constraint, rangeType), - }, - }) + emit(nil) // trailing `introduced` with no upper bound } return ranges } @@ -126,7 +118,7 @@ func getGrypeRangesFromRange(r models.Range, rangeType string) []db.Range { // n // getGrypeUnaffectedRangesFromRange inverts the OSV range events into // "unaffected" ranges for advisory records. rangeType is the grype-side format // string supplied by the calling strategy (see strategy rangeType() helpers). -func getGrypeUnaffectedRangesFromRange(r models.Range, rangeType string) []db.Range { +func getGrypeUnaffectedRangesFromRange(r osvmodel.Range, rangeType string) []db.Range { if len(r.Events) == 0 { return nil } @@ -135,7 +127,11 @@ func getGrypeUnaffectedRangesFromRange(r models.Range, rangeType string) []db.Ra } func normalizeConstraint(constraint string, rangeType string) string { - if rangeType == "semver" || rangeType == "bitnami" { + // Go versions are semver-shaped (with optional "v"/"go" prefix the parser + // strips); multi-window ranges built via versionutil.AndConstraints use + // space-separated form which the Go constraint parser rejects. Apply the + // same comma-separated normalization that semver/bitnami get. + if rangeType == "semver" || rangeType == "bitnami" || rangeType == "go" { return versionutil.EnforceSemVerConstraint(constraint) } return constraint @@ -157,9 +153,9 @@ func normalizeFix(fix string, detail *db.FixDetail) *db.Fix { // defaultRangeType is the generic OSV range-type → grype format mapping with // no provider-specific overrides. Strategies use this as the fallback for OSV // range types they don't have a special interpretation for. -func defaultRangeType(t models.RangeType) string { +func defaultRangeType(t osvmodel.RangeType) string { switch t { - case models.RangeSemVer, models.RangeEcosystem, models.RangeGit: + case osvmodel.RangeSemVer, osvmodel.RangeEcosystem, osvmodel.RangeGit: return strings.ToLower(string(t)) default: return "unknown" @@ -170,7 +166,7 @@ func defaultRangeType(t models.RangeType) string { // Fix-availability decoding (database_specific.anchore.fixes) // ============================================================================ -func extractFixAvailability(r models.Range) map[string]db.FixAvailability { +func extractFixAvailability(r osvmodel.Range) map[string]db.FixAvailability { fixByVersion := make(map[string]db.FixAvailability) dbSpecific, ok := r.DatabaseSpecific["anchore"] @@ -211,7 +207,7 @@ func parseSingleFixEntry(fixEntry any, fixByVersion map[string]db.FixAvailabilit } } -func buildUnaffectedRangesFromEvents(events []models.Event, fixByVersion map[string]db.FixAvailability, rangeType string) []db.Range { +func buildUnaffectedRangesFromEvents(events []osvmodel.Event, fixByVersion map[string]db.FixAvailability, rangeType string) []db.Range { var ranges []db.Range for _, e := range events { if e.Fixed != "" { @@ -250,9 +246,9 @@ func extractCVSSInfo(cvss string) (string, string, error) { return matches[1], matches[0], nil } -func normalizeSeverity(severity models.Severity) (db.Severity, error) { +func normalizeSeverity(severity osvmodel.Severity) (db.Severity, error) { switch severity.Type { - case models.SeverityCVSSV2, models.SeverityCVSSV3, models.SeverityCVSSV4: + case osvmodel.SeverityCVSSV2, osvmodel.SeverityCVSSV3, osvmodel.SeverityCVSSV4: version, vector, err := extractCVSSInfo(severity.Score) if err != nil { return db.Severity{}, err diff --git a/grype/db/v6/build/transformers/osv/testdata/GO-2020-0001.json b/grype/db/v6/build/transformers/osv/testdata/GO-2020-0001.json new file mode 100644 index 00000000000..422c391b750 --- /dev/null +++ b/grype/db/v6/build/transformers/osv/testdata/GO-2020-0001.json @@ -0,0 +1,77 @@ +{ + "affected": [ + { + "ecosystem_specific": { + "imports": [ + { + "path": "github.com/gin-gonic/gin", + "symbols": [ + "Default", + "Logger", + "LoggerWithConfig", + "LoggerWithFormatter", + "LoggerWithWriter" + ] + } + ] + }, + "package": { + "ecosystem": "Go", + "name": "github.com/gin-gonic/gin" + }, + "ranges": [ + { + "database_specific": { + "anchore": { + "fixes": [ + { + "date": "2021-04-14", + "kind": "advisory", + "version": "1.6.0" + } + ] + } + }, + "events": [ + { + "introduced": "0" + }, + { + "fixed": "1.6.0" + } + ], + "type": "SEMVER" + } + ] + } + ], + "aliases": [ + "CVE-2020-36567", + "GHSA-6vm3-jj99-7229" + ], + "credits": [ + { + "name": "@thinkerou " + } + ], + "database_specific": { + "review_status": "REVIEWED", + "url": "https://pkg.go.dev/vuln/GO-2020-0001" + }, + "details": "The default Formatter for the Logger middleware (LoggerConfig.Formatter), which is included in the Default engine, allows attackers to inject arbitrary log entries by manipulating the request path.", + "id": "GO-2020-0001", + "modified": "2024-05-20T16:03:47Z", + "published": "2021-04-14T20:04:52Z", + "references": [ + { + "type": "FIX", + "url": "https://github.com/gin-gonic/gin/pull/2237" + }, + { + "type": "FIX", + "url": "https://github.com/gin-gonic/gin/commit/a71af9c144f9579f6dbe945341c1df37aaf09c0d" + } + ], + "schema_version": "1.3.1", + "summary": "Arbitrary log line injection in github.com/gin-gonic/gin" +} diff --git a/grype/db/v6/build/transformers/osv/testdata/GO-2022-0969.json b/grype/db/v6/build/transformers/osv/testdata/GO-2022-0969.json new file mode 100644 index 00000000000..747cb51b198 --- /dev/null +++ b/grype/db/v6/build/transformers/osv/testdata/GO-2022-0969.json @@ -0,0 +1,123 @@ +{ + "affected": [ + { + "ecosystem_specific": { + "imports": [ + { + "path": "net/http", + "symbols": [ + "ListenAndServe", + "ListenAndServeTLS", + "Serve", + "ServeTLS", + "Server.ListenAndServe", + "Server.ListenAndServeTLS", + "Server.Serve", + "Server.ServeTLS", + "http2Server.ServeConn", + "http2serverConn.goAway" + ] + } + ] + }, + "package": { + "ecosystem": "Go", + "name": "stdlib" + }, + "ranges": [ + { + "events": [ + { + "introduced": "0" + }, + { + "fixed": "1.18.6" + }, + { + "introduced": "1.19.0-0" + }, + { + "fixed": "1.19.1" + } + ], + "type": "SEMVER" + } + ] + }, + { + "ecosystem_specific": { + "imports": [ + { + "path": "golang.org/x/net/http2", + "symbols": [ + "Server.ServeConn", + "serverConn.goAway" + ] + } + ] + }, + "package": { + "ecosystem": "Go", + "name": "golang.org/x/net" + }, + "ranges": [ + { + "events": [ + { + "introduced": "0" + }, + { + "fixed": "0.0.0-20220906165146-f3363e06e74c" + } + ], + "type": "SEMVER" + } + ] + } + ], + "aliases": [ + "CVE-2022-27664", + "GHSA-69cg-p879-7622" + ], + "credits": [ + { + "name": "Bahruz Jabiyev" + }, + { + "name": "Tommaso Innocenti" + }, + { + "name": "Anthony Gavazzi" + }, + { + "name": "Steven Sprecher" + }, + { + "name": "Kaan Onarlioglu" + } + ], + "database_specific": { + "review_status": "REVIEWED", + "url": "https://pkg.go.dev/vuln/GO-2022-0969" + }, + "details": "HTTP/2 server connections can hang forever waiting for a clean shutdown that was preempted by a fatal error. This condition can be exploited by a malicious client to cause a denial of service.", + "id": "GO-2022-0969", + "modified": "2024-05-20T16:03:47Z", + "published": "2022-09-12T20:23:06Z", + "references": [ + { + "type": "WEB", + "url": "https://groups.google.com/g/golang-announce/c/x49AQzIVX-s" + }, + { + "type": "REPORT", + "url": "https://go.dev/issue/54658" + }, + { + "type": "FIX", + "url": "https://go.dev/cl/428735" + } + ], + "schema_version": "1.3.1", + "summary": "Denial of service in net/http and golang.org/x/net/http2" +} diff --git a/grype/db/v6/build/transformers/osv/transform.go b/grype/db/v6/build/transformers/osv/transform.go index a9ef275e6de..7327664f9f7 100644 --- a/grype/db/v6/build/transformers/osv/transform.go +++ b/grype/db/v6/build/transformers/osv/transform.go @@ -47,5 +47,6 @@ type Strategy interface { var strategies = []Strategy{ almaStrategy{}, bitnamiStrategy{}, + govulndbStrategy{}, rootioStrategy{}, } diff --git a/grype/db/v6/build/transformers/osv/transform_alma.go b/grype/db/v6/build/transformers/osv/transform_alma.go index c0e62f86b75..85166569c05 100644 --- a/grype/db/v6/build/transformers/osv/transform_alma.go +++ b/grype/db/v6/build/transformers/osv/transform_alma.go @@ -6,11 +6,10 @@ import ( "strconv" "strings" - "github.com/google/osv-scanner/pkg/models" - "github.com/anchore/grype/grype/db/data" "github.com/anchore/grype/grype/db/internal/codename" "github.com/anchore/grype/grype/db/internal/provider/unmarshal" + "github.com/anchore/grype/grype/db/internal/provider/unmarshal/osvmodel" "github.com/anchore/grype/grype/db/provider" db "github.com/anchore/grype/grype/db/v6" "github.com/anchore/grype/grype/db/v6/build/transformers" @@ -78,7 +77,7 @@ func almaReferences(vuln unmarshal.OSVVulnerability) []db.Reference { var refs []db.Reference for _, ref := range vuln.References { refID := "" - if ref.Type == models.ReferenceAdvisory { + if ref.Type == osvmodel.ReferenceAdvisory { refID = vuln.ID } refs = append(refs, db.Reference{ @@ -98,7 +97,7 @@ func almaUnaffectedPackages(vuln unmarshal.OSVVulnerability) []db.UnaffectedPack for _, affected := range vuln.Affected { uphs = append(uphs, db.UnaffectedPackageHandle{ Package: almaPackage(affected.Package), - OperatingSystem: almaOSFromEcosystem(string(affected.Package.Ecosystem)), + OperatingSystem: almaOSFromEcosystem(affected.Package.Ecosystem), BlobValue: almaUnaffectedBlob(vuln, affected), }) } @@ -106,7 +105,7 @@ func almaUnaffectedPackages(vuln unmarshal.OSVVulnerability) []db.UnaffectedPack return uphs } -func almaUnaffectedBlob(vuln unmarshal.OSVVulnerability, affected models.Affected) *db.PackageBlob { +func almaUnaffectedBlob(vuln unmarshal.OSVVulnerability, affected osvmodel.Affected) *db.PackageBlob { var ranges []db.Range for _, r := range affected.Ranges { ranges = append(ranges, getGrypeUnaffectedRangesFromRange(r, almaRangeType(r.Type))...) @@ -129,7 +128,7 @@ func almaUnaffectedBlob(vuln unmarshal.OSVVulnerability, affected models.Affecte } } -func almaPackage(p models.Package) *db.Package { +func almaPackage(p osvmodel.Package) *db.Package { return &db.Package{ Ecosystem: pkg.RpmPkg.String(), Name: name.Normalize(p.Name, pkg.RpmPkg), @@ -141,8 +140,8 @@ func almaPackage(p models.Package) *db.Package { // other OSV types fall through to the default mapping. AlmaLinux records in // practice only use ECOSYSTEM, but the fallback is here so unexpected shapes // don't silently become "unknown". -func almaRangeType(t models.RangeType) string { - if t == models.RangeEcosystem { +func almaRangeType(t osvmodel.RangeType) string { + if t == osvmodel.RangeEcosystem { return pkg.RpmPkg.String() } return defaultRangeType(t) @@ -151,7 +150,7 @@ func almaRangeType(t models.RangeType) string { // extractRpmModularity reads ecosystem_specific.rpm_modularity from an OSV // affected entry. Only alma emits this in the OSV records we currently // consume; other strategies don't have a use for it. -func extractRpmModularity(affected models.Affected) string { +func extractRpmModularity(affected osvmodel.Affected) string { if affected.EcosystemSpecific == nil { return "" } diff --git a/grype/db/v6/build/transformers/osv/transform_alma_test.go b/grype/db/v6/build/transformers/osv/transform_alma_test.go index f403a161885..6914ae46b36 100644 --- a/grype/db/v6/build/transformers/osv/transform_alma_test.go +++ b/grype/db/v6/build/transformers/osv/transform_alma_test.go @@ -5,8 +5,7 @@ import ( "testing" "time" - "github.com/google/osv-scanner/pkg/models" - + "github.com/anchore/grype/grype/db/internal/provider/unmarshal/osvmodel" db "github.com/anchore/grype/grype/db/v6" "github.com/anchore/grype/grype/db/v6/build/transformers" ) @@ -228,14 +227,14 @@ func TestAlmaTransform(t *testing.T) { func TestAlmaRangeConversion(t *testing.T) { tests := []struct { name string - rnge models.Range + rnge osvmodel.Range want []db.Range }{ { name: "ECOSYSTEM range with introduced=0 and fixed produces inverted unaffected range", - rnge: models.Range{ - Type: models.RangeEcosystem, - Events: []models.Event{ + rnge: osvmodel.Range{ + Type: osvmodel.RangeEcosystem, + Events: []osvmodel.Event{ {Introduced: "0"}, {Fixed: "1.0.28-10.el8"}, }, @@ -247,9 +246,9 @@ func TestAlmaRangeConversion(t *testing.T) { }, { name: "ECOSYSTEM range with modular RPM fix version", - rnge: models.Range{ - Type: models.RangeEcosystem, - Events: []models.Event{ + rnge: osvmodel.Range{ + Type: osvmodel.RangeEcosystem, + Events: []osvmodel.Event{ {Introduced: "0"}, {Fixed: "1.6.0-1.module_el8.5.0+2604+960c7771"}, }, @@ -261,9 +260,9 @@ func TestAlmaRangeConversion(t *testing.T) { }, { name: "ECOSYSTEM range with epoch-prefixed RPM version", - rnge: models.Range{ - Type: models.RangeEcosystem, - Events: []models.Event{ + rnge: osvmodel.Range{ + Type: osvmodel.RangeEcosystem, + Events: []osvmodel.Event{ {Introduced: "0"}, {Fixed: "2:1.18.1-1.el10_0"}, }, @@ -291,12 +290,12 @@ func TestAlmaRangeConversion(t *testing.T) { func Test_extractRpmModularity(t *testing.T) { tests := []struct { name string - affected models.Affected + affected osvmodel.Affected want string }{ { name: "with rpm_modularity", - affected: models.Affected{ + affected: osvmodel.Affected{ EcosystemSpecific: map[string]any{ "rpm_modularity": "mariadb:10.3", }, @@ -305,26 +304,26 @@ func Test_extractRpmModularity(t *testing.T) { }, { name: "no ecosystem_specific", - affected: models.Affected{EcosystemSpecific: nil}, + affected: osvmodel.Affected{EcosystemSpecific: nil}, want: "", }, { name: "no rpm_modularity key", - affected: models.Affected{ + affected: osvmodel.Affected{ EcosystemSpecific: map[string]any{"other_key": "some_value"}, }, want: "", }, { name: "rpm_modularity not string", - affected: models.Affected{ + affected: osvmodel.Affected{ EcosystemSpecific: map[string]any{"rpm_modularity": 123}, }, want: "", }, { name: "nodejs modularity", - affected: models.Affected{ + affected: osvmodel.Affected{ EcosystemSpecific: map[string]any{"rpm_modularity": "nodejs:16"}, }, want: "nodejs:16", diff --git a/grype/db/v6/build/transformers/osv/transform_bitnami.go b/grype/db/v6/build/transformers/osv/transform_bitnami.go index d31ead66817..85fc0d0f917 100644 --- a/grype/db/v6/build/transformers/osv/transform_bitnami.go +++ b/grype/db/v6/build/transformers/osv/transform_bitnami.go @@ -5,10 +5,9 @@ import ( "sort" "strings" - "github.com/google/osv-scanner/pkg/models" - "github.com/anchore/grype/grype/db/data" "github.com/anchore/grype/grype/db/internal/provider/unmarshal" + "github.com/anchore/grype/grype/db/internal/provider/unmarshal/osvmodel" "github.com/anchore/grype/grype/db/provider" db "github.com/anchore/grype/grype/db/v6" "github.com/anchore/grype/grype/db/v6/build/transformers" @@ -98,10 +97,10 @@ func bitnamiAffectedPackages(vuln unmarshal.OSVVulnerability) []db.AffectedPacka return aphs } -func bitnamiPackage(p models.Package) *db.Package { +func bitnamiPackage(p osvmodel.Package) *db.Package { pkgType := pkg.TypeFromPURL(p.Purl) return &db.Package{ - Ecosystem: string(p.Ecosystem), + Ecosystem: p.Ecosystem, Name: name.Normalize(p.Name, pkgType), } } @@ -109,8 +108,8 @@ func bitnamiPackage(p models.Package) *db.Package { // bitnamiRangeType maps an OSV range type to the grype version-format string // for Bitnami records. SEMVER ranges describe bitnami-flavored semver // (separate version comparator); other OSV types fall through to the default. -func bitnamiRangeType(t models.RangeType) string { - if t == models.RangeSemVer { +func bitnamiRangeType(t osvmodel.RangeType) string { + if t == osvmodel.RangeSemVer { return "bitnami" } return defaultRangeType(t) diff --git a/grype/db/v6/build/transformers/osv/transform_bitnami_test.go b/grype/db/v6/build/transformers/osv/transform_bitnami_test.go index c5f53fcf6d4..d3ac0ac11a5 100644 --- a/grype/db/v6/build/transformers/osv/transform_bitnami_test.go +++ b/grype/db/v6/build/transformers/osv/transform_bitnami_test.go @@ -5,8 +5,7 @@ import ( "testing" "time" - "github.com/google/osv-scanner/pkg/models" - + "github.com/anchore/grype/grype/db/internal/provider/unmarshal/osvmodel" db "github.com/anchore/grype/grype/db/v6" "github.com/anchore/grype/grype/db/v6/build/transformers" ) @@ -165,14 +164,14 @@ func TestBitnamiTransform(t *testing.T) { func TestBitnamiRangeConversion(t *testing.T) { tests := []struct { name string - rnge models.Range + rnge osvmodel.Range want []db.Range }{ { name: "simple introduced -> fixed semver range", - rnge: models.Range{ - Type: models.RangeSemVer, - Events: []models.Event{ + rnge: osvmodel.Range{ + Type: osvmodel.RangeSemVer, + Events: []osvmodel.Event{ {Introduced: "12.0.0"}, {Fixed: "12.18.4"}, }, @@ -184,9 +183,9 @@ func TestBitnamiRangeConversion(t *testing.T) { }, { name: "introduced=0 -> fixed produces fixed-only constraint", - rnge: models.Range{ - Type: models.RangeSemVer, - Events: []models.Event{ + rnge: osvmodel.Range{ + Type: osvmodel.RangeSemVer, + Events: []osvmodel.Event{ {Introduced: "0"}, {Fixed: "3.4.0"}, }, @@ -198,9 +197,9 @@ func TestBitnamiRangeConversion(t *testing.T) { }, { name: "introduced + last_affected (no fixed event)", - rnge: models.Range{ - Type: models.RangeSemVer, - Events: []models.Event{ + rnge: osvmodel.Range{ + Type: osvmodel.RangeSemVer, + Events: []osvmodel.Event{ {Introduced: "2.4.32"}, {LastAffected: "2.4.43"}, }, @@ -211,9 +210,9 @@ func TestBitnamiRangeConversion(t *testing.T) { }, { name: "two disjoint introduced/fixed windows in one range", - rnge: models.Range{ - Type: models.RangeSemVer, - Events: []models.Event{ + rnge: osvmodel.Range{ + Type: osvmodel.RangeSemVer, + Events: []osvmodel.Event{ {Introduced: "0"}, {Fixed: "3.2.2"}, {Introduced: "3.3.0"}, @@ -233,9 +232,9 @@ func TestBitnamiRangeConversion(t *testing.T) { }, { name: "range with anchore.fixes metadata attaches FixDetail", - rnge: models.Range{ - Type: models.RangeSemVer, - Events: []models.Event{ + rnge: osvmodel.Range{ + Type: osvmodel.RangeSemVer, + Events: []osvmodel.Event{ {Introduced: "12.0.0"}, {Fixed: "12.18.4"}, }, diff --git a/grype/db/v6/build/transformers/osv/transform_go_vuln_db.go b/grype/db/v6/build/transformers/osv/transform_go_vuln_db.go new file mode 100644 index 00000000000..14f2bb5b21a --- /dev/null +++ b/grype/db/v6/build/transformers/osv/transform_go_vuln_db.go @@ -0,0 +1,320 @@ +package osv + +import ( + "fmt" + "sort" + "strings" + "time" + + "github.com/go-viper/mapstructure/v2" + + "github.com/anchore/grype/grype/db/data" + "github.com/anchore/grype/grype/db/internal/provider/unmarshal" + "github.com/anchore/grype/grype/db/internal/provider/unmarshal/osvmodel" + "github.com/anchore/grype/grype/db/provider" + db "github.com/anchore/grype/grype/db/v6" + "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/log" + "github.com/anchore/syft/syft/pkg" +) + +// govulndbStrategy handles GO-* records from the Go vulnerability database +// (vuln.go.dev): affected version ranges for Go modules, plus the "stdlib" and +// "toolchain" pseudo-modules. Govulndb specifics: +// - aliases hold CVEs/GHSAs directly; the schema has no `related` field. +// - ecosystem is always "Go" → syft go-module type, found by the matcher's +// ecosystem-name search. +// - range type is always SEMVER → "go" format, which parses pseudo-versions +// (v0.0.0--). +// - ecosystem_specific.imports (per-symbol reachability) is dropped; grype +// matches at module granularity. +// - references pass through, OSV type as tag, refID empty (canonical advisory +// page is in database_specific.url, not refs). +type govulndbStrategy struct{} + +func (govulndbStrategy) Matches(id string) bool { + return strings.HasPrefix(id, "GO-") +} + +// govulndbEmits reports whether grype emits records for a govulndb affected +// package. For general third-party Go modules the go vuln db carries odd version +// ranges (a source of false positives) and duplicates advisories grype already +// gets from GHSA, so those packages are dropped. Two classes are kept because +// they are both absent from GHSA and versioned by the Go team itself — so the +// strange-range drawback that motivates dropping third-party modules does not +// apply: +// - the "stdlib" pseudo-module: the core language, statically linked into +// every Go binary. +// - the golang.org/x/* extended standard libraries (golang.org/x/net, +// golang.org/x/crypto, golang.org/x/text, …). +func govulndbEmits(name string) bool { + return strings.EqualFold(name, "stdlib") || + strings.HasPrefix(name, "golang.org/x/") +} + +func (govulndbStrategy) Transform(vuln unmarshal.OSVVulnerability, state provider.State) ([]data.Entry, error) { + affected := govulndbAffectedPackages(vuln) + if len(affected) == 0 { + // every affected package was filtered out (or there were none): only the + // stdlib and golang.org/x/* modules are emitted (see govulndbEmits). + // Emitting just the vulnerability handle would write an orphaned record + // that can never match a package, so skip the advisory entirely. + return nil, nil + } + + severities, err := getSeverities(vuln) + if err != nil { + return nil, fmt.Errorf("unable to obtain severities: %w", err) + } + + // Withdrawn GO advisories keep their `affected` ranges but must not match. + // Like github: Status=Rejected plus WithdrawnDate, which the matcher's + // OnlyNonWithdrawnVulnerabilities filter skips. + status := db.VulnerabilityActive + var withdrawnDate *time.Time + if !vuln.Withdrawn.IsZero() { + status = db.VulnerabilityRejected + withdrawnDate = &vuln.Withdrawn + } + + in := []any{ + db.VulnerabilityHandle{ + Name: vuln.ID, + ProviderID: state.Provider, + Provider: provider.Model(state), + Status: status, + ModifiedDate: &vuln.Modified, + PublishedDate: &vuln.Published, + WithdrawnDate: withdrawnDate, + BlobValue: &db.VulnerabilityBlob{ + ID: vuln.ID, + Description: vuln.Details, + References: govulndbReferences(vuln), + Aliases: vuln.Aliases, + Severities: severities, + }, + }, + } + + for _, aph := range affected { + in = append(in, aph) + } + return transformers.NewEntries(in...), nil +} + +func govulndbReferences(vuln unmarshal.OSVVulnerability) []db.Reference { + var refs []db.Reference + for _, ref := range vuln.References { + refs = append(refs, db.Reference{ + URL: ref.URL, + Tags: []string{string(ref.Type)}, + }) + } + return refs +} + +func govulndbAffectedPackages(vuln unmarshal.OSVVulnerability) []db.AffectedPackageHandle { + if len(vuln.Affected) == 0 { + return nil + } + var aphs []db.AffectedPackageHandle + for _, affected := range vuln.Affected { + if !govulndbEmits(affected.Package.Name) { + continue + } + aphs = append(aphs, db.AffectedPackageHandle{ + Package: govulndbPackage(affected.Package), + BlobValue: &db.PackageBlob{ + CVEs: vuln.Aliases, + Ranges: govulndbRanges(affected.Ranges, govulndbCustomRanges(affected, vuln.ID)), + }, + }) + } + sort.Sort(internal.ByAffectedPackage(aphs)) + return aphs +} + +// govulndbRanges builds the affected ranges for one affected entry from its +// standard OSV ranges and ecosystem_specific.custom_ranges. +// +// Without custom_ranges, standard ranges pass through: bounded windows; a +// trailing open-ended ">= X" (a real unfixed vuln, e.g. GO-2024-2584's >=0.50.0); +// or a lone introduced:0, which yields no ranges so grype treats every version as +// vulnerable (GO-2024-3240). With custom_ranges, mergeWithCustom combines them; +// custom supersedes only when it yields windows, so malformed/windowless custom +// never widens matching. Conversion runs in eventsToRanges (helpers.go). +func govulndbRanges(standard, custom []osvmodel.Range) []db.Range { + standardRanges := eventsToRanges(flattenEvents(standard), fixDates(standard), "go") + if len(custom) == 0 { + return standardRanges + } + + merged := mergeWithCustom(flattenEvents(standard), flattenEvents(custom)) + customRanges := eventsToRanges(merged, fixDates(standard, custom), "go") + + // custom_ranges supersedes the standard range only when it is semantically + // useful — when it actually yields version windows. A malformed or windowless + // custom_ranges must not erase the standard range: an affected package with no + // ranges matches *every* version, so dropping the standard windows here would + // silently widen matching instead of narrowing it. + if len(customRanges) == 0 { + return standardRanges + } + return customRanges +} + +// mergeWithCustom combines a standard range's events with custom_ranges' events. +// go.dev writes custom_ranges only for the "+incompatible" case (a module ships +// vN tags, N>1, without a /vN path), so the standard range is one of: +// - bare "introduced: 0": says nothing, so use custom as-is (GO-2024-2513). +// - open-ended floor (">= X", no fix): X is the real lower bound; graft it onto +// custom's leading window (GO-2024-2858 → "[5.0.0-beta1,8.5.14) || +// [9.0.0,9.1.8)", matching the GHSA). See withFloor. +// - bounded windows: keep and union with custom. When custom is itself a +// bounded window they're disjoint and span different namespaces (mattermost: +// tag windows standard, pseudo-version windows custom), so append. But when +// custom is an open-ended floor (">= X", no fix) it is the real lower bound +// for the standard window — a +incompatible introduced that standard SEMVER +// records as the placeholder introduced:0 — so graft it on rather than append +// it as a disjoint trailing window (anchore/grype#3520: docker/cli +// GO-2026-4610 was emitting "<29.2.0+incompatible || >=19.03.0+incompatible", +// re-matching every release after the fix). Drop the standard's own trailing +// open-ended window either way; it over-matches later releases. +func mergeWithCustom(standardEvents, customEvents []osvmodel.Event) []osvmodel.Event { + switch { + case isDefaultFloorOnly(standardEvents): + return customEvents + case !hasUpperBound(standardEvents): + return withFloor(customEvents, firstIntroduced(standardEvents)) + case !hasUpperBound(customEvents): + return withFloor(boundedEvents(standardEvents), firstIntroduced(customEvents)) + default: + return append(boundedEvents(standardEvents), customEvents...) + } +} + +// isDefaultFloorOnly reports whether events are exactly the bare "introduced: 0" +// placeholder go.dev emits when it cannot map affected versions — a standard +// range that, on its own, matches every version. +func isDefaultFloorOnly(events []osvmodel.Event) bool { + return len(events) == 1 && events[0].Introduced == "0" +} + +// hasUpperBound reports whether any event closes a window (fixed/last_affected). +// When none do, the standard range is an open-ended floor. +func hasUpperBound(events []osvmodel.Event) bool { + for _, e := range events { + if e.Fixed != "" || e.LastAffected != "" { + return true + } + } + return false +} + +// firstIntroduced returns the first non-empty introduced version, or "". +func firstIntroduced(events []osvmodel.Event) string { + for _, e := range events { + if e.Introduced != "" { + return e.Introduced + } + } + return "" +} + +// withFloor raises the first window's lower bound to floor, overwriting a leading +// introduced:0 with the standard range's real introduction. A floor of "0", or a +// window that already names introduced, leaves events unchanged. +func withFloor(events []osvmodel.Event, floor string) []osvmodel.Event { + if floor == "" || floor == "0" { + return events + } + out := append([]osvmodel.Event(nil), events...) + for i := range out { + if out[i].Introduced == "" { + continue + } + if out[i].Introduced == "0" { + out[i].Introduced = floor + } + break // only the first window takes the floor + } + return out +} + +// flattenEvents concatenates the events of every range into one stream. OSV +// treats "one range with N windows" and "N ranges with one window each" as +// equivalent, so this is faithful. +func flattenEvents(ranges []osvmodel.Range) []osvmodel.Event { + var out []osvmodel.Event + for _, r := range ranges { + out = append(out, r.Events...) + } + return out +} + +// boundedEvents keeps the events up to and including the last fixed/last_affected, +// dropping a trailing `introduced` that nothing closes — the open-ended ">= X" +// window. Used in the merge, where custom_ranges supersedes such windows. +func boundedEvents(evts []osvmodel.Event) []osvmodel.Event { + lastClose := -1 + for i, e := range evts { + if e.Fixed != "" || e.LastAffected != "" { + lastClose = i + } + } + return evts[:lastClose+1] +} + +// fixDates collects fix-availability dates (database_specific.anchore.fixes), +// keyed by fixed version, across all given range groups. The vunnel govulndb +// provider attaches these to the standard ranges; custom_ranges carry none. +func fixDates(rangeGroups ...[]osvmodel.Range) map[string]db.FixAvailability { + out := map[string]db.FixAvailability{} + for _, ranges := range rangeGroups { + for _, r := range ranges { + for v, f := range extractFixAvailability(r) { + out[v] = f + } + } + } + return out +} + +// govulndbCustomRanges decodes ecosystem_specific.custom_ranges into OSV ranges. +// They are OSV-shaped but carry type ECOSYSTEM; mapstructure reuses the `json` +// tags to avoid re-serializing. Returns nil if absent or undecodable (logged), +// so malformed custom_ranges degrades to standard-only matching instead of +// erroring. +func govulndbCustomRanges(affected osvmodel.Affected, id string) []osvmodel.Range { + if affected.EcosystemSpecific == nil { + return nil + } + raw, ok := affected.EcosystemSpecific["custom_ranges"] + if !ok { + return nil + } + + var ranges []osvmodel.Range + decoder, err := mapstructure.NewDecoder(&mapstructure.DecoderConfig{ + Result: &ranges, + TagName: "json", + }) + if err != nil { + return nil + } + if err := decoder.Decode(raw); err != nil { + log.WithFields("id", id, "package", affected.Package.Name, "error", err). + Warn("unable to decode govulndb custom_ranges; matching on standard ranges only") + return nil + } + return ranges +} + +func govulndbPackage(p osvmodel.Package) *db.Package { + return &db.Package{ + Ecosystem: string(pkg.GoModulePkg), + Name: name.Normalize(p.Name, pkg.GoModulePkg), + } +} diff --git a/grype/db/v6/build/transformers/osv/transform_go_vuln_db_test.go b/grype/db/v6/build/transformers/osv/transform_go_vuln_db_test.go new file mode 100644 index 00000000000..a4c057f006a --- /dev/null +++ b/grype/db/v6/build/transformers/osv/transform_go_vuln_db_test.go @@ -0,0 +1,279 @@ +package osv + +import ( + "reflect" + "sort" + "testing" + "time" + + "github.com/anchore/grype/grype/db/internal/provider/unmarshal/osvmodel" + db "github.com/anchore/grype/grype/db/v6" + "github.com/anchore/grype/grype/db/v6/build/transformers" +) + +// TestGoVulnDBTransform exercises the govulndb strategy end-to-end against a real +// GO-* fixture. The transformer emits the stdlib pseudo-module and the +// golang.org/x/* extended standard libraries (see govulndbEmits); other +// third-party modules are dropped. +// +// - GO-2022-0969: a MIXED record listing both "stdlib" and "golang.org/x/net". +// Both survive the emit filter, so the want below contains BOTH handles +// (golang.org/x/net sorts before stdlib). The stdlib range is multi-window +// (< 1.18.6 || >= 1.19.0-0,< 1.19.1), exercising the comma-separated constraint +// normalization the Go version comparator needs; the golang.org/x/net range is +// a single pseudo-version window, exercising the golang.org/x/* emit path. +func TestGoVulnDBTransform(t *testing.T) { + tests := []transformCase{ + { + name: "stdlib 2022-0969 multi-window", + fixturePath: "testdata/GO-2022-0969.json", + want: []transformers.RelatedEntries{{ + VulnerabilityHandle: &db.VulnerabilityHandle{ + Name: "GO-2022-0969", + Status: db.VulnerabilityActive, + ProviderID: "osv", + Provider: expectedProvider(), + ModifiedDate: timeRef(time.Date(2024, time.May, 20, 16, 3, 47, 0, time.UTC)), + PublishedDate: timeRef(time.Date(2022, time.September, 12, 20, 23, 6, 0, time.UTC)), + BlobValue: &db.VulnerabilityBlob{ + ID: "GO-2022-0969", + Description: "HTTP/2 server connections can hang forever waiting for a clean shutdown that was preempted by a fatal error. This condition can be exploited by a malicious client to cause a denial of service.", + References: []db.Reference{{ + URL: "https://groups.google.com/g/golang-announce/c/x49AQzIVX-s", + Tags: []string{"WEB"}, + }, { + URL: "https://go.dev/issue/54658", + Tags: []string{"REPORT"}, + }, { + URL: "https://go.dev/cl/428735", + Tags: []string{"FIX"}, + }}, + Aliases: []string{"CVE-2022-27664", "GHSA-69cg-p879-7622"}, + }, + }, + Related: affectedPkgSlice( + db.AffectedPackageHandle{ + Package: &db.Package{ + Name: "golang.org/x/net", + Ecosystem: "go-module", + }, + BlobValue: &db.PackageBlob{ + CVEs: []string{"CVE-2022-27664", "GHSA-69cg-p879-7622"}, + Ranges: []db.Range{{ + Version: db.Version{ + Type: "go", + Constraint: "<0.0.0-20220906165146-f3363e06e74c", + }, + Fix: &db.Fix{ + Version: "0.0.0-20220906165146-f3363e06e74c", + State: db.FixedStatus, + }, + }}, + }, + }, + db.AffectedPackageHandle{ + Package: &db.Package{ + Name: "stdlib", + Ecosystem: "go-module", + }, + BlobValue: &db.PackageBlob{ + CVEs: []string{"CVE-2022-27664", "GHSA-69cg-p879-7622"}, + Ranges: []db.Range{{ + Version: db.Version{ + Type: "go", + Constraint: "<1.18.6", + }, + Fix: &db.Fix{ + Version: "1.18.6", + State: db.FixedStatus, + }, + }, { + Version: db.Version{ + Type: "go", + Constraint: ">=1.19.0-0,<1.19.1", + }, + Fix: &db.Fix{ + Version: "1.19.1", + State: db.FixedStatus, + }, + }}, + }, + }, + ), + }}, + }, + } + runTransformCases(t, tests) +} + +// TestEventsToRanges exercises event-stream → grype-range conversion. +// Multi-window is load-bearing: stdlib carries two disjoint windows in one +// stream, and the Go parser needs the AND form comma-separated. Trailing +// introduced with no fix → open-ended. +func TestEventsToRanges(t *testing.T) { + tests := []struct { + name string + events []osvmodel.Event + want []db.Range + }{ + { + name: "simple introduced=0 -> fixed", + events: []osvmodel.Event{{Introduced: "0"}, {Fixed: "1.6.0"}}, + want: []db.Range{{ + Version: db.Version{Type: "go", Constraint: "<1.6.0"}, + Fix: &db.Fix{Version: "1.6.0", State: db.FixedStatus}, + }}, + }, + { + name: "two disjoint windows (stdlib shape)", + events: []osvmodel.Event{ + {Introduced: "0"}, + {Fixed: "1.18.6"}, + {Introduced: "1.19.0-0"}, + {Fixed: "1.19.1"}, + }, + want: []db.Range{ + { + Version: db.Version{Type: "go", Constraint: "<1.18.6"}, + Fix: &db.Fix{Version: "1.18.6", State: db.FixedStatus}, + }, + { + Version: db.Version{Type: "go", Constraint: ">=1.19.0-0,<1.19.1"}, + Fix: &db.Fix{Version: "1.19.1", State: db.FixedStatus}, + }, + }, + }, + { + name: "pseudo-version fixed (golang.org/x/* shape)", + events: []osvmodel.Event{{Introduced: "0"}, {Fixed: "0.0.0-20220906165146-f3363e06e74c"}}, + want: []db.Range{{ + Version: db.Version{Type: "go", Constraint: "<0.0.0-20220906165146-f3363e06e74c"}, + Fix: &db.Fix{Version: "0.0.0-20220906165146-f3363e06e74c", State: db.FixedStatus}, + }}, + }, + { + name: "trailing introduced with no fix is open-ended", + events: []osvmodel.Event{{Introduced: "1.2.0"}}, + want: []db.Range{{ + Version: db.Version{Type: "go", Constraint: ">=1.2.0"}, + }}, + }, + { + name: "lone introduced=0 yields no ranges (all-versions)", + events: []osvmodel.Event{{Introduced: "0"}}, + want: nil, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := eventsToRanges(tt.events, nil, "go") + if !reflect.DeepEqual(got, tt.want) { + t.Errorf("got %v, want %v", got, tt.want) + } + }) + } +} + +// TestGoVulnDB_NonStdlibEmitsNothing guards the drop path for general third-party +// modules: an advisory whose only affected package is a non-emitted module (here +// github.com/gin-gonic/gin — not stdlib and not golang.org/x/*) must produce NO +// entries at all — not an orphaned vulnerability handle with zero affected +// packages. Transform returns early when every affected package is filtered out, +// so such a GO record never reaches the DB. +func TestGoVulnDB_NonStdlibEmitsNothing(t *testing.T) { + vulns := loadFixture(t, "testdata/GO-2020-0001.json") + if len(vulns) != 1 { + t.Fatalf("expected 1 vuln in fixture, got %d", len(vulns)) + } + entries, err := Transform(vulns[0], inputProviderState()) + if err != nil { + t.Fatalf("transform: %v", err) + } + if len(entries) != 0 { + t.Errorf("non-stdlib advisory must emit no entries (no orphaned vuln handle), got %d", len(entries)) + } +} + +// TestGoVulnDB_WithdrawnStdlibIsRejected pins that a WITHDRAWN govulndb advisory +// survives the stdlib-only filter (it has a stdlib affected package) but is +// emitted with Status=Rejected + a WithdrawnDate, so the matcher's +// OnlyNonWithdrawnVulnerabilities filter drops it — a withdrawn stdlib advisory +// must NOT match. No real stdlib record is withdrawn today, so this builds one +// inline to exercise the withdrawn-marking path. +func TestGoVulnDB_WithdrawnStdlibIsRejected(t *testing.T) { + withdrawn := time.Date(2024, time.August, 21, 16, 25, 56, 0, time.UTC) + vuln := osvmodel.Vulnerability{ + ID: "GO-0000-9999", + Withdrawn: withdrawn, + Affected: []osvmodel.Affected{{ + Package: osvmodel.Package{Name: "stdlib", Ecosystem: "Go"}, + Ranges: []osvmodel.Range{{Type: osvmodel.RangeSemVer, Events: []osvmodel.Event{{Introduced: "0"}, {Fixed: "1.18.6"}}}}, + }}, + } + entries, err := Transform(vuln, inputProviderState()) + if err != nil { + t.Fatalf("transform: %v", err) + } + if len(entries) != 1 { + t.Fatalf("expected 1 entry, got %d", len(entries)) + } + re, ok := entries[0].Data.(transformers.RelatedEntries) + if !ok { + t.Fatalf("unexpected entry type %T", entries[0].Data) + } + vh := re.VulnerabilityHandle + if vh == nil { + t.Fatal("entry has no VulnerabilityHandle") + } + if vh.Status != db.VulnerabilityRejected { + t.Errorf("Status = %q, want %q (withdrawn must surface as rejected so the matcher drops it)", vh.Status, db.VulnerabilityRejected) + } + if vh.WithdrawnDate == nil || !vh.WithdrawnDate.Equal(withdrawn) { + t.Errorf("WithdrawnDate = %v, want %s", vh.WithdrawnDate, withdrawn) + } +} + +// TestGoVulnDB_EmitsStdlibAndGolangOrgX pins the emit filter on a real MIXED +// advisory: GO-2022-0969 lists both "stdlib" and "golang.org/x/net". Both classes +// are emitted — stdlib because it is statically linked into every Go binary, and +// golang.org/x/net because the golang.org/x/* extended standard libraries are +// versioned by the Go team and absent from GHSA, so they don't carry the +// false-positive/duplicate baggage that motivates dropping general third-party +// modules. Per-package filtering is asserted on a single record. +func TestGoVulnDB_EmitsStdlibAndGolangOrgX(t *testing.T) { + vulns := loadFixture(t, "testdata/GO-2022-0969.json") + if len(vulns) != 1 { + t.Fatalf("expected 1 vuln, got %d", len(vulns)) + } + + // sanity: the fixture really is mixed, otherwise this test proves nothing + var fixtureNames []string + for _, a := range vulns[0].Affected { + fixtureNames = append(fixtureNames, a.Package.Name) + } + if len(fixtureNames) < 2 { + t.Fatalf("fixture is not mixed (need stdlib + golang.org/x/*), got %v", fixtureNames) + } + + got := map[string]bool{} + for _, aph := range govulndbAffectedPackages(vulns[0]) { + got[aph.Package.Name] = true + } + for _, want := range []string{"stdlib", "golang.org/x/net"} { + if !got[want] { + t.Errorf("expected %q to survive the emit filter, got %v (fixture had %v)", want, keys(got), fixtureNames) + } + } + if len(got) != 2 { + t.Errorf("expected exactly [stdlib golang.org/x/net] to survive, got %v (fixture had %v)", keys(got), fixtureNames) + } +} + +func keys(m map[string]bool) []string { + out := make([]string, 0, len(m)) + for k := range m { + out = append(out, k) + } + sort.Strings(out) + return out +} diff --git a/grype/db/v6/build/transformers/osv/transform_rootio.go b/grype/db/v6/build/transformers/osv/transform_rootio.go index f88bbdbaab3..fa757402517 100644 --- a/grype/db/v6/build/transformers/osv/transform_rootio.go +++ b/grype/db/v6/build/transformers/osv/transform_rootio.go @@ -6,11 +6,10 @@ import ( "strconv" "strings" - "github.com/google/osv-scanner/pkg/models" - "github.com/anchore/grype/grype/db/data" "github.com/anchore/grype/grype/db/internal/codename" "github.com/anchore/grype/grype/db/internal/provider/unmarshal" + "github.com/anchore/grype/grype/db/internal/provider/unmarshal/osvmodel" "github.com/anchore/grype/grype/db/provider" db "github.com/anchore/grype/grype/db/v6" "github.com/anchore/grype/grype/db/v6/build/transformers" @@ -84,7 +83,7 @@ func rootioReferences(vuln unmarshal.OSVVulnerability) []db.Reference { var refs []db.Reference for _, ref := range vuln.References { refID := "" - if ref.Type == models.ReferenceAdvisory { + if ref.Type == osvmodel.ReferenceAdvisory { refID = vuln.ID } refs = append(refs, db.Reference{ @@ -103,7 +102,7 @@ func rootioUnaffectedPackages(vuln unmarshal.OSVVulnerability, aliases []string) rootIO := true var uphs []db.UnaffectedPackageHandle for _, affected := range vuln.Affected { - ecosystem := string(affected.Package.Ecosystem) + ecosystem := affected.Package.Ecosystem pkgType := rootioPackageType(ecosystem) if pkgType == "" { // rootio ships occasional records for ecosystems the transformer @@ -187,8 +186,8 @@ func rootioPackageType(ecosystem string) pkg.Type { // Ecosystem is canonicalized to the grype package-type string (e.g. // "PyPI" → "python", "Alpine:3.18" → "apk"). Name is normalized per the // package type (e.g. PEP 503 for PythonPkg). -func rootioPackage(p models.Package, pkgType pkg.Type) *db.Package { - ecosystem := string(p.Ecosystem) +func rootioPackage(p osvmodel.Package, pkgType pkg.Type) *db.Package { + ecosystem := p.Ecosystem if pkgType != "" { ecosystem = pkgType.String() } diff --git a/grype/db/v6/build/transformers/osv/transform_rootio_test.go b/grype/db/v6/build/transformers/osv/transform_rootio_test.go index 3f71617ca3e..5fa0b1cd141 100644 --- a/grype/db/v6/build/transformers/osv/transform_rootio_test.go +++ b/grype/db/v6/build/transformers/osv/transform_rootio_test.go @@ -3,10 +3,10 @@ package osv import ( "testing" - "github.com/google/osv-scanner/pkg/models" "github.com/stretchr/testify/require" "github.com/anchore/grype/grype/db/internal/provider/unmarshal" + "github.com/anchore/grype/grype/db/internal/provider/unmarshal/osvmodel" db "github.com/anchore/grype/grype/db/v6" "github.com/anchore/grype/grype/db/v6/build/transformers" ) @@ -130,16 +130,16 @@ func TestRootioTransform_RelatedToAliases(t *testing.T) { vuln := unmarshal.OSVVulnerability{} vuln.ID = "ROOT-OS-UBUNTU-2204-CVE-2024-2236" vuln.Related = []string{"CVE-2024-2236"} - vuln.Affected = []models.Affected{ + vuln.Affected = []osvmodel.Affected{ { - Package: models.Package{ + Package: osvmodel.Package{ Ecosystem: "Root:Ubuntu:22.04", Name: "rootio-libgcrypt20", }, - Ranges: []models.Range{ + Ranges: []osvmodel.Range{ { - Type: models.RangeEcosystem, - Events: []models.Event{ + Type: osvmodel.RangeEcosystem, + Events: []osvmodel.Event{ {Introduced: "0"}, {Fixed: "1.9.4-3ubuntu3.root.io.2"}, }, diff --git a/grype/db/v6/build/writer.go b/grype/db/v6/build/writer.go index d5aaf43ab42..37bf9adda2d 100644 --- a/grype/db/v6/build/writer.go +++ b/grype/db/v6/build/writer.go @@ -22,7 +22,11 @@ type writer struct { store db.ReadWriter providerCache map[string]db.Provider states provider.States - severityCache map[string]db.Severity + // severityCache holds CVE → NVD severity for the alias-lookup path used by + // fillInMissingSeverity. Keys are always lowercase CVE IDs; writers go + // through cacheNVDSeverity and readers through lookupNVDSeverity, both of + // which normalize casing so the invariant can't drift. + severityCache map[string]db.Severity // Two-tier batching: parent records (vulnerabilities + providers) and child records (related entries) // This maintains FK integrity while maximizing batch sizes @@ -258,6 +262,11 @@ func (w *writer) writeUnaffectedCPE(vulnHandle *db.VulnerabilityHandle, row db.U // fillInMissingSeverity will add a severity entry to the vulnerability record if it is missing, empty, or "unknown". // The upstream NVD record is used to fill in these missing values. Note that the NVD provider is always guaranteed // to be processed first before other providers. +// +// When the record's primary ID is itself a CVE, the cache is consulted directly. When the primary ID is something +// else (GO-*, BIT-*, ROOT-*, ALSA-*, GHSA-*…), the record's CVE aliases are walked to find a cached NVD severity. +// This is what lets feeds that don't ship their own severity — notably govulndb — present as something other than +// "Unknown" to end users. func (w *writer) fillInMissingSeverity(handle *db.VulnerabilityHandle) { if handle == nil { return @@ -272,15 +281,11 @@ func (w *writer) fillInMissingSeverity(handle *db.VulnerabilityHandle) { isCVE := strings.HasPrefix(id, "cve-") if strings.ToLower(handle.ProviderID) == "nvd" && isCVE { if len(blob.Severities) > 0 { - w.severityCache[id] = blob.Severities[0] + w.cacheNVDSeverity(id, blob.Severities[0]) } return } - if !isCVE { - return - } - // parse all string severities and remove all unknown values sevs := filterUnknownSeverities(blob.Severities) @@ -300,8 +305,7 @@ func (w *writer) fillInMissingSeverity(handle *db.VulnerabilityHandle) { return // already has a severity, don't normalize } - // add the top NVD severity value - nvdSev, ok := w.severityCache[id] + nvdSev, ok := w.lookupNVDSeverity(id, blob.Aliases) if !ok { log.WithFields("id", blob.ID).Trace("unable to find NVD severity") return @@ -312,6 +316,37 @@ func (w *writer) fillInMissingSeverity(handle *db.VulnerabilityHandle) { handle.BlobValue.Severities = sevs } +// cacheNVDSeverity stores an NVD CVE severity for later alias-based lookup. +// Lowercases the key so the read-side lookupNVDSeverity finds it regardless +// of the casing on the lookup record's primary ID or aliases. +func (w *writer) cacheNVDSeverity(cveID string, sev db.Severity) { + w.severityCache[strings.ToLower(cveID)] = sev +} + +// lookupNVDSeverity returns the cached NVD severity for a CVE — either the +// record's own primary ID when it is a CVE, or the first CVE alias that has a +// cached severity. Lowercases both id and each alias so callers can pass any +// casing without a silent cache miss; pairs with cacheNVDSeverity on the +// write side. +func (w *writer) lookupNVDSeverity(id string, aliases []string) (db.Severity, bool) { + id = strings.ToLower(id) + if strings.HasPrefix(id, "cve-") { + if s, ok := w.severityCache[id]; ok { + return s, true + } + } + for _, alias := range aliases { + a := strings.ToLower(alias) + if !strings.HasPrefix(a, "cve-") { + continue + } + if s, ok := w.severityCache[a]; ok { + return s, true + } + } + return db.Severity{}, false +} + // addToParentBatch adds an operation to parent buffer and flushes when threshold reached func (w *writer) addToParentBatch(op func() error) error { w.mu.Lock() diff --git a/grype/db/v6/build/writer_test.go b/grype/db/v6/build/writer_test.go index c7275dad1fa..e0e866b87aa 100644 --- a/grype/db/v6/build/writer_test.go +++ b/grype/db/v6/build/writer_test.go @@ -100,6 +100,79 @@ func TestFillInMissingSeverity(t *testing.T) { }, expected: []db.Severity{{Value: "high"}}, }, + { + // govulndb is the canonical case: GO- records ship with no + // severity but carry the CVE as an alias. The cached NVD + // severity should reach the GO- record via the alias. + name: "non-CVE primary ID with CVE alias resolves via cache", + handle: &db.VulnerabilityHandle{ + ProviderID: "govulndb", + BlobValue: &db.VulnerabilityBlob{ + ID: "GO-2026-5005", + Aliases: []string{"CVE-2026-39833"}, + Severities: nil, + }, + }, + severityCache: map[string]db.Severity{ + "cve-2026-39833": {Value: "medium"}, + }, + expected: []db.Severity{{Value: "medium"}}, + }, + { + // Mixed alias list: the first CVE alias with a cached severity + // wins; GHSA aliases are skipped because the cache is only + // populated from NVD CVE records. + name: "non-CVE primary ID picks first matching CVE alias", + handle: &db.VulnerabilityHandle{ + ProviderID: "govulndb", + BlobValue: &db.VulnerabilityBlob{ + ID: "GO-2024-2731", + Aliases: []string{"GHSA-aaaa-bbbb-cccc", "CVE-2024-1111", "CVE-2024-2222"}, + Severities: nil, + }, + }, + severityCache: map[string]db.Severity{ + "cve-2024-1111": {Value: "high"}, + "cve-2024-2222": {Value: "low"}, + }, + expected: []db.Severity{{Value: "high"}}, + }, + { + // No alias-based hit either: stays as-is. Validates that the + // fallback doesn't manufacture severities for records that + // genuinely have no NVD coverage. + name: "non-CVE primary ID with no cache hit stays empty", + handle: &db.VulnerabilityHandle{ + ProviderID: "govulndb", + BlobValue: &db.VulnerabilityBlob{ + ID: "GO-9999-0001", + Aliases: []string{"CVE-9999-9999"}, + Severities: nil, + }, + }, + severityCache: map[string]db.Severity{}, + expected: nil, + }, + { + // Locks in that lookupNVDSeverity normalizes casing on both + // the primary ID and aliases. The cache is keyed by lowercase + // CVE IDs; if the helper stopped normalizing, a primary CVE + // with upstream casing variations (or any future caller that + // forgot the pre-lowercase) would silently miss a hit. + name: "casing-insensitive lookup on primary ID and aliases", + handle: &db.VulnerabilityHandle{ + ProviderID: "github", + BlobValue: &db.VulnerabilityBlob{ + ID: "GHSA-mixed-case", + Aliases: []string{"cve-2024-AAAA"}, + Severities: nil, + }, + }, + severityCache: map[string]db.Severity{ + "cve-2024-aaaa": {Value: "critical"}, + }, + expected: []db.Severity{{Value: "critical"}}, + }, } for _, tt := range tests { diff --git a/grype/db/v6/data.go b/grype/db/v6/data.go index a425a80940f..25969fb516a 100644 --- a/grype/db/v6/data.go +++ b/grype/db/v6/data.go @@ -1,8 +1,10 @@ package v6 import ( + "sort" "strings" + "github.com/anchore/grype/grype/pkg/qualifier/architecture" "github.com/anchore/syft/syft/pkg" ) @@ -162,6 +164,25 @@ func KnownPackageSpecifierOverrides() []PackageSpecifierOverride { return ret } +// KnownArchitectureAliases is the default arch alias table seeded into the DB at build time. +// It is sourced from architecture.DefaultAliases so the build-time seed and the match-time +// fallback (used by clients whose DB predates this table) can never drift apart. +func KnownArchitectureAliases() []ArchitectureAlias { + defaults := architecture.DefaultAliases() + + aliases := make([]string, 0, len(defaults)) + for alias := range defaults { + aliases = append(aliases, alias) + } + sort.Strings(aliases) // deterministic seed order + + ret := make([]ArchitectureAlias, 0, len(defaults)) + for _, alias := range aliases { + ret = append(ret, ArchitectureAlias{Alias: alias, Canonical: defaults[alias]}) + } + return ret +} + func ptr[T any](v T) *T { return &v } diff --git a/grype/db/v6/db.go b/grype/db/v6/db.go index 84f24e3a763..fea0d9b7a41 100644 --- a/grype/db/v6/db.go +++ b/grype/db/v6/db.go @@ -24,7 +24,7 @@ const ( Revision = 1 // Addition indicates how many changes have been introduced that are compatible with all historical data - Addition = 7 + Addition = 8 // v6 model changelog: // 6.0.0: Initial version 🎉 @@ -48,6 +48,10 @@ const ( // architecture). The field's semantics are unchanged; the rename drops the rpm- // specific prefix because the value already lives in PackageQualifiers and can // carry any architecture string for future arch-scoped advisories. + // 6.1.8: Add ArchitectureAlias table (architecture_aliases). The architecture qualifier + // reads it at match time to fold dialect arch spellings (e.g. "x86_64" <-> "amd64") + // onto a canonical token. Older clients ignore the table; clients reading a DB built + // before it existed fall back to the built-in default aliases. ) const ( @@ -77,6 +81,7 @@ type Reader interface { UnaffectedPackageStoreReader AffectedCPEStoreReader UnaffectedCPEStoreReader + ArchitectureAliasStoreReader io.Closer attachBlobValue(...blobable) error } diff --git a/grype/db/v6/models.go b/grype/db/v6/models.go index 674f5147a14..350510f4e01 100644 --- a/grype/db/v6/models.go +++ b/grype/db/v6/models.go @@ -52,6 +52,7 @@ func Models() []any { &OperatingSystemSpecifierOverride{}, &Package{}, &PackageSpecifierOverride{}, + &ArchitectureAlias{}, // CPE related search tables &AffectedCPEHandle{}, // join on CPE @@ -533,6 +534,22 @@ type PackageSpecifierOverride struct { ReplacementEcosystem *string `gorm:"column:replacement_ecosystem;primaryKey"` } +// ArchitectureAlias folds an architecture spelling onto a canonical token shared by every +// dialect spelling of the same CPU architecture (e.g. rpm "x86_64" and deb/OCI "amd64" both +// fold to "amd64"). The architecture qualifier reads this table at match time so cross-dialect +// arch comparisons don't drop real matches; the data lives in the DB rather than the grype +// binary so the folding can change without a release. See pkg/qualifier/architecture. +type ArchitectureAlias struct { + // Alias is an architecture spelling as it may appear on a package or vulnerability entry + // (e.g. "x86_64"). Only non-canonical spellings are stored; a canonical token resolves to + // itself by absence. + Alias string `gorm:"column:alias;primaryKey"` + + // Canonical is the token Alias folds to (e.g. "amd64"). The choice of token is arbitrary; + // only equivalence across spellings matters. + Canonical string `gorm:"column:canonical;not null"` +} + // OperatingSystem represents a specific release of an operating system. The resolution of the version is // relative to the available data by the vulnerability data provider, so though there may be major.minor.patch OS // releases, there may only be data available for major.minor. diff --git a/grype/db/v6/store.go b/grype/db/v6/store.go index 9c367a41268..d35193b78bf 100644 --- a/grype/db/v6/store.go +++ b/grype/db/v6/store.go @@ -19,6 +19,7 @@ type store struct { *affectedCPEStore *unaffectedCPEStore *vulnerabilityDecoratorStore + *architectureAliasStore blobStore *blobStore db *gorm.DB config Config @@ -45,6 +46,11 @@ func InitialData() []any { for i := range p { data = append(data, &p[i]) } + + a := KnownArchitectureAliases() + for i := range a { + data = append(data, &a[i]) + } return data } @@ -96,6 +102,7 @@ func newStore(cfg Config, empty, writable bool) (*store, error) { affectedCPEStore: newAffectedCPEStore(db, bs), vulnerabilityDecoratorStore: newVulnerabilityDecoratorStore(db, bs, dbVersion), unaffectedCPEStore: newUnaffectedCPEStore(db, bs), + architectureAliasStore: newArchitectureAliasStore(db), blobStore: bs, db: db, config: cfg, diff --git a/grype/db/v6/vulnerability.go b/grype/db/v6/vulnerability.go index 03f87b3dcd2..aee9d6bf38d 100644 --- a/grype/db/v6/vulnerability.go +++ b/grype/db/v6/vulnerability.go @@ -25,7 +25,7 @@ const ( v5NvdNamespace = "nvd:cpe" ) -func newVulnerabilityFromAffectedPackageHandle(affected AffectedPackageHandle, affectedRanges []Range) (*vulnerability.Vulnerability, error) { +func newVulnerabilityFromAffectedPackageHandle(affected AffectedPackageHandle, affectedRanges []Range, archAliases map[string]string) (*vulnerability.Vulnerability, error) { packageName := "" if affected.Package != nil { packageName = affected.Package.Name @@ -35,17 +35,19 @@ func newVulnerabilityFromAffectedPackageHandle(affected AffectedPackageHandle, a return nil, fmt.Errorf("nil data when attempting to create vulnerability from AffectedPackageHandle") } - return newVulnerabilityFromParts(packageName, affected.Vulnerability, affected.BlobValue, affectedRanges, &affected, nil) + qualifiers := getPackageQualifiers(affected.BlobValue, archAliases, architecture.New) + return newVulnerabilityFromParts(packageName, affected.Vulnerability, affected.BlobValue, affectedRanges, &affected, nil, qualifiers) } -func newVulnerabilityFromAffectedCPEHandle(affected AffectedCPEHandle, affectedRanges []Range) (*vulnerability.Vulnerability, error) { +func newVulnerabilityFromAffectedCPEHandle(affected AffectedCPEHandle, affectedRanges []Range, archAliases map[string]string) (*vulnerability.Vulnerability, error) { if affected.Vulnerability == nil || affected.Vulnerability.BlobValue == nil || affected.BlobValue == nil { return nil, fmt.Errorf("nil data when attempting to create vulnerability from AffectedCPEHandle") } - return newVulnerabilityFromParts(affected.CPE.Product, affected.Vulnerability, affected.BlobValue, affectedRanges, nil, &affected) + qualifiers := getPackageQualifiers(affected.BlobValue, archAliases, architecture.New) + return newVulnerabilityFromParts(affected.CPE.Product, affected.Vulnerability, affected.BlobValue, affectedRanges, nil, &affected, qualifiers) } -func newVulnerabilityFromUnaffectedPackageHandle(unaffected UnaffectedPackageHandle, unaffectedRanges []Range) (*vulnerability.Vulnerability, error) { +func newVulnerabilityFromUnaffectedPackageHandle(unaffected UnaffectedPackageHandle, unaffectedRanges []Range, archAliases map[string]string) (*vulnerability.Vulnerability, error) { packageName := "" if unaffected.Package != nil { packageName = unaffected.Package.Name @@ -55,25 +57,27 @@ func newVulnerabilityFromUnaffectedPackageHandle(unaffected UnaffectedPackageHan return nil, fmt.Errorf("nil data when attempting to create vulnerability from UnaffectedPackageHandle") } - vuln, err := newVulnerabilityFromParts(packageName, unaffected.Vulnerability, unaffected.BlobValue, unaffectedRanges, (*AffectedPackageHandle)(&unaffected), nil) + qualifiers := getPackageQualifiers(unaffected.BlobValue, archAliases, architecture.NewUnaffected) + vuln, err := newVulnerabilityFromParts(packageName, unaffected.Vulnerability, unaffected.BlobValue, unaffectedRanges, (*AffectedPackageHandle)(&unaffected), nil, qualifiers) if vuln != nil { vuln.Unaffected = true } return vuln, err } -func newVulnerabilityFromUnaffectedCPEHandle(unaffected UnaffectedCPEHandle, unaffectedRanges []Range) (*vulnerability.Vulnerability, error) { +func newVulnerabilityFromUnaffectedCPEHandle(unaffected UnaffectedCPEHandle, unaffectedRanges []Range, archAliases map[string]string) (*vulnerability.Vulnerability, error) { if unaffected.Vulnerability == nil || unaffected.Vulnerability.BlobValue == nil || unaffected.BlobValue == nil { return nil, fmt.Errorf("nil data when attempting to create vulnerability from UnaffectedCPEHandle") } - vuln, err := newVulnerabilityFromParts(unaffected.CPE.Product, unaffected.Vulnerability, unaffected.BlobValue, unaffectedRanges, nil, (*AffectedCPEHandle)(&unaffected)) + qualifiers := getPackageQualifiers(unaffected.BlobValue, archAliases, architecture.NewUnaffected) + vuln, err := newVulnerabilityFromParts(unaffected.CPE.Product, unaffected.Vulnerability, unaffected.BlobValue, unaffectedRanges, nil, (*AffectedCPEHandle)(&unaffected), qualifiers) if vuln != nil { vuln.Unaffected = true } return vuln, err } -func newVulnerabilityFromParts(packageName string, vuln *VulnerabilityHandle, pkgBlob *PackageBlob, ranges []Range, affectedPackageHandle *AffectedPackageHandle, affectedCpeHandle *AffectedCPEHandle) (*vulnerability.Vulnerability, error) { +func newVulnerabilityFromParts(packageName string, vuln *VulnerabilityHandle, pkgBlob *PackageBlob, ranges []Range, affectedPackageHandle *AffectedPackageHandle, affectedCpeHandle *AffectedCPEHandle, qualifiers []qualifier.Qualifier) (*vulnerability.Vulnerability, error) { if vuln.BlobValue == nil { return nil, fmt.Errorf("vuln has no blob value: %+v", vuln) } @@ -96,7 +100,7 @@ func newVulnerabilityFromParts(packageName string, vuln *VulnerabilityHandle, pk Internal: vuln, // just hold a reference to the vulnHandle for later use }, PackageName: packageName, - PackageQualifiers: getPackageQualifiers(pkgBlob), + PackageQualifiers: qualifiers, Constraint: constraint, CPEs: toCPEs(affectedPackageHandle, affectedCpeHandle), RelatedVulnerabilities: getRelatedVulnerabilities(vuln, pkgBlob, language), @@ -189,9 +193,14 @@ func getRelatedVulnerabilities(vuln *VulnerabilityHandle, affected *PackageBlob, return relatedVulnerabilities } -func getPackageQualifiers(affected *PackageBlob) []qualifier.Qualifier { +// newArchQualifier builds the architecture qualifier for a stored arch value. The affected and +// unaffected construction paths supply architecture.New / architecture.NewUnaffected respectively, +// which is the only way the two paths diverge — see the newVulnerabilityFrom*Handle constructors. +type newArchQualifier func(arch string, aliases map[string]string) qualifier.Qualifier + +func getPackageQualifiers(affected *PackageBlob, archAliases map[string]string, newArch newArchQualifier) []qualifier.Qualifier { if affected != nil { - return toPackageQualifiers(affected.Qualifiers) + return toPackageQualifiers(affected.Qualifiers, archAliases, newArch) } return nil @@ -300,7 +309,7 @@ func mimicV5GithubNamespace(provider, language string) string { return fmt.Sprintf("%s:language:%s", provider, language) } -func toPackageQualifiers(qualifiers *PackageQualifiers) []qualifier.Qualifier { +func toPackageQualifiers(qualifiers *PackageQualifiers, archAliases map[string]string, newArch newArchQualifier) []qualifier.Qualifier { if qualifiers == nil { return nil } @@ -312,7 +321,7 @@ func toPackageQualifiers(qualifiers *PackageQualifiers) []qualifier.Qualifier { out = append(out, rpmmodularity.New(*qualifiers.RpmModularity)) } if qualifiers.Architecture != nil { - out = append(out, architecture.New(*qualifiers.Architecture)) + out = append(out, newArch(*qualifiers.Architecture, archAliases)) } if qualifiers.RootIO != nil && *qualifiers.RootIO { out = append(out, rootio.New()) diff --git a/grype/db/v6/vulnerability_provider.go b/grype/db/v6/vulnerability_provider.go index c1b19d2689c..41630dff7c1 100644 --- a/grype/db/v6/vulnerability_provider.go +++ b/grype/db/v6/vulnerability_provider.go @@ -29,13 +29,23 @@ var ( ) func NewVulnerabilityProvider(rdr Reader) vulnerability.Provider { + // load the arch alias table once: it is small, static for the life of the DB, and read by + // every architecture qualifier at match time. An error (or a DB predating the table) leaves + // archAliases empty, which the architecture qualifier reads as "use the built-in defaults". + archAliases, err := rdr.GetArchitectureAliases() + if err != nil { + log.WithFields("error", err).Warn("unable to load architecture aliases from DB; falling back to built-in defaults") + archAliases = nil + } return &vulnerabilityProvider{ - reader: rdr, + reader: rdr, + archAliases: archAliases, } } type vulnerabilityProvider struct { - reader Reader + reader Reader + archAliases map[string]string } // Deprecated: vulnerability.Vulnerability objects now have metadata included @@ -480,7 +490,7 @@ func (vp vulnerabilityProvider) processAffectedPackageHandles(out []vulnerabilit log.Debugf("unable to find blobValue for %+v", ph) continue } - v, err := newVulnerabilityFromAffectedPackageHandle(ph, ph.BlobValue.Ranges) + v, err := newVulnerabilityFromAffectedPackageHandle(ph, ph.BlobValue.Ranges, vp.archAliases) if err != nil { return nil, err } @@ -507,7 +517,7 @@ func (vp vulnerabilityProvider) processAffectedCPEHandles(out []vulnerability.Vu log.Debugf("unable to find blobValue for %+v", ch) continue } - v, err := newVulnerabilityFromAffectedCPEHandle(ch, ch.BlobValue.Ranges) + v, err := newVulnerabilityFromAffectedCPEHandle(ch, ch.BlobValue.Ranges, vp.archAliases) if err != nil { return nil, err } @@ -534,7 +544,7 @@ func (vp vulnerabilityProvider) processUnaffectedPackageHandles(out []vulnerabil log.Debugf("unable to find blobValue for %+v", ph) continue } - v, err := newVulnerabilityFromUnaffectedPackageHandle(ph, ph.BlobValue.Ranges) + v, err := newVulnerabilityFromUnaffectedPackageHandle(ph, ph.BlobValue.Ranges, vp.archAliases) if err != nil { return nil, err } @@ -561,7 +571,7 @@ func (vp vulnerabilityProvider) processUnaffectedCPEHandles(out []vulnerability. log.Debugf("unable to find blobValue for %+v", ch) continue } - v, err := newVulnerabilityFromUnaffectedCPEHandle(ch, ch.BlobValue.Ranges) + v, err := newVulnerabilityFromUnaffectedCPEHandle(ch, ch.BlobValue.Ranges, vp.archAliases) if err != nil { return nil, err } diff --git a/grype/distro/distro.go b/grype/distro/distro.go index d60a610df91..58eecc92df5 100644 --- a/grype/distro/distro.go +++ b/grype/distro/distro.go @@ -176,10 +176,11 @@ func NewFromRelease(release linux.Release, channels []FixChannel) (*Distro, erro continue } - selectedVersionObj = version.New(ver, version.SemanticFormat) + versionObj := version.New(ver, version.SemanticFormat) - if selectedVersionObj.Validate() == nil { + if versionObj.Validate() == nil { selectedVersion = ver + selectedVersionObj = versionObj break } } diff --git a/grype/distro/distro_test.go b/grype/distro/distro_test.go index eea468e8d31..8f8d8f45528 100644 --- a/grype/distro/distro_test.go +++ b/grype/distro/distro_test.go @@ -319,6 +319,18 @@ func Test_NewDistroFromRelease(t *testing.T) { } } +func Test_NewDistroFromRelease_DoesNotPanicOnInvalidRHELVersionID(t *testing.T) { + require.NotPanics(t, func() { + d, err := NewFromRelease(linux.Release{ + ID: "rhel", + VersionID: "7.cv00", + }, testFixChannels()) + require.NoError(t, err) + assert.Equal(t, RedHat, d.Type) + assert.Equal(t, "7.cv00", d.Version) + }) +} + func Test_NewDistroFromRelease_Coverage(t *testing.T) { observedDistros := stringutil.NewStringSet() definedDistros := stringutil.NewStringSet() diff --git a/grype/internal/packagemetadata/discover_type_names.go b/grype/internal/packagemetadata/discover_type_names.go index ed26c57e1a3..6b3d6a49fce 100644 --- a/grype/internal/packagemetadata/discover_type_names.go +++ b/grype/internal/packagemetadata/discover_type_names.go @@ -20,6 +20,7 @@ var metadataExceptions = strset.New( "SBOMFileMetadata", "PURLLiteralMetadata", "CPELiteralMetadata", + "ZarfPackageMetadata", ) func DiscoverTypeNames() ([]string, error) { diff --git a/grype/matcher/golang/govulndb_test.go b/grype/matcher/golang/govulndb_test.go new file mode 100644 index 00000000000..23cb88d1a23 --- /dev/null +++ b/grype/matcher/golang/govulndb_test.go @@ -0,0 +1,147 @@ +package golang + +import ( + "testing" + + "github.com/anchore/grype/grype/match" + "github.com/anchore/grype/grype/pkg" + "github.com/anchore/grype/internal/dbtest" + syftPkg "github.com/anchore/syft/syft/pkg" +) + +// TestMatcherGolang_GoVulnDB drives ecosystem-name matching against Go vuln DB +// (vuln.go.dev) records via the govulndb vunnel provider. The transformer emits +// the "stdlib" pseudo-module and the golang.org/x/* extended standard libraries; +// general third-party modules are dropped to avoid the false-positive load from +// module-path/range mismatches. This test covers the "stdlib" pseudo-module; the +// golang.org/x/* path is covered by TestMatcherGolang_GoVulnDB_GolangOrgX. The +// general third-party cases (gin, grafana, mattermost, docker/cli, k8s) were +// removed because those packages no longer produce matches; transformer-level +// behavior for custom_ranges, withdrawn status, etc. remains covered by the unit +// tests in the osv transformer package. +func TestMatcherGolang_GoVulnDB(t *testing.T) { + // GO-2023-1840 declares stdlib vulnerable for everything below 1.19.10 (plus + // a later [1.20.0, 1.20.5) window). The sub-1.19.10 cases below use a version + // under 1.19.10, so 1840 matches each one on top of whatever GO-2022-0969 + // window it falls in. + const stdlibBelow11910 = "GO-2023-1840" + + tests := []struct { + name string + pkgName string + pkgVersion string + expectIDs []string // empty means no match expected + }{ + { + // GO-2022-0969 first window (< 1.18.6); also under GO-2023-1840's < 1.19.10. + name: "stdlib in first vulnerable window: GO-2022-0969 flags it", + pkgName: "stdlib", + pkgVersion: "go1.18.0", + expectIDs: []string{"GO-2022-0969", stdlibBelow11910}, + }, + { + // Past GO-2022-0969's first fix (1.18.6) but still below 1.19.10. + name: "stdlib past GO-2022-0969 first fix but still below GO-2023-1840 fix: only 1840 flags it", + pkgName: "stdlib", + pkgVersion: "go1.18.6", + expectIDs: []string{stdlibBelow11910}, + }, + { + // GO-2022-0969 second window ([1.19.0, 1.19.1)); still under 1.19.10. + name: "stdlib in second vulnerable window: GO-2022-0969 flags it", + pkgName: "stdlib", + pkgVersion: "go1.19.0", + expectIDs: []string{"GO-2022-0969", stdlibBelow11910}, + }, + { + // Past every stdlib window in the fixture: no match. Guards against + // over-matching stdlib once non-stdlib records are out of the picture. + name: "stdlib past all fixed versions: no match", + pkgName: "stdlib", + pkgVersion: "go1.25.0", + }, + } + + dbtest.DBs(t, "govulndb-go").Run(func(t *testing.T, db *dbtest.DB) { + matcher := NewGolangMatcher(MatcherConfig{}) + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + p := dbtest.NewPackage(tt.pkgName, tt.pkgVersion, syftPkg.GoModulePkg). + WithLanguage(syftPkg.Go). + WithMetadata(pkg.GolangBinMetadata{}). + Build() + + findings := db.Match(t, matcher, p) + + if len(tt.expectIDs) == 0 { + findings.IsEmpty() + return + } + + for _, id := range tt.expectIDs { + findings.SelectMatch(id). + SelectDetailByType(match.ExactDirectMatch). + AsEcosystemSearch() + } + }) + } + }) +} + +// TestMatcherGolang_GoVulnDB_GolangOrgX covers the golang.org/x/* extended +// standard libraries, which the transformer now emits alongside stdlib (they are +// versioned by the Go team and absent from GHSA, so they avoid the false-positive +// load that keeps general third-party Go modules out). GO-2022-0969 also lists +// golang.org/x/net as affected below the pseudo-version +// 0.0.0-20220906165146-f3363e06e74c, so a golang.org/x/net package under that fix +// must match and one at/after it must not. +func TestMatcherGolang_GoVulnDB_GolangOrgX(t *testing.T) { + tests := []struct { + name string + pkgName string + pkgVersion string + expectIDs []string // empty means no match expected + }{ + { + // pseudo-version older than GO-2022-0969's fix (a Feb 2022 commit + // predates the Sep 2022 fix pseudo-version), so it is vulnerable. + name: "golang.org/x/net before fix pseudo-version: GO-2022-0969 flags it", + pkgName: "golang.org/x/net", + pkgVersion: "v0.0.0-20220225172249-27dd8689420f", + expectIDs: []string{"GO-2022-0969"}, + }, + { + // a tagged release well past the fix pseudo-version: no match. + name: "golang.org/x/net past fix: no match", + pkgName: "golang.org/x/net", + pkgVersion: "v0.17.0", + }, + } + + dbtest.DBs(t, "govulndb-go").Run(func(t *testing.T, db *dbtest.DB) { + matcher := NewGolangMatcher(MatcherConfig{}) + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + p := dbtest.NewPackage(tt.pkgName, tt.pkgVersion, syftPkg.GoModulePkg). + WithLanguage(syftPkg.Go). + WithMetadata(pkg.GolangBinMetadata{}). + Build() + + findings := db.Match(t, matcher, p) + + if len(tt.expectIDs) == 0 { + findings.IsEmpty() + return + } + + for _, id := range tt.expectIDs { + findings.SelectMatch(id). + SelectDetailByType(match.ExactDirectMatch). + AsEcosystemSearch() + } + }) + } + }) +} diff --git a/grype/matcher/golang/matcher_test.go b/grype/matcher/golang/matcher_test.go index 5693d53228c..3b113b1046e 100644 --- a/grype/matcher/golang/matcher_test.go +++ b/grype/matcher/golang/matcher_test.go @@ -167,3 +167,53 @@ func TestMatcher_SearchForStdlib(t *testing.T) { }) } } + +// TestMatcher_StdLibFromGoVulnDb proves the everyday case: a plain +// pkg:golang/stdlib package (no CPEs, no main-module metadata, default +// matcher config) is flagged for a Go vuln DB stdlib advisory purely via +// ecosystem-name search. GO-2023-1840 (CVE-2023-29403) declares stdlib +// vulnerable in [0, 1.19.10) || [1.20.0, 1.20.5), so 1.18.7 lands in the +// first window and must match, while 1.19.10 (the fix) must not. +func TestMatcher_StdLibFromGoVulnDb(t *testing.T) { + tests := []struct { + name string + version string + expectHit bool + }{ + { + name: "stdlib 1.18.7 is inside the first vulnerable window: GO-2023-1840 flags it", + version: "1.18.7", + expectHit: true, + }, + { + name: "stdlib 1.19.10 is the fixed version: no match", + version: "1.19.10", + expectHit: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + dbtest.DBs(t, "govulndb-go"). + Run(func(t *testing.T, db *dbtest.DB) { + matcher := NewGolangMatcher(MatcherConfig{}) + + p := dbtest.NewPackage("stdlib", tt.version, syftPkg.GoModulePkg). + WithLanguage(syftPkg.Go). + WithMetadata(pkg.GolangBinMetadata{}). + Build() + + findings := db.Match(t, matcher, p) + + if !tt.expectHit { + findings.IsEmpty() + return + } + + findings.SelectMatch("GO-2023-1840"). + SelectDetailByType(match.ExactDirectMatch). + AsEcosystemSearch() + }) + }) + } +} diff --git a/grype/matcher/golang/testdata/govulndb-go/db-lock.json b/grype/matcher/golang/testdata/govulndb-go/db-lock.json new file mode 100644 index 00000000000..6a2ed0d5c5a --- /dev/null +++ b/grype/matcher/golang/testdata/govulndb-go/db-lock.json @@ -0,0 +1,11 @@ +{ + "content_hash": "56c5ebe67c1ccc2e", + "created_at": "2026-06-02T14:48:22.689885019Z", + "regenerated_at": "2026-06-23T15:21:21.274771973Z", + "providers": { + "govulndb": { + "vunnel_version": "vunnel@0.59.0.post3+53fcd5d", + "timestamp": "2026-06-22T00:58:35.822252Z" + } + } +} diff --git a/grype/matcher/golang/testdata/govulndb-go/db.yaml b/grype/matcher/golang/testdata/govulndb-go/db.yaml new file mode 100644 index 00000000000..fd97ad40285 --- /dev/null +++ b/grype/matcher/golang/testdata/govulndb-go/db.yaml @@ -0,0 +1,5 @@ +auto-generate: true +extractions: + govulndb: + - go-2022-0969 + - GO-2023-1840 diff --git a/grype/matcher/golang/testdata/govulndb-go/govulndb/metadata.json b/grype/matcher/golang/testdata/govulndb-go/govulndb/metadata.json new file mode 100644 index 00000000000..3b809c7214a --- /dev/null +++ b/grype/matcher/golang/testdata/govulndb-go/govulndb/metadata.json @@ -0,0 +1,19 @@ +{ + "provider": "govulndb", + "version": 1, + "processor": "vunnel@0.59.0.post3+53fcd5d", + "schema": { + "version": "1.0.3", + "url": "https://raw.githubusercontent.com/anchore/vunnel/main/schema/provider-workspace-state/schema-1.0.3.json" + }, + "urls": [ + "https://vuln.go.dev/vulndb.zip" + ], + "timestamp": "2026-06-22T00:58:35Z", + "listing": { + "path": "results/listing.xxh64", + "digest": "651b2ab4ad6d62d2", + "algorithm": "xxh64" + }, + "store": "flat-file" +} diff --git a/grype/matcher/golang/testdata/govulndb-go/govulndb/results/go-2022-0969.json b/grype/matcher/golang/testdata/govulndb-go/govulndb/results/go-2022-0969.json new file mode 100644 index 00000000000..6dd22abbb0b --- /dev/null +++ b/grype/matcher/golang/testdata/govulndb-go/govulndb/results/go-2022-0969.json @@ -0,0 +1,154 @@ +{ + "identifier": "go-2022-0969", + "item": { + "affected": [ + { + "ecosystem_specific": { + "imports": [ + { + "path": "net/http", + "symbols": [ + "ListenAndServe", + "ListenAndServeTLS", + "Serve", + "ServeTLS", + "Server.ListenAndServe", + "Server.ListenAndServeTLS", + "Server.Serve", + "Server.ServeTLS", + "http2Server.ServeConn", + "http2serverConn.goAway" + ] + } + ] + }, + "package": { + "ecosystem": "Go", + "name": "stdlib" + }, + "ranges": [ + { + "database_specific": { + "anchore": { + "fixes": [ + { + "date": "2022-09-12", + "kind": "advisory", + "version": "1.18.6" + }, + { + "date": "2022-09-12", + "kind": "advisory", + "version": "1.19.1" + } + ] + } + }, + "events": [ + { + "introduced": "0" + }, + { + "fixed": "1.18.6" + }, + { + "introduced": "1.19.0-0" + }, + { + "fixed": "1.19.1" + } + ], + "type": "SEMVER" + } + ] + }, + { + "ecosystem_specific": { + "imports": [ + { + "path": "golang.org/x/net/http2", + "symbols": [ + "Server.ServeConn", + "serverConn.goAway" + ] + } + ] + }, + "package": { + "ecosystem": "Go", + "name": "golang.org/x/net" + }, + "ranges": [ + { + "database_specific": { + "anchore": { + "fixes": [ + { + "date": "2022-09-12", + "kind": "advisory", + "version": "0.0.0-20220906165146-f3363e06e74c" + } + ] + } + }, + "events": [ + { + "introduced": "0" + }, + { + "fixed": "0.0.0-20220906165146-f3363e06e74c" + } + ], + "type": "SEMVER" + } + ] + } + ], + "aliases": [ + "CVE-2022-27664", + "GHSA-69cg-p879-7622" + ], + "credits": [ + { + "name": "Bahruz Jabiyev" + }, + { + "name": "Tommaso Innocenti" + }, + { + "name": "Anthony Gavazzi" + }, + { + "name": "Steven Sprecher" + }, + { + "name": "Kaan Onarlioglu" + } + ], + "database_specific": { + "review_status": "REVIEWED", + "url": "https://pkg.go.dev/vuln/GO-2022-0969" + }, + "details": "HTTP/2 server connections can hang forever waiting for a clean shutdown that was preempted by a fatal error. This condition can be exploited by a malicious client to cause a denial of service.", + "id": "GO-2022-0969", + "modified": "2024-05-20T16:03:47Z", + "published": "2022-09-12T20:23:06Z", + "references": [ + { + "type": "WEB", + "url": "https://groups.google.com/g/golang-announce/c/x49AQzIVX-s" + }, + { + "type": "REPORT", + "url": "https://go.dev/issue/54658" + }, + { + "type": "FIX", + "url": "https://go.dev/cl/428735" + } + ], + "schema_version": "1.3.1", + "summary": "Denial of service in net/http and golang.org/x/net/http2" + }, + "schema": "https://raw.githubusercontent.com/anchore/vunnel/main/schema/vulnerability/osv/schema-1.3.1.json" +} diff --git a/grype/matcher/golang/testdata/govulndb-go/govulndb/results/go-2023-1840.json b/grype/matcher/golang/testdata/govulndb-go/govulndb/results/go-2023-1840.json new file mode 100644 index 00000000000..6e3ff5b204d --- /dev/null +++ b/grype/matcher/golang/testdata/govulndb-go/govulndb/results/go-2023-1840.json @@ -0,0 +1,88 @@ +{ + "identifier": "go-2023-1840", + "item": { + "affected": [ + { + "ecosystem_specific": { + "imports": [ + { + "path": "runtime" + } + ] + }, + "package": { + "ecosystem": "Go", + "name": "stdlib" + }, + "ranges": [ + { + "database_specific": { + "anchore": { + "fixes": [ + { + "date": "2023-06-08", + "kind": "advisory", + "version": "1.19.10" + }, + { + "date": "2023-06-08", + "kind": "advisory", + "version": "1.20.5" + } + ] + } + }, + "events": [ + { + "introduced": "0" + }, + { + "fixed": "1.19.10" + }, + { + "introduced": "1.20.0-0" + }, + { + "fixed": "1.20.5" + } + ], + "type": "SEMVER" + } + ] + } + ], + "aliases": [ + "CVE-2023-29403" + ], + "credits": [ + { + "name": "Vincent Dehors from Synacktiv" + } + ], + "database_specific": { + "review_status": "REVIEWED", + "url": "https://pkg.go.dev/vuln/GO-2023-1840" + }, + "details": "On Unix platforms, the Go runtime does not behave differently when a binary is run with the setuid/setgid bits. This can be dangerous in certain cases, such as when dumping memory state, or assuming the status of standard i/o file descriptors.\n\nIf a setuid/setgid binary is executed with standard I/O file descriptors closed, opening any files can result in unexpected content being read or written with elevated privileges. Similarly, if a setuid/setgid program is terminated, either via panic or signal, it may leak the contents of its registers.", + "id": "GO-2023-1840", + "modified": "2024-05-20T16:03:47Z", + "published": "2023-06-08T20:16:06Z", + "references": [ + { + "type": "REPORT", + "url": "https://go.dev/issue/60272" + }, + { + "type": "FIX", + "url": "https://go.dev/cl/501223" + }, + { + "type": "WEB", + "url": "https://groups.google.com/g/golang-announce/c/q5135a9d924/m/j0ZoAJOHAwAJ" + } + ], + "schema_version": "1.3.1", + "summary": "Unsafe behavior in setuid/setgid binaries in runtime" + }, + "schema": "https://raw.githubusercontent.com/anchore/vunnel/main/schema/vulnerability/osv/schema-1.3.1.json" +} diff --git a/grype/matcher/internal/source_or_unspecified_arch.go b/grype/matcher/internal/source_or_unspecified_arch.go deleted file mode 100644 index 648d3a546e2..00000000000 --- a/grype/matcher/internal/source_or_unspecified_arch.go +++ /dev/null @@ -1,35 +0,0 @@ -package internal - -import ( - "fmt" - - "github.com/anchore/grype/grype/pkg/qualifier/architecture" - "github.com/anchore/grype/grype/search" - "github.com/anchore/grype/grype/vulnerability" -) - -// SourceOrUnspecifiedArch returns a criterion that drops vulnerability entries tagged with -// any rpm arch other than "src". Entries with no architecture qualifier (older databases -// or providers that don't distinguish source from binary) pass through unchanged, so -// existing upstream-indirected matching against source-granularity advisories keeps -// working. -// -// Pass this criterion only on upstream-search code paths in the RPM matcher. Direct-match -// paths must NOT use it: a binary-tagged entry is the canonical hit when scanning that -// exact binary RPM by name. -func SourceOrUnspecifiedArch() vulnerability.Criteria { - return search.ByFunc(func(vuln vulnerability.Vulnerability) (bool, string, error) { - for _, q := range vuln.PackageQualifiers { - ra, ok := q.(interface{ Arch() string }) - if !ok { - continue - } - a := ra.Arch() - if a == "" || a == architecture.ArchSource { - continue - } - return false, fmt.Sprintf("rpm arch %q is not source; not eligible for upstream-indirected match", a), nil - } - return true, "", nil - }) -} diff --git a/grype/matcher/internal/source_or_unspecified_arch_test.go b/grype/matcher/internal/source_or_unspecified_arch_test.go deleted file mode 100644 index a35559477e8..00000000000 --- a/grype/matcher/internal/source_or_unspecified_arch_test.go +++ /dev/null @@ -1,67 +0,0 @@ -package internal - -import ( - "testing" - - "github.com/stretchr/testify/require" - - "github.com/anchore/grype/grype/pkg/qualifier" - "github.com/anchore/grype/grype/pkg/qualifier/architecture" - "github.com/anchore/grype/grype/pkg/qualifier/rpmmodularity" - "github.com/anchore/grype/grype/vulnerability" -) - -func TestSourceOrUnspecifiedArch(t *testing.T) { - tests := []struct { - name string - qualifiers []qualifier.Qualifier - want bool - }{ - { - name: "no qualifiers passes through (old DBs / non-CSAF providers)", - qualifiers: nil, - want: true, - }, - { - name: "architecture=src passes", - qualifiers: []qualifier.Qualifier{architecture.New(architecture.ArchSource)}, - want: true, - }, - { - name: "architecture=binary-no-arch-specified is rejected", - qualifiers: []qualifier.Qualifier{architecture.New(architecture.ArchBinaryNoArchSpecified)}, - want: false, - }, - { - name: "literal arch like x86_64 is rejected (extension point for future arch-scoped advisories)", - qualifiers: []qualifier.Qualifier{architecture.New("x86_64")}, - want: false, - }, - { - name: "architecture=binary-no-arch-specified is rejected even alongside other qualifiers", - qualifiers: []qualifier.Qualifier{rpmmodularity.New(""), architecture.New(architecture.ArchBinaryNoArchSpecified)}, - want: false, - }, - { - name: "unrelated qualifiers do not interfere", - qualifiers: []qualifier.Qualifier{rpmmodularity.New("nodejs:18")}, - want: true, - }, - { - name: "empty architecture value passes (treated like unspecified)", - qualifiers: []qualifier.Qualifier{architecture.New("")}, - want: true, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - vuln := vulnerability.Vulnerability{ - PackageQualifiers: tt.qualifiers, - } - matched, _, err := SourceOrUnspecifiedArch().MatchesVulnerability(vuln) - require.NoError(t, err) - require.Equal(t, tt.want, matched) - }) - } -} diff --git a/grype/matcher/rpm/almalinux.go b/grype/matcher/rpm/almalinux.go index 2ea8d370c5d..e1e2c2a45b1 100644 --- a/grype/matcher/rpm/almalinux.go +++ b/grype/matcher/rpm/almalinux.go @@ -89,7 +89,6 @@ func almaLinuxMatchesWithUpstreams(provider result.Provider, binaryPkg pkg.Packa search.ByPackageName(upstreamPkg.Name), search.ByDistro(rhelCompatibleDistro), internal.OnlyQualifiedPackages(upstreamPkg), - internal.SourceOrUnspecifiedArch(), ) if err != nil { log.WithFields("error", err, "upstreamPkg", upstreamPkg.Name, "binaryPkg", binaryPkg.Name).Debug("failed to fetch RHEL disclosures for upstream package") diff --git a/grype/matcher/rpm/almalinux_test.go b/grype/matcher/rpm/almalinux_test.go index 1053d63dcf1..042ab98e8b6 100644 --- a/grype/matcher/rpm/almalinux_test.go +++ b/grype/matcher/rpm/almalinux_test.go @@ -66,6 +66,7 @@ func TestAlmaLinuxMatching_ModularVulnerable(t *testing.T) { matcher := Matcher{} // httpd 2.4.37-30 is below both RHEL fix (-39) and AlmaLinux fix (-43) p := dbtest.NewPackage("httpd", "2.4.37-30.module_el8.3.0+1234+abcd", syftPkg.RpmPkg). + WithArchitecture("x86_64"). WithDistro(dbtest.AlmaLinux8). WithMetadata(pkg.RpmMetadata{ Epoch: intPtr(0), @@ -95,6 +96,7 @@ func TestAlmaLinuxMatching_ModularFixed(t *testing.T) { // httpd at exact AlmaLinux fix version (which is past the RHEL fix) pkgID := pkg.ID("httpd-fixed") p := dbtest.NewPackage("httpd", "2.4.37-43.module_el8.5.0+2597+c4b14997.alma", syftPkg.RpmPkg). + WithArchitecture("x86_64"). WithID(pkgID). WithDistro(dbtest.AlmaLinux8). WithMetadata(pkg.RpmMetadata{ @@ -121,6 +123,7 @@ func TestAlmaLinuxMatching_ModularityMismatch(t *testing.T) { matcher := Matcher{} // httpd in a different module - no match p := dbtest.NewPackage("httpd", "2.4.37-30.module_el8.3.0+1234+abcd", syftPkg.RpmPkg). + WithArchitecture("aarch64"). WithDistro(dbtest.AlmaLinux8). WithMetadata(pkg.RpmMetadata{ Epoch: intPtr(0), @@ -142,6 +145,7 @@ func TestAlmaLinuxMatching_NonModularVulnerable(t *testing.T) { matcher := Matcher{} // patch 2.7.6-10 < fix 2.7.6-11 p := dbtest.NewPackage("patch", "2.7.6-10.el8", syftPkg.RpmPkg). + WithArchitecture("x86_64"). WithDistro(dbtest.AlmaLinux8). Build() @@ -163,6 +167,7 @@ func TestAlmaLinuxMatching_NonModularFixed(t *testing.T) { matcher := Matcher{} pkgID := pkg.ID("patch-fixed") p := dbtest.NewPackage("patch", "2.7.6-11.el8", syftPkg.RpmPkg). + WithArchitecture("x86_64"). WithID(pkgID). WithDistro(dbtest.AlmaLinux8). Build() @@ -183,6 +188,7 @@ func TestAlmaLinuxMatching_WontFixPassesThrough(t *testing.T) { Run(func(t *testing.T, db *dbtest.DB) { matcher := Matcher{} p := dbtest.NewPackage("tar", "2:1.30-5.el8", syftPkg.RpmPkg). + WithArchitecture("aarch64"). WithDistro(dbtest.AlmaLinux8). WithMetadata(pkg.RpmMetadata{Epoch: intPtr(2)}). Build() @@ -209,6 +215,7 @@ func TestAlmaLinuxMatching_UpstreamMatchWithFixReplacement(t *testing.T) { matcher := Matcher{} // httpd-tools binary at the same vulnerable version, upstream is httpd p := dbtest.NewPackage("httpd-tools", "2.4.37-30.module_el8.3.0+1234+abcd", syftPkg.RpmPkg). + WithArchitecture("x86_64"). WithDistro(dbtest.AlmaLinux8). WithUpstream("httpd", "2.4.37-30.module_el8.3.0+1234+abcd"). WithMetadata(pkg.RpmMetadata{ @@ -249,6 +256,7 @@ func TestAlmaLinuxMatching_LowerAlmaModuleBuildFiltersVulnerability(t *testing.T matcher := Matcher{} pkgID := pkg.ID("mariadb-at-alma-fix") p := dbtest.NewPackage("mariadb", "3:10.3.28-1.module_el8.3.0+2177+7adc332a", syftPkg.RpmPkg). + WithArchitecture("x86_64"). WithID(pkgID). WithDistro(dbtest.AlmaLinux8). WithMetadata(pkg.RpmMetadata{ @@ -279,6 +287,7 @@ func TestAlmaLinuxMatching_BelowBothModuleBuildsStillVulnerable(t *testing.T) { matcher := Matcher{} // 10.3.27 < both fix versions (alma 10.3.28 and rhel 10.3.28) p := dbtest.NewPackage("mariadb", "3:10.3.27-3.module_el8.3.0+1234+abcdef", syftPkg.RpmPkg). + WithArchitecture("aarch64"). WithDistro(dbtest.AlmaLinux8). WithMetadata(pkg.RpmMetadata{ Epoch: intPtr(3), @@ -304,6 +313,7 @@ func TestAlmaLinuxMatching_DebuginfoSkipped(t *testing.T) { Run(func(t *testing.T, db *dbtest.DB) { matcher := Matcher{} p := dbtest.NewPackage("patch-debuginfo", "2.7.6-10.el8", syftPkg.RpmPkg). + WithArchitecture("x86_64"). WithDistro(dbtest.AlmaLinux8). WithUpstream("patch", "2.7.6-10.el8"). Build() @@ -320,6 +330,7 @@ func TestAlmaLinuxIgnoreFilters_NoIgnoresWhenVulnerable(t *testing.T) { Run(func(t *testing.T, db *dbtest.DB) { matcher := Matcher{} p := dbtest.NewPackage("patch", "2.7.6-1.el8", syftPkg.RpmPkg). + WithArchitecture("x86_64"). WithDistro(dbtest.AlmaLinux8). Build() @@ -339,6 +350,7 @@ func TestAlmaLinuxIgnoreFilters_DistroFixedIgnore(t *testing.T) { matcher := Matcher{} pkgID := pkg.ID("patch-fixed") p := dbtest.NewPackage("patch", "2.7.6-12.el8", syftPkg.RpmPkg). + WithArchitecture("aarch64"). WithID(pkgID). WithDistro(dbtest.AlmaLinux8). Build() @@ -376,6 +388,7 @@ func TestAlmaLinuxIgnoreFilters_AlmaUnaffectedAndAliasUnwind(t *testing.T) { matcher := Matcher{} pkgID := pkg.ID("httpd-multi-cve-fixed") p := dbtest.NewPackage("httpd", "2.4.37-43.module_el8.5.0+2597+c4b14997.alma", syftPkg.RpmPkg). + WithArchitecture("x86_64"). WithID(pkgID). WithDistro(dbtest.AlmaLinux8). WithMetadata(pkg.RpmMetadata{ diff --git a/grype/matcher/rpm/hummingbird_test.go b/grype/matcher/rpm/hummingbird_test.go index 33e01f4d0da..375223e65f7 100644 --- a/grype/matcher/rpm/hummingbird_test.go +++ b/grype/matcher/rpm/hummingbird_test.go @@ -17,9 +17,10 @@ import ( // 1. The csafvex transformer drops a src product when a same-named binary is also in the // advisory's known_affected ∪ fixed for the same platform. // 2. Every emitted RPM-typed entry is tagged with an architecture ("src" / -// "binary-no-arch-specified" / a literal arch). The RPM matcher's upstream-search path -// adds internal.SourceOrUnspecifiedArch() so any non-src tagged entry is excluded — -// direct matches still hit because that path doesn't apply the criterion. +// "binary-no-arch-specified" / a literal arch). The RPM matcher tags its synthesized +// upstream (source) package "src", so the architecture qualifier matches it only against +// src records and rejects binary-arch records — direct matches still hit because the +// scanned binary's own arch matches the binary/concrete records. // // These tests exercise the end-to-end build → match flow against a real fixture // (cve-2026-5928) extracted from the local hummingbird vunnel cache. @@ -33,12 +34,13 @@ func TestHummingbirdMatching_BinaryDirectHit(t *testing.T) { Run(func(t *testing.T, db *dbtest.DB) { matcher := Matcher{} p := dbtest.NewPackage("glibc", "2.42-11.1.hum1", syftPkg.RpmPkg). + WithArchitecture("x86_64"). WithDistro(dbtest.Hummingbird1). Build() findings := db.Match(t, &matcher, p) findings.SelectMatch("CVE-2026-5928"). - InNamespace("hummingbird:distro:hummingbird:1"). + InNamespace("hummingbird:distro:hummingbird:rolling"). SelectDetailByDistro("hummingbird", "1"). HasMatchType(match.ExactDirectMatch) }) @@ -48,15 +50,16 @@ func TestHummingbirdMatching_BinaryDirectHit(t *testing.T) { // reported FP: glibc-minimal-langpack is built from glibc.src but is NOT named in the // advisory. Without our changes, grype's upstream search (name=glibc, derived from the // package's `upstream` field) would hit the binary `glibc` row in the DB and emit an -// indirect match. With architecture tagging + SourceOrUnspecifiedArch, the upstream search -// excludes the binary row, and there's no glibc-minimal-langpack entry to direct-match, -// so no match is produced. +// indirect match. Because the matcher tags the synthesized upstream package "src", the +// architecture qualifier excludes the binary `glibc` row from the upstream search, and +// there's no glibc-minimal-langpack entry to direct-match, so no match is produced. func TestHummingbirdMatching_SiblingBinaryUpstreamFiltered(t *testing.T) { dbtest.DBs(t, "hummingbird"). SelectOnly("cve-2026-5928"). Run(func(t *testing.T, db *dbtest.DB) { matcher := Matcher{} p := dbtest.NewPackage("glibc-minimal-langpack", "2.42-11.1.hum1", syftPkg.RpmPkg). + WithArchitecture("aarch64"). WithDistro(dbtest.Hummingbird1). WithUpstream("glibc", "2.42-11.1.hum1"). WithMetadata(pkg.RpmMetadata{Epoch: intPtr(0)}). @@ -77,6 +80,7 @@ func TestHummingbirdMatching_GlibcCommonDirectOnly(t *testing.T) { Run(func(t *testing.T, db *dbtest.DB) { matcher := Matcher{} p := dbtest.NewPackage("glibc-common", "2.42-11.1.hum1", syftPkg.RpmPkg). + WithArchitecture("x86_64"). WithDistro(dbtest.Hummingbird1). WithUpstream("glibc", "2.42-11.1.hum1"). WithMetadata(pkg.RpmMetadata{Epoch: intPtr(0)}). @@ -107,6 +111,7 @@ func TestHummingbirdMatching_PerlBDirectFix(t *testing.T) { // binary's own fix (5.42.2-524.1.hum1) — without the filter the upstream // search would indirect-match via the perl entry. p := dbtest.NewPackage("perl-B", "1.85-524.1.hum1", syftPkg.RpmPkg). + WithArchitecture("x86_64"). WithDistro(dbtest.Hummingbird1). WithUpstream("perl", "5.42.0-524.1.hum1"). WithMetadata(pkg.RpmMetadata{Epoch: intPtr(0)}). @@ -114,7 +119,7 @@ func TestHummingbirdMatching_PerlBDirectFix(t *testing.T) { findings := db.Match(t, &matcher, p) findings.SelectMatch("CVE-2018-18311"). - InNamespace("hummingbird:distro:hummingbird:1"). + InNamespace("hummingbird:distro:hummingbird:rolling"). SelectDetailByDistro("hummingbird", "1"). HasMatchType(match.ExactDirectMatch) }) diff --git a/grype/matcher/rpm/matcher.go b/grype/matcher/rpm/matcher.go index 53439a26c9b..9efd77d40c8 100644 --- a/grype/matcher/rpm/matcher.go +++ b/grype/matcher/rpm/matcher.go @@ -215,7 +215,11 @@ func (m *Matcher) matchUpstreamPackages(vp vulnerability.Provider, p pkg.Package var ignored []match.IgnoreFilter for _, indirectPackage := range pkg.UpstreamPackages(p) { - indirectMatches, ignores, err := m.findMatches(provider, indirectPackage, internal.SourceOrUnspecifiedArch()) + // An rpm's upstream is its source rpm, so tag the synthesized package "src". The + // architecture qualifier then matches it only against src (and unspecified) records + // and rejects binary-arch records — which is how we avoid matching a binary's + // upstream against a sibling binary's vulnerability. + indirectMatches, ignores, err := m.findMatches(provider, indirectPackage) if err != nil { return nil, nil, fmt.Errorf("failed to find vulnerabilities for rpm upstream source package: %w", err) } @@ -226,7 +230,7 @@ func (m *Matcher) matchUpstreamPackages(vp vulnerability.Provider, p pkg.Package return matches, ignored, nil } -func (m *Matcher) findMatches(provider result.Provider, searchPkg pkg.Package, extra ...vulnerability.Criteria) ([]match.Match, []match.IgnoreFilter, error) { +func (m *Matcher) findMatches(provider result.Provider, searchPkg pkg.Package) ([]match.Match, []match.IgnoreFilter, error) { if searchPkg.Distro == nil { return nil, nil, nil } @@ -237,13 +241,13 @@ func (m *Matcher) findMatches(provider result.Provider, searchPkg pkg.Package, e switch { case shouldUseRedhatEUSMatching(searchPkg.Distro): - return redhatEUSMatches(provider, searchPkg, m.cfg.MissingEpochStrategy, extra...) + return redhatEUSMatches(provider, searchPkg, m.cfg.MissingEpochStrategy) default: - return m.standardMatches(provider, searchPkg, extra...) + return m.standardMatches(provider, searchPkg) } } -func (m *Matcher) standardMatches(provider result.Provider, searchPkg pkg.Package, extra ...vulnerability.Criteria) ([]match.Match, []match.IgnoreFilter, error) { +func (m *Matcher) standardMatches(provider result.Provider, searchPkg pkg.Package) ([]match.Match, []match.IgnoreFilter, error) { // Create version with config embedded pkgVersion := version.NewWithConfig( searchPkg.Version, @@ -258,7 +262,6 @@ func (m *Matcher) standardMatches(provider result.Provider, searchPkg pkg.Packag search.ByDistro(*searchPkg.Distro), internal.OnlyQualifiedPackages(searchPkg), } - disclosureCriteria = append(disclosureCriteria, extra...) all, err := provider.FindResults(disclosureCriteria...) if err != nil { @@ -272,7 +275,6 @@ func (m *Matcher) standardMatches(provider result.Provider, searchPkg pkg.Packag internal.OnlyVulnerableVersions(pkgVersion), search.ForUnaffected(), } - unaffectedCriteria = append(unaffectedCriteria, extra...) unaffected, err := provider.FindResults(unaffectedCriteria...) if err != nil { diff --git a/grype/matcher/rpm/matcher_test.go b/grype/matcher/rpm/matcher_test.go index 6d1b3bf4999..21a5a659aa8 100644 --- a/grype/matcher/rpm/matcher_test.go +++ b/grype/matcher/rpm/matcher_test.go @@ -20,6 +20,7 @@ func TestMatcherRpm_DirectMatch(t *testing.T) { matcher := Matcher{} // openssl 1:1.1.1b-1 is older than the RHEL 8 fix 1:1.1.1c-2.el8 p := dbtest.NewPackage("openssl", "1:1.1.1b-1.el8", syftPkg.RpmPkg). + WithArchitecture("aarch64"). WithDistro(dbtest.RHEL8). WithMetadata(pkg.RpmMetadata{Epoch: intPtr(1)}). Build() @@ -40,6 +41,7 @@ func TestMatcherRpm_IndirectMatchBySource(t *testing.T) { Run(func(t *testing.T, db *dbtest.DB) { matcher := Matcher{} p := dbtest.NewPackage("openssl-libs", "1:1.1.1b-1.el8", syftPkg.RpmPkg). + WithArchitecture("x86_64"). WithDistro(dbtest.RHEL8). WithUpstream("openssl", "1:1.1.1b-1.el8"). WithMetadata(pkg.RpmMetadata{Epoch: intPtr(1)}). @@ -61,6 +63,7 @@ func TestMatcherRpm_FixedVersionNoMatch(t *testing.T) { // a "Distro Not Vulnerable" ignore so consumers can suppress related // matches (e.g., GHSA-language matches that overlap by file ownership) p := dbtest.NewPackage("openssl", "1:1.1.1c-2.el8", syftPkg.RpmPkg). + WithArchitecture("x86_64"). WithDistro(dbtest.RHEL8). WithMetadata(pkg.RpmMetadata{Epoch: intPtr(1)}). Build() @@ -77,6 +80,7 @@ func TestMatcherRpm_ModularityLabelMatchesVulnInSameModule(t *testing.T) { matcher := Matcher{} // httpd in the httpd:2.4 module - the RHEL fix is in module httpd:2.4 p := dbtest.NewPackage("httpd", "0:2.4.37-30.module+el8.3.0+1.el8", syftPkg.RpmPkg). + WithArchitecture("aarch64"). WithDistro(dbtest.RHEL8). WithMetadata(pkg.RpmMetadata{ Epoch: intPtr(0), @@ -103,6 +107,7 @@ func TestMatcherRpm_PackageWithoutModularityLabelMatchesModuleVuln(t *testing.T) matcher := Matcher{} // CVE-2018-17199 has Module="httpd:2.4"; pkg has no ModularityLabel p := dbtest.NewPackage("httpd", "0:2.4.37-30.module+el8.3.0+1.el8", syftPkg.RpmPkg). + WithArchitecture("x86_64"). WithDistro(dbtest.RHEL8). WithMetadata(pkg.RpmMetadata{Epoch: intPtr(0)}). Build() @@ -121,6 +126,7 @@ func TestMatcherRpm_ModularityLabelMismatchSkipsVuln(t *testing.T) { Run(func(t *testing.T, db *dbtest.DB) { matcher := Matcher{} p := dbtest.NewPackage("httpd", "0:2.4.37-30.module+el8.3.0+1.el8", syftPkg.RpmPkg). + WithArchitecture("x86_64"). WithDistro(dbtest.RHEL8). WithMetadata(pkg.RpmMetadata{ Epoch: intPtr(0), @@ -146,6 +152,7 @@ func TestMatcherRpm_PackageWithoutEpochAssumesZero(t *testing.T) { Run(func(t *testing.T, db *dbtest.DB) { matcher := Matcher{} p := dbtest.NewPackage("openssl", "1.1.1z-99.el8", syftPkg.RpmPkg). + WithArchitecture("aarch64"). WithDistro(dbtest.RHEL8). Build() @@ -167,6 +174,7 @@ func TestMatcherRpm_PackageEpochOnlyInMetadata(t *testing.T) { Run(func(t *testing.T, db *dbtest.DB) { matcher := Matcher{} p := dbtest.NewPackage("openssl", "1.1.1c-2.el8", syftPkg.RpmPkg). + WithArchitecture("x86_64"). WithDistro(dbtest.RHEL8). WithMetadata(pkg.RpmMetadata{Epoch: intPtr(0)}). Build() @@ -188,6 +196,7 @@ func TestMatcherRpm_PackageHigherEpochNoMatch(t *testing.T) { Run(func(t *testing.T, db *dbtest.DB) { matcher := Matcher{} p := dbtest.NewPackage("openssl", "2:1.1.1b-1.el8", syftPkg.RpmPkg). + WithArchitecture("x86_64"). WithDistro(dbtest.RHEL8). WithMetadata(pkg.RpmMetadata{Epoch: intPtr(2)}). Build() @@ -211,6 +220,7 @@ func TestMatcherRpm_DistroNotVulnerableIgnore(t *testing.T) { // openssl 1:1.1.1d-1.el8 is past the RHEL 8 fix 1:1.1.1c-2.el8 → not vulnerable pkgID := pkg.ID("openssl-fixed") p := dbtest.NewPackage("openssl", "1:1.1.1d-1.el8", syftPkg.RpmPkg). + WithArchitecture("aarch64"). WithID(pkgID). WithDistro(dbtest.RHEL8). WithMetadata(pkg.RpmMetadata{Epoch: intPtr(1)}). @@ -235,6 +245,7 @@ func TestMatcherRpm_DistroNotVulnerableIgnoreViaUpstream(t *testing.T) { // openssl-libs is owned by upstream openssl source RPM; both at fixed version pkgID := pkg.ID("openssl-libs-fixed") p := dbtest.NewPackage("openssl-libs", "1:1.1.1d-1.el8", syftPkg.RpmPkg). + WithArchitecture("x86_64"). WithID(pkgID). WithDistro(dbtest.RHEL8). WithUpstream("openssl", "1:1.1.1d-1.el8"). @@ -264,6 +275,7 @@ func TestMatcherRpm_UnaffectedRecordProducesIgnore(t *testing.T) { matcher := Matcher{} pkgID := pkg.ID("glibc-unaffected") p := dbtest.NewPackage("glibc", "2.28-100.el8", syftPkg.RpmPkg). + WithArchitecture("x86_64"). WithID(pkgID). WithDistro(dbtest.RHEL8). Build() @@ -287,6 +299,7 @@ func TestMatcherRpm_MajorOnlyUnaffectedRecordAppliesToMinorScan(t *testing.T) { matcher := Matcher{} pkgID := pkg.ID("glibc-on-8.4") p := dbtest.NewPackage("glibc", "2.28-100.el8", syftPkg.RpmPkg). + WithArchitecture("aarch64"). WithID(pkgID). WithDistro(distro.New(distro.RedHat, "8.4", "")). Build() @@ -313,6 +326,7 @@ func TestMatcherRpm_VulnerableAndUnaffectedInSameCall(t *testing.T) { matcher := Matcher{} pkgID := pkg.ID("glibc-mixed") p := dbtest.NewPackage("glibc", "2.28-100.el8", syftPkg.RpmPkg). + WithArchitecture("x86_64"). WithID(pkgID). WithDistro(dbtest.RHEL8). Build() @@ -336,6 +350,7 @@ func TestMatcherRpm_NoIgnoresWhenVulnerable(t *testing.T) { Run(func(t *testing.T, db *dbtest.DB) { matcher := Matcher{} p := dbtest.NewPackage("openssl", "1:1.1.1a-1.el8", syftPkg.RpmPkg). + WithArchitecture("x86_64"). WithDistro(dbtest.RHEL8). WithMetadata(pkg.RpmMetadata{Epoch: intPtr(1)}). Build() @@ -419,6 +434,7 @@ func TestMatcherRpm_CPEFallbackWhenEOL_EOLDistroEnabled(t *testing.T) { // RHEL 7 (EOL'd 2024-06-30); openssl 1.1.0a CPE matches NVD CVE-2018-0735 // (vulnerable range 1.1.0 - 1.1.0i) p := dbtest.NewPackage("openssl", "1.1.0a", syftPkg.RpmPkg). + WithArchitecture("aarch64"). WithDistro(distro.New(distro.RedHat, "7", "")). WithCPE("cpe:2.3:a:openssl:openssl:1.1.0a:*:*:*:*:*:*:*"). Build() @@ -442,6 +458,7 @@ func TestMatcherRpm_CPEFallbackWhenEOL_EOLDistroDisabled(t *testing.T) { matcher := NewRpmMatcher(MatcherConfig{UseCPEsForEOL: false}) p := dbtest.NewPackage("openssl", "1.1.0a", syftPkg.RpmPkg). + WithArchitecture("x86_64"). WithDistro(distro.New(distro.RedHat, "7", "")). WithCPE("cpe:2.3:a:openssl:openssl:1.1.0a:*:*:*:*:*:*:*"). Build() @@ -463,6 +480,7 @@ func TestMatcherRpm_CPEFallbackWhenEOL_NoEOLData(t *testing.T) { // fake distro with no EOL data → fallback should not engage p := dbtest.NewPackage("openssl", "1.1.0a", syftPkg.RpmPkg). + WithArchitecture("x86_64"). WithDistro(distro.New(distro.RedHat, "9999", "")). WithCPE("cpe:2.3:a:openssl:openssl:1.1.0a:*:*:*:*:*:*:*"). Build() @@ -483,6 +501,7 @@ func TestMatcherRpm_CPEFallbackWhenEOL_DistroNotEOL_FlagEnabled(t *testing.T) { matcher := NewRpmMatcher(MatcherConfig{UseCPEsForEOL: true}) p := dbtest.NewPackage("openssl", "1.1.0a", syftPkg.RpmPkg). + WithArchitecture("aarch64"). WithDistro(dbtest.RHEL9). WithCPE("cpe:2.3:a:openssl:openssl:1.1.0a:*:*:*:*:*:*:*"). Build() @@ -499,6 +518,7 @@ func TestMatcherRpm_CPEFallbackWhenEOL_DistroNotEOL_FlagDisabled(t *testing.T) { matcher := NewRpmMatcher(MatcherConfig{UseCPEsForEOL: false}) p := dbtest.NewPackage("openssl", "1.1.0a", syftPkg.RpmPkg). + WithArchitecture("x86_64"). WithDistro(dbtest.RHEL9). WithCPE("cpe:2.3:a:openssl:openssl:1.1.0a:*:*:*:*:*:*:*"). Build() diff --git a/grype/matcher/rpm/oracle_arch_test.go b/grype/matcher/rpm/oracle_arch_test.go new file mode 100644 index 00000000000..edc229a62d0 --- /dev/null +++ b/grype/matcher/rpm/oracle_arch_test.go @@ -0,0 +1,51 @@ +package rpm + +import ( + "testing" + + "github.com/anchore/grype/grype/distro" + "github.com/anchore/grype/grype/match" + "github.com/anchore/grype/internal/dbtest" + syftPkg "github.com/anchore/syft/syft/pkg" +) + +// oracle7 is the distro a scanned Oracle Linux 7 package carries. +var oracle7 = distro.New(distro.OracleLinux, "7", "") + +// TestOraclePerArchFix_NoFalsePositiveOnPatchedArch is the end-to-end regression for the +// ELSA-2022-4803 false positive. Oracle respun the aarch64 rsyslog at a higher revision +// (.0.4) than the x86_64 build (.0.1); vunnel records that as two FixedIn entries with distinct +// Arch, and the os transformer emits one affected package handle per arch carrying an +// Architecture qualifier. So a patched x86_64 rsyslog (.0.1) must NOT be flagged by the aarch64 +// fix, while an aarch64 build at the same version still is (it's below the aarch64 fix). Before +// arch was carried through to a qualifier, the higher .0.4 constraint falsely flagged the +// patched x86_64 package. +func TestOraclePerArchFix_NoFalsePositiveOnPatchedArch(t *testing.T) { + dbtest.DBs(t, "oracle"). + SelectOnly("ol:7/elsa-2022-4803"). + Run(func(t *testing.T, db *dbtest.DB) { + matcher := Matcher{} + + // x86_64 host patched to the x86_64 fix (.0.1) must NOT be reported vulnerable: the + // aarch64-scoped .0.4 record is the FP we're preventing. Instead the matcher recognizes + // the package is at the x86_64 fix and emits a "Distro Not Vulnerable" ignore for the + // advisory and its CVE alias — exactly the patched-but-acknowledged outcome. + patchedX86 := dbtest.NewPackage("rsyslog", "0:8.24.0-57.0.1.el7_9.3", syftPkg.RpmPkg). + WithArchitecture("x86_64"). + WithDistro(oracle7). + Build() + patched := db.Match(t, &matcher, patchedX86) + patched.DoesNotHaveAnyVulnerabilities("ELSA-2022-4803", "CVE-2022-24903") + patched.Ignores().SelectRelatedPackageIgnores(IgnoreReasonDistroNotVulnerable, "ELSA-2022-4803", "CVE-2022-24903") + + // an aarch64 build at the same version is still below the aarch64 fix (.0.4) → vulnerable. + vulnerableArm := dbtest.NewPackage("rsyslog", "0:8.24.0-57.0.1.el7_9.3", syftPkg.RpmPkg). + WithArchitecture("aarch64"). + WithDistro(oracle7). + Build() + db.Match(t, &matcher, vulnerableArm). + SelectMatch("ELSA-2022-4803"). + SelectDetailByType(match.ExactDirectMatch). + AsDistroSearch() + }) +} diff --git a/grype/matcher/rpm/rhel_eus.go b/grype/matcher/rpm/rhel_eus.go index 44bfa8e022d..a90a4f8bc79 100644 --- a/grype/matcher/rpm/rhel_eus.go +++ b/grype/matcher/rpm/rhel_eus.go @@ -163,7 +163,7 @@ func shouldUseRedhatEUSMatching(d *distro.Distro) bool { // Any disclosure that does not apply to the original package version (e.g. a fix was found) at this point has been removed. // // The final step is to render the final matches from the merged collection. -func redhatEUSMatches(provider result.Provider, searchPkg pkg.Package, missingEpochStrategy version.MissingEpochStrategy, extra ...vulnerability.Criteria) ([]match.Match, []match.IgnoreFilter, error) { +func redhatEUSMatches(provider result.Provider, searchPkg pkg.Package, missingEpochStrategy version.MissingEpochStrategy) ([]match.Match, []match.IgnoreFilter, error) { distroWithoutEUS := *searchPkg.Distro distroWithoutEUS.Channels = nil // clear the EUS channel so that we can search for the base distro @@ -182,7 +182,6 @@ func redhatEUSMatches(provider result.Provider, searchPkg pkg.Package, missingEp internal.OnlyQualifiedPackages(searchPkg), internal.OnlyVulnerableVersions(pkgVersion), // if these records indicate the version of the package is not vulnerable, do not include them } - disclosureCriteria = append(disclosureCriteria, extra...) // find all disclosures for the package in the base distro (e.g. '>= 9.0 && < 10') disclosures, err := provider.FindResults(disclosureCriteria...) @@ -202,7 +201,6 @@ func redhatEUSMatches(provider result.Provider, searchPkg pkg.Package, missingEp // and combine within the collection. If we do filter on a fix version, it could result in // false positives (missing EUS fixes that resolve a disclosure). } - resolutionCriteria = append(resolutionCriteria, extra...) // find all base distro fixes (e.g. '>= 9.0 && < 10') and EUS fixes for the package in the specific minor version of the distro (e.g. '9.4+eus') resolutions, err := provider.FindResults(resolutionCriteria...) diff --git a/grype/matcher/rpm/rhel_eus_test.go b/grype/matcher/rpm/rhel_eus_test.go index c54b7ee7bfb..52834197756 100644 --- a/grype/matcher/rpm/rhel_eus_test.go +++ b/grype/matcher/rpm/rhel_eus_test.go @@ -806,6 +806,7 @@ func TestRedhatEUSMatches_VulnerableOnEUS(t *testing.T) { matcher := Matcher{} // EUS 9.4 fix is 5.14.0-427.68.1.el9_4; 100 < 427 → vulnerable p := dbtest.NewPackage("kernel", "0:5.14.0-100.el9_4", syftPkg.RpmPkg). + WithArchitecture("x86_64"). WithDistro(newEUSDistro("9.4")). WithMetadata(pkg.RpmMetadata{Epoch: intPtr(0)}). Build() @@ -832,6 +833,7 @@ func TestRedhatEUSMatches_IndirectMatchBySource(t *testing.T) { Run(func(t *testing.T, db *dbtest.DB) { matcher := Matcher{} p := dbtest.NewPackage("kernel-tools", "0:5.14.0-100.el9_4", syftPkg.RpmPkg). + WithArchitecture("aarch64"). WithDistro(newEUSDistro("9.4")). WithUpstream("kernel", "0:5.14.0-100.el9_4"). WithMetadata(pkg.RpmMetadata{Epoch: intPtr(0)}). @@ -854,6 +856,7 @@ func TestRedhatEUSMatches_FixedOnEUS(t *testing.T) { Run(func(t *testing.T, db *dbtest.DB) { matcher := Matcher{} p := dbtest.NewPackage("kernel", "0:5.14.0-427.68.1.el9_4", syftPkg.RpmPkg). + WithArchitecture("x86_64"). WithDistro(newEUSDistro("9.4")). WithMetadata(pkg.RpmMetadata{Epoch: intPtr(0)}). Build() @@ -874,6 +877,7 @@ func TestRedhatEUSMatches_BetweenEUSAndMainFix(t *testing.T) { matcher := Matcher{} // 450 > EUS fix 427.68 but < main fix 503.11 → resolved per EUS p := dbtest.NewPackage("kernel", "0:5.14.0-450.el9_4", syftPkg.RpmPkg). + WithArchitecture("x86_64"). WithDistro(newEUSDistro("9.4")). WithMetadata(pkg.RpmMetadata{Epoch: intPtr(0)}). Build() @@ -892,6 +896,7 @@ func TestRedhatEUSMatches_MultipleCVEsAllVulnerable(t *testing.T) { dbtest.DBs(t, "rhel9-eus").Run(func(t *testing.T, db *dbtest.DB) { matcher := Matcher{} p := dbtest.NewPackage("kernel", "0:5.14.0-300.el9_4", syftPkg.RpmPkg). + WithArchitecture("aarch64"). WithDistro(newEUSDistro("9.4")). WithMetadata(pkg.RpmMetadata{Epoch: intPtr(0)}). Build() @@ -918,6 +923,7 @@ func TestRedhatEUSMatches_MultipleCVEsAllIgnored(t *testing.T) { matcher := Matcher{} pkgID := pkg.ID("kernel-past-fixes") p := dbtest.NewPackage("kernel", "0:5.14.0-500.el9_4", syftPkg.RpmPkg). + WithArchitecture("x86_64"). WithID(pkgID). WithDistro(newEUSDistro("9.4")). WithMetadata(pkg.RpmMetadata{Epoch: intPtr(0)}). @@ -942,6 +948,7 @@ func TestRedhatEUSIgnoreFilters_FixedProducesIgnore(t *testing.T) { matcher := Matcher{} pkgID := pkg.ID("kernel-eus-fixed") p := dbtest.NewPackage("kernel", "0:5.14.0-427.68.1.el9_4", syftPkg.RpmPkg). + WithArchitecture("x86_64"). WithID(pkgID). WithDistro(newEUSDistro("9.4")). WithMetadata(pkg.RpmMetadata{Epoch: intPtr(0)}). @@ -962,6 +969,7 @@ func TestRedhatEUSIgnoreFilters_VulnerablePackageNoIgnores(t *testing.T) { Run(func(t *testing.T, db *dbtest.DB) { matcher := Matcher{} p := dbtest.NewPackage("kernel", "0:5.14.0-200.el9_4", syftPkg.RpmPkg). + WithArchitecture("aarch64"). WithDistro(newEUSDistro("9.4")). WithMetadata(pkg.RpmMetadata{Epoch: intPtr(0)}). Build() diff --git a/grype/matcher/rpm/rhel_multi_rhsa_test.go b/grype/matcher/rpm/rhel_multi_rhsa_test.go new file mode 100644 index 00000000000..c12cd2cd0df --- /dev/null +++ b/grype/matcher/rpm/rhel_multi_rhsa_test.go @@ -0,0 +1,140 @@ +package rpm + +import ( + "testing" + + "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/grype/internal/dbtest" + "github.com/anchore/syft/syft/artifact" + syftPkg "github.com/anchore/syft/syft/pkg" +) + +// The fixture (testdata/rhel-multi-rhsa) holds the verbatim record vunnel's RHEL +// provider emits for CVE-2024-8088 after the multi-RHSA phase-1 change: two RHSAs +// fixing python3.9 at distinct upstream bases (the 9.4 Z-stream backport +// 3.9.18-3.el9_4.5 and the 9.5 GA rebase 3.9.19-8.el9) are reduced to a single record +// whose VulnerableRange partitions the two streams: +// +// < 0:3.9.18-3.el9_4.5 || >= 0:3.9.19, < 0:3.9.19-8.el9 +// +// dbtest runs the real grype-db transform over that record, so this validates the +// whole vunnel -> grype-db -> grype matching path, not a hand-built v6 constraint. +// +// See https://access.redhat.com/security/cve/cve-2024-8088#cve-affected-packages + +// rhelPython39Host builds the package shape the matcher receives for a RHEL host: +// the python3 binary RPM plus an upstream pointer to its python3.9 source RPM, so the +// matcher's source-indirection path engages (the RHEL data is keyed on the source RPM). +func rhelPython39Host(d *distro.Distro, version string, id pkg.ID) pkg.Package { + return dbtest.NewPackage("python3", version, syftPkg.RpmPkg). + WithArchitecture("x86_64"). + WithID(id). + WithDistro(d). + WithUpstream("python3.9", version). + WithMetadata(pkg.RpmMetadata{Epoch: intPtr(0)}). + Build() +} + +func TestRpmMultiRHSA_CVE2024_8088_PartitionedStreams(t *testing.T) { + dbtest.DBs(t, "rhel-multi-rhsa"). + SelectOnly("CVE-2024-8088"). + Run(func(t *testing.T, db *dbtest.DB) { + rhel94 := distro.New(distro.RedHat, "9.4", "") + + cases := []struct { + name string + version string + vulnerable bool + why string + }{ + { + name: "at lower-stream fix (RHSA-2024:6163)", + version: "3.9.18-3.el9_4.5", + vulnerable: false, + why: "host carries the 9.4 Z-stream backport; < 3.9.19 so it falls into no clause", + }, + { + name: "below lower-stream fix", + version: "3.9.18-3.el9_4.4", + vulnerable: true, + why: "one Z-stream patch behind the 9.4 fix - genuinely vulnerable", + }, + { + name: "above lower-stream fix, still in 3.9.18 line", + version: "3.9.18-10.el9_4", + vulnerable: false, + why: "above the 9.4 fix, still < 3.9.19 - not vulnerable", + }, + { + name: "in 3.9.19 stream below upper-stream fix", + version: "3.9.19-1.el9", + vulnerable: true, + why: ">= 3.9.19 and < 3.9.19-8.el9 - pre-RHSA-2024:9371 build is vulnerable", + }, + { + name: "at upper-stream fix (RHSA-2024:9371)", + version: "3.9.19-8.el9", + vulnerable: false, + why: "host carries the 9.5 GA fix - not vulnerable", + }, + } + + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + matcher := Matcher{} + pkgID := pkg.ID("python3-" + c.version) + p := rhelPython39Host(rhel94, c.version, pkgID) + if c.vulnerable { + db.Match(t, &matcher, p). + SelectMatch("CVE-2024-8088"). + SelectDetailByType(match.ExactIndirectMatch). + AsDistroSearch() + } else { + // not vulnerable: the owned python3 binary yields a "Distro Not + // Vulnerable" ownership ignore rather than a match. + db.Match(t, &matcher, p). + Ignores(). + SelectRelatedPackageIgnore(IgnoreReasonDistroNotVulnerable, "CVE-2024-8088"). + ForPackage(pkgID). + WithRelationshipType(artifact.OwnershipByFileOverlapRelationship) + } + }) + } + }) +} + +// The fix metadata and folded advisories should survive the transform onto the match. +func TestRpmMultiRHSA_CVE2024_8088_FixAndAdvisories(t *testing.T) { + dbtest.DBs(t, "rhel-multi-rhsa"). + SelectOnly("CVE-2024-8088"). + Run(func(t *testing.T, db *dbtest.DB) { + matcher := Matcher{} + p := rhelPython39Host(distro.New(distro.RedHat, "9.4", ""), "3.9.18-3.el9_4.4", pkg.ID("python3")) + + sf := db.Match(t, &matcher, p).SelectMatch("CVE-2024-8088") + // canonical fix is the newest stream's build; both RHSAs are surfaced. + sf.HasFix(vulnerability.FixStateFixed, "0:3.9.19-8.el9"). + HasAdvisories("RHSA-2024:9371", "RHSA-2024:6163") + sf.SelectDetailByType(match.ExactIndirectMatch).AsDistroSearch() + }) +} + +// A host on an older base than either fix (e.g. a 9.2 build that never received a +// targeted backport for this CVE) is below the lowest stream's fix and must stay +// flagged - phase 1 must not over-suppress. +func TestRpmMultiRHSA_CVE2024_8088_OlderBaseStillFlagged(t *testing.T) { + dbtest.DBs(t, "rhel-multi-rhsa"). + SelectOnly("CVE-2024-8088"). + Run(func(t *testing.T, db *dbtest.DB) { + matcher := Matcher{} + p := rhelPython39Host(distro.New(distro.RedHat, "9.2", ""), "3.9.16-1.el9_2.5", pkg.ID("python3")) + + db.Match(t, &matcher, p). + SelectMatch("CVE-2024-8088"). + SelectDetailByType(match.ExactIndirectMatch). + AsDistroSearch() + }) +} diff --git a/grype/matcher/rpm/sles_test.go b/grype/matcher/rpm/sles_test.go index 372bd3e0f4e..999c2bb2bec 100644 --- a/grype/matcher/rpm/sles_test.go +++ b/grype/matcher/rpm/sles_test.go @@ -23,6 +23,7 @@ func TestMatcherRpm_SLES_UnaffectedRecordProducesIgnore(t *testing.T) { pkgID := pkg.ID("python311-Werkzeug-unaffected") // the user's reported version from issue #2566 p := dbtest.NewPackage("python311-Werkzeug", "0:2.3.6-150400.6.12.1", syftPkg.RpmPkg). + WithArchitecture("x86_64"). WithID(pkgID). WithDistro(dbtest.SLES156). Build() @@ -44,6 +45,7 @@ func TestMatcherRpm_SLES_VulnerableAndUnaffectedInSameCall(t *testing.T) { pkgID := pkg.ID("python311-Werkzeug-mixed") // 2.3.0 is below the SUSE fix at 2.3.6 → vulnerable for CVE-2023-25577 p := dbtest.NewPackage("python311-Werkzeug", "0:2.3.0-150400.6.1.1", syftPkg.RpmPkg). + WithArchitecture("aarch64"). WithID(pkgID). WithDistro(dbtest.SLES156). Build() @@ -71,6 +73,7 @@ func TestMatcherRpm_SLES_RecordsDoNotCrossMinorVersion(t *testing.T) { // on 15.7; with the sles:15.7 fixture entry present, the OS-row // lookup hits 15.7 directly and neither 15.6 record leaks. p := dbtest.NewPackage("python311-Werkzeug", "0:2.3.0-150700.6.1.1", syftPkg.RpmPkg). + WithArchitecture("x86_64"). WithDistro(distro.New(distro.SLES, "15.7", "")). Build() @@ -87,6 +90,7 @@ func TestMatcherRpm_SLES_FixturePresenceOfMinorVersionRow(t *testing.T) { Run(func(t *testing.T, db *dbtest.DB) { matcher := Matcher{} p := dbtest.NewPackage("glibc", "0:2.36-150600.10.1.1", syftPkg.RpmPkg). + WithArchitecture("x86_64"). WithDistro(dbtest.SLES157). Build() @@ -108,6 +112,7 @@ func TestMatcherRpm_SLES_GANamespaceAppliesToGAScan(t *testing.T) { pkgID := pkg.ID("python3-werkzeug-on-sles-15-ga") // SLES 15 GA host: VERSION_ID="15" -> distro major=15, minor="" p := dbtest.NewPackage("python3-Werkzeug", "0:0.16.1-150100.4.6.1", syftPkg.RpmPkg). + WithArchitecture("aarch64"). WithID(pkgID). WithDistro(distro.New(distro.SLES, "15", "")). Build() @@ -129,6 +134,7 @@ func TestMatcherRpm_SLES_GANamespaceLeaksOntoUnknownMinor(t *testing.T) { matcher := Matcher{} pkgID := pkg.ID("python3-werkzeug-on-unknown-sles-minor") p := dbtest.NewPackage("python3-Werkzeug", "0:0.16.1-150100.4.6.1", syftPkg.RpmPkg). + WithArchitecture("x86_64"). WithID(pkgID). WithDistro(distro.New(distro.SLES, "15.99", "")). Build() diff --git a/grype/matcher/rpm/testdata/oracle/db.yaml b/grype/matcher/rpm/testdata/oracle/db.yaml new file mode 100644 index 00000000000..1b255eca594 --- /dev/null +++ b/grype/matcher/rpm/testdata/oracle/db.yaml @@ -0,0 +1,4 @@ +auto-generate: true +extractions: + oracle: + - ol:7/elsa-2022-4803 diff --git a/grype/matcher/rpm/testdata/oracle/oracle/metadata.json b/grype/matcher/rpm/testdata/oracle/oracle/metadata.json new file mode 100644 index 00000000000..b1467015416 --- /dev/null +++ b/grype/matcher/rpm/testdata/oracle/oracle/metadata.json @@ -0,0 +1,19 @@ +{ + "provider": "oracle", + "version": 1, + "processor": "vunnel@0.56.0", + "schema": { + "version": "1.0.3", + "url": "https://raw.githubusercontent.com/anchore/vunnel/main/schema/provider-workspace-state/schema-1.0.3.json" + }, + "urls": [ + "https://linux.oracle.com/security/oval/com.oracle.elsa-all.xml.bz2" + ], + "timestamp": "2026-06-16T00:00:00Z", + "listing": { + "path": "results/listing.xxh64", + "digest": "31d6699c3a6503b2", + "algorithm": "xxh64" + }, + "store": "flat-file" +} diff --git a/grype/matcher/rpm/testdata/oracle/oracle/results/ol@7/elsa-2022-4803.json b/grype/matcher/rpm/testdata/oracle/oracle/results/ol@7/elsa-2022-4803.json new file mode 100644 index 00000000000..9809834f3ec --- /dev/null +++ b/grype/matcher/rpm/testdata/oracle/oracle/results/ol@7/elsa-2022-4803.json @@ -0,0 +1,58 @@ +{ + "identifier": "ol:7/elsa-2022-4803", + "item": { + "Vulnerability": { + "CVSS": [], + "Description": "A flaw was found in rsyslog: a heap-based overflow in the TCP syslog server (CVE-2022-24903).", + "FixedIn": [ + { + "Name": "rsyslog", + "NamespaceName": "ol:7", + "Module": null, + "Version": "0:8.24.0-57.0.1.el7_9.3", + "VersionFormat": "rpm", + "Arch": "x86_64" + }, + { + "Name": "rsyslog", + "NamespaceName": "ol:7", + "Module": null, + "Version": "0:8.24.0-57.0.4.el7_9.3", + "VersionFormat": "rpm", + "Arch": "aarch64" + }, + { + "Name": "rsyslog-doc", + "NamespaceName": "ol:7", + "Module": null, + "Version": "0:8.24.0-57.0.1.el7_9.3", + "VersionFormat": "rpm", + "Arch": "x86_64" + }, + { + "Name": "rsyslog-doc", + "NamespaceName": "ol:7", + "Module": null, + "Version": "0:8.24.0-57.0.4.el7_9.3", + "VersionFormat": "rpm", + "Arch": "aarch64" + } + ], + "Link": "https://linux.oracle.com/errata/ELSA-2022-4803.html", + "Metadata": { + "Issued": "2022-05-31", + "RefId": "ELSA-2022-4803", + "CVE": [ + { + "Name": "CVE-2022-24903", + "Link": "https://linux.oracle.com/cve/CVE-2022-24903.html" + } + ] + }, + "Name": "ELSA-2022-4803", + "NamespaceName": "ol:7", + "Severity": "Important" + } + }, + "schema": "https://raw.githubusercontent.com/anchore/vunnel/main/schema/vulnerability/os/schema-1.1.1.json" +} diff --git a/grype/matcher/rpm/testdata/rhel-multi-rhsa/db.yaml b/grype/matcher/rpm/testdata/rhel-multi-rhsa/db.yaml new file mode 100644 index 00000000000..6945681e1c2 --- /dev/null +++ b/grype/matcher/rpm/testdata/rhel-multi-rhsa/db.yaml @@ -0,0 +1,7 @@ +# Hand-authored fixture (auto-generate disabled). The single rhel:9 record is the +# verbatim OS-schema output of vunnel's RHEL provider for CVE-2024-8088 after the +# multi-RHSA phase-1 change (distinct upstream bases -> partitioned VulnerableRange). +auto-generate: false +extractions: + rhel: + - rhel:9/cve-2024-8088 diff --git a/grype/matcher/rpm/testdata/rhel-multi-rhsa/rhel/metadata.json b/grype/matcher/rpm/testdata/rhel-multi-rhsa/rhel/metadata.json new file mode 100644 index 00000000000..5b90a9a716c --- /dev/null +++ b/grype/matcher/rpm/testdata/rhel-multi-rhsa/rhel/metadata.json @@ -0,0 +1,18 @@ +{ + "provider": "rhel", + "version": 1, + "processor": "vunnel@multi-rhsa-phase-1", + "schema": { + "version": "1.0.3", + "url": "https://raw.githubusercontent.com/anchore/vunnel/main/schema/provider-workspace-state/schema-1.0.3.json" + }, + "urls": [ + "https://access.redhat.com/hydra/rest/securitydata/cve.json" + ], + "timestamp": "2026-06-01T00:00:00Z", + "listing": { + "path": "results/listing.xxh64", + "algorithm": "xxh64" + }, + "store": "flat-file" +} diff --git a/grype/matcher/rpm/testdata/rhel-multi-rhsa/rhel/results/rhel@9/cve-2024-8088.json b/grype/matcher/rpm/testdata/rhel-multi-rhsa/rhel/results/rhel@9/cve-2024-8088.json new file mode 100644 index 00000000000..45ca0cada68 --- /dev/null +++ b/grype/matcher/rpm/testdata/rhel-multi-rhsa/rhel/results/rhel@9/cve-2024-8088.json @@ -0,0 +1,38 @@ +{ + "identifier": "rhel:9/cve-2024-8088", + "item": { + "Vulnerability": { + "CVSS": [], + "Description": "A flaw was found in Python's zipfile module. When iterating over the entries of a zip archive, the process can enter into an infinite loop state and become unresponsive. This flaw allows an attacker to craft a malicious ZIP archive, leading to a denial of service from the application consuming the zip archive.", + "FixedIn": [ + { + "Module": null, + "Name": "python3.9", + "NamespaceName": "rhel:9", + "VendorAdvisory": { + "AdvisorySummary": [ + { + "ID": "RHSA-2024:9371", + "Link": "https://access.redhat.com/errata/RHSA-2024:9371" + }, + { + "ID": "RHSA-2024:6163", + "Link": "https://access.redhat.com/errata/RHSA-2024:6163" + } + ], + "NoAdvisory": false + }, + "Version": "0:3.9.19-8.el9", + "VersionFormat": "rpm", + "VulnerableRange": "< 0:3.9.18-3.el9_4.5 || >= 0:3.9.19, < 0:3.9.19-8.el9" + } + ], + "Link": "https://access.redhat.com/security/cve/CVE-2024-8088", + "Metadata": {}, + "Name": "CVE-2024-8088", + "NamespaceName": "rhel:9", + "Severity": "Medium" + } + }, + "schema": "https://raw.githubusercontent.com/anchore/vunnel/main/schema/vulnerability/os/schema-1.1.0.json" +} diff --git a/grype/pkg/package.go b/grype/pkg/package.go index 65d333b4ef8..303c73d4f9f 100644 --- a/grype/pkg/package.go +++ b/grype/pkg/package.go @@ -4,6 +4,7 @@ import ( "fmt" "regexp" "slices" + "sort" "strings" "github.com/anchore/grype/grype/distro" @@ -55,6 +56,10 @@ type Package struct { Upstreams []UpstreamPackage Metadata any // This is NOT 1-for-1 the syft metadata! Only the select data needed for vulnerability matching + // Annotations carries provider-supplied per-package metadata; values are slices to + // retain multiple sources when the same package is contributed by more than one origin. + Annotations map[string][]string + // Related packages may be used for scanning RelatedPackages map[artifact.RelationshipType][]*Package } @@ -196,6 +201,20 @@ func FromPackages(syftPkgs []syftPkg.Package, relationships []artifact.Relations return pkgs } +// AddAnnotation appends value under key, keeping the slice sorted and de-duplicated. +func (p *Package) AddAnnotation(key, value string) { + if p.Annotations == nil { + p.Annotations = map[string][]string{} + } + existing := p.Annotations[key] + if slices.Contains(existing, value) { + return + } + existing = append(existing, value) + sort.Strings(existing) + p.Annotations[key] = existing +} + func (p Package) String() string { var d string if p.Distro != nil { @@ -360,7 +379,11 @@ func javaVMDataFromPkg(p syftPkg.Package) any { func apkMetadataFromPkg(p syftPkg.Package) any { if m, ok := p.Metadata.(syftPkg.ApkDBEntry); ok { - metadata := ApkMetadata{} + // Grype's APK Metadata only has files, so return early + // and leave the field blank if there are no files. + if len(m.Files) == 0 { + return nil + } fileRecords := make([]ApkFileRecord, 0, len(m.Files)) for _, record := range m.Files { @@ -368,9 +391,7 @@ func apkMetadataFromPkg(p syftPkg.Package) any { fileRecords = append(fileRecords, r) } - metadata.Files = fileRecords - - return metadata + return ApkMetadata{Files: fileRecords} } return nil @@ -434,18 +455,24 @@ func rpmDataFromPkg(p syftPkg.Package) (metadata *RpmMetadata, upstreams []Upstr upstreams = handleSourceRPM(p.Name, m.SourceRpm) } - metadata = &RpmMetadata{ - Epoch: m.Epoch, - ModularityLabel: m.ModularityLabel, + if m.Epoch != nil || m.ModularityLabel != nil || m.Arch != "" { + metadata = &RpmMetadata{ + Epoch: m.Epoch, + ModularityLabel: m.ModularityLabel, + Arch: m.Arch, + } } case syftPkg.RpmArchive: if m.SourceRpm != "" { upstreams = handleSourceRPM(p.Name, m.SourceRpm) } - metadata = &RpmMetadata{ - Epoch: m.Epoch, - ModularityLabel: m.ModularityLabel, + if m.Epoch != nil || m.ModularityLabel != nil || m.Arch != "" { + metadata = &RpmMetadata{ + Epoch: m.Epoch, + ModularityLabel: m.ModularityLabel, + Arch: m.Arch, + } } } return metadata, upstreams diff --git a/grype/pkg/package_test.go b/grype/pkg/package_test.go index 032a6b1620e..fe83c9d02eb 100644 --- a/grype/pkg/package_test.go +++ b/grype/pkg/package_test.go @@ -130,6 +130,7 @@ func TestNew(t *testing.T) { }, metadata: RpmMetadata{ Epoch: intRef(30), + Arch: "arch-info", }, upstreams: []UpstreamPackage{ { @@ -168,6 +169,7 @@ func TestNew(t *testing.T) { }, metadata: RpmMetadata{ Epoch: intRef(30), + Arch: "arch-info", }, upstreams: []UpstreamPackage{ { @@ -184,7 +186,8 @@ func TestNew(t *testing.T) { SourceRpm: "sqlite-3.26.0-6.el8.src.rpm", }, }, - metadata: RpmMetadata{}, + // source matches the package name (so no upstream) and neither epoch nor + // modularity is set, so grype keeps nothing: metadata is nil, not an empty struct. }, { name: "rpm archive with modularity label", @@ -265,9 +268,28 @@ func TestNew(t *testing.T) { Name: "libcurl", }, }, - metadata: ApkMetadata{Files: []ApkFileRecord{}}, + // no file records and grype's apk model carries nothing else, so metadata + // is nil rather than an empty struct (the upstream is still parsed out). }, // the below packages are those that have no metadata or upstream info to parse out + { + name: "bun-lock-entry", + syftPkg: syftPkg.Package{ + Metadata: syftPkg.BunLockEntry{}, + }, + }, + { + name: "deno-lock-entry", + syftPkg: syftPkg.Package{ + Metadata: syftPkg.DenoLockEntry{}, + }, + }, + { + name: "deno-remote-lock-entry", + syftPkg: syftPkg.Package{ + Metadata: syftPkg.DenoRemoteLockEntry{}, + }, + }, { name: "npm-metadata", syftPkg: syftPkg.Package{ diff --git a/grype/pkg/provider.go b/grype/pkg/provider.go index a37d17ba89e..84824aedfb8 100644 --- a/grype/pkg/provider.go +++ b/grype/pkg/provider.go @@ -227,6 +227,19 @@ func provide(userInput string, config ProviderConfig, applyChannel func(d *distr return packages, ctx, s, err } + packages, ctx, s, err = zarfProvider(userInput, config, applyChannel) + 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 a Zarf package") + return packages, ctx, s, err + } + log.WithFields("input", userInput).Trace("passing input to syft for interpretation") return syftProvider(userInput, config, applyChannel) } diff --git a/grype/pkg/purl_provider_test.go b/grype/pkg/purl_provider_test.go index ea82d0ee57d..880d571617a 100644 --- a/grype/pkg/purl_provider_test.go +++ b/grype/pkg/purl_provider_test.go @@ -173,6 +173,9 @@ func Test_PurlProvider(t *testing.T) { Version: "239-82.el8_10.2", }, }, + Metadata: RpmMetadata{ + Arch: "aarch64", + }, }, }, }, @@ -194,6 +197,9 @@ func Test_PurlProvider(t *testing.T) { 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{"redhat"}}, + Metadata: RpmMetadata{ + Arch: "noarch", + }, Upstreams: []UpstreamPackage{ { Name: "dbus", @@ -223,6 +229,7 @@ func Test_PurlProvider(t *testing.T) { Distro: &distro.Distro{Type: distro.RedHat, Version: "8.7", Codename: "", IDLike: []string{"redhat"}}, Metadata: RpmMetadata{ ModularityLabel: strRef("httpd:2.4"), + Arch: "x86_64", }, }, }, diff --git a/grype/pkg/qualifier/architecture/qualifier.go b/grype/pkg/qualifier/architecture/qualifier.go index 940c5189929..7a5636b6979 100644 --- a/grype/pkg/qualifier/architecture/qualifier.go +++ b/grype/pkg/qualifier/architecture/qualifier.go @@ -18,26 +18,185 @@ import ( const ( ArchSource = "src" ArchBinaryNoArchSpecified = "binary-no-arch-specified" + + // archNoarch (rpm) and archAll (deb) are the dialect spellings for an arch-independent + // package — one with no architecture-specific content, installed identically on every + // architecture. These are real values seen in data, not sentinels we emit. The qualifier + // treats them as inert (see Satisfied) so an arch-independent artifact is never filtered. + archNoarch = "noarch" + archAll = "all" ) +// architectureQualifier gates a package against an AFFECTED vulnerability entry's stored arch: a +// satisfied entry keeps the match. It is lenient where the arch can't be decided so it never drops +// a real vulnerability (the conservative direction for an affected record). The unaffected +// (negation) counterpart is unaffectedArchitectureQualifier; both share concreteMatch. type architectureQualifier struct { - arch string + arch string + aliases map[string]string +} + +// New builds the architecture qualifier for an AFFECTED vulnerability entry's stored arch. aliases +// is the arch alias table read from the database (alias spelling -> canonical token); pass nil/empty +// to fall back to the built-in DefaultAliases (e.g. for databases built before the +// architecture_aliases table existed). See canonicalArch. For unaffected entries use NewUnaffected. +func New(arch string, aliases map[string]string) qualifier.Qualifier { + return &architectureQualifier{arch: arch, aliases: aliases} +} + +// Satisfied reports whether package p falls under what this AFFECTED vulnerability entry describes, +// based on the entry's stored arch: +// +// - a concrete arch ("x86_64", "aarch64"): matches only packages of that arch. This lets +// a provider (e.g. Chainguard) scope an advisory or its fix to a single architecture. +// - ArchSource ("src"): a source-level record. It applies to the package by name +// regardless of the scanned arch — a binary inherits its source RPM's vulnerability — +// so it matches both source packages and same-name binaries. (Binaries whose source has +// a different name reach it via the rpm matcher's upstream search, whose synthesized +// package is tagged "src".) +// - ArchBinaryNoArchSpecified: a binary disclosure with no specific arch — matches any +// binary package (any non-src arch), but NOT a source package. That exclusion is what +// keeps a binary record from matching through the synthesized "src" upstream package, +// so a sibling binary built from the same source isn't falsely matched. +// +// A package with no recorded arch is inert (older DBs / inputs without arch, or a package +// type that doesn't carry one), as is an entry with no arch (treated as "any"); neither +// direction filters when we cannot decide. An arch-independent package (rpm "noarch", deb +// "all") is likewise inert: it has no architecture-specific content and is installed on every +// architecture, so an arch-scoped entry still applies to it and must not drop it. +// +// Concrete arches are compared in canonical form (see canonicalArch) so a package and an +// entry that name the same architecture in different ecosystem dialects still match — e.g. +// an "amd64" deb package against an "x86_64" RPM-dialect advisory. +func (r architectureQualifier) Satisfied(p pkg.Package) (bool, error) { + pkgArch := packageArch(p) + if pkgArch == "" || r.arch == "" { + return true, nil + } + // An arch-independent package (or entry) carries no architecture to compare against, so it + // never filters — folding it onto a concrete token and equality-comparing would wrongly drop + // a real match (e.g. a "noarch" package against an "x86_64" advisory). + if isArchIndependent(pkgArch) || isArchIndependent(r.arch) { + return true, nil + } + return concreteMatch(pkgArch, r.arch, r.aliases), nil +} + +// unaffectedArchitectureQualifier gates a package against an UNAFFECTED (negation) vulnerability +// entry's stored arch. A satisfied unaffected entry SUPPRESSES a match, so its polarity is the +// reverse of the affected qualifier: it is evaluated conservatively and never suppresses on an arch +// it cannot positively confirm corresponds — declining to suppress is the safe direction here, the +// same way the affected qualifier declines to drop. See NewUnaffected. +type unaffectedArchitectureQualifier struct { + arch string + aliases map[string]string +} + +// NewUnaffected builds the architecture qualifier for an UNAFFECTED vulnerability entry. See New for +// the aliases argument and architectureQualifier for the affected counterpart. +func NewUnaffected(arch string, aliases map[string]string) qualifier.Qualifier { + return &unaffectedArchitectureQualifier{arch: arch, aliases: aliases} } -func New(arch string) qualifier.Qualifier { - return &architectureQualifier{arch: arch} +// Satisfied reports whether this UNAFFECTED entry covers package p — i.e. whether it may suppress a +// match. It diverges from the affected qualifier only on the cases the affected side resolves +// leniently, because here "lenient" would mean suppressing on an unconfirmed arch: +// +// - an entry with no arch is a blanket "not affected" and still applies to every package; +// - a package with no readable arch is NOT suppressed (we couldn't confirm the arch); +// - an arch-independent package/entry ("noarch"/"all") takes no shortcut — it suppresses only on +// an exact token match (e.g. "noarch" vs "noarch"), never folding a concrete arch onto it. +// +// The definite cases (src, binary-no-arch, concrete vs concrete) are identical to the affected +// qualifier; see concreteMatch. +func (r unaffectedArchitectureQualifier) Satisfied(p pkg.Package) (bool, error) { + if r.arch == "" { + return true, nil + } + pkgArch := packageArch(p) + if pkgArch == "" { + return false, nil + } + return concreteMatch(pkgArch, r.arch, r.aliases), nil +} + +// concreteMatch is the polarity-independent core shared by the affected and unaffected qualifiers: +// the cases where the arch comparison is definite. Both agree here; they differ only in how they +// resolve the uncertain cases above (see each Satisfied). +func concreteMatch(pkgArch, recordArch string, aliases map[string]string) bool { + switch recordArch { + case ArchSource: + return true + case ArchBinaryNoArchSpecified: + return pkgArch != ArchSource + default: + return canonicalArch(pkgArch, aliases) == canonicalArch(recordArch, aliases) + } +} + +// isArchIndependent reports whether an architecture value describes an arch-independent package +// (rpm "noarch", deb "all") rather than a concrete CPU architecture. See archNoarch / archAll. +func isArchIndependent(a string) bool { + return a == archNoarch || a == archAll +} + +// packageArch reads the architecture off the package's metadata contract. Only RPM metadata +// carries an arch today; any other (or absent) metadata reports "", which Satisfied treats as +// inert. The architecture qualifier is only emitted on rpm/CSAF entries, so it is only ever +// evaluated against rpm packages in practice. +func packageArch(p pkg.Package) string { + if m, ok := p.Metadata.(pkg.RpmMetadata); ok { + return m.Arch + } + return "" +} + +// canonicalArch folds an architecture string onto one token per CPU architecture so matching +// doesn't drop vulnerabilities across ecosystem dialects. The same architecture is spelled +// differently by different tooling: RPM/GNU/Alpine say "x86_64"/"aarch64", while Debian/Go/OCI +// say "amd64"/"arm64" (and Windows "x64"). Since a package carries its package manager's +// spelling and an advisory carries its source's spelling, an exact string compare would +// wrongly drop a real match when those dialects differ. +// +// The alias table is data-driven: it comes from the database (the architecture_aliases table), +// so the folding can be updated without shipping a new grype binary. An empty table — which is +// what a database built before that table existed reports — falls back to the built-in +// defaultAliases so historical databases keep cross-dialect matching. +// +// Only genuine CPU architectures reach this function. The role markers ("src", binary-no-arch) +// and arch-independent values (rpm "noarch", deb "all") are handled earlier in Satisfied and +// never get here. A spelling not in the table is its own canonical form. +func canonicalArch(a string, aliases map[string]string) string { + if len(aliases) == 0 { + aliases = defaultAliases + } + if canonical, ok := aliases[a]; ok { + return canonical + } + return a } -// Arch returns the stored architecture value (e.g. "src", "x86_64", -// "binary-no-arch-specified", or "" if unset). -func (r architectureQualifier) Arch() string { - return r.arch +// defaultAliases maps each non-canonical architecture spelling to its canonical token. The +// canonical token is arbitrary (we use the Go/OCI spelling); only equivalence matters, so a +// canonical spelling (e.g. "amd64") is intentionally absent and resolves to itself. +var defaultAliases = map[string]string{ + "x86_64": "amd64", + "x64": "amd64", + "aarch64": "arm64", + "i386": "386", + "i686": "386", + "x86": "386", + "ppc64el": "ppc64le", } -// Satisfied is intentionally inert: the architecture qualifier is read by criteria that -// operate on the vulnerability side (see internal.SourceOrUnspecifiedArch), not by -// per-package qualifier evaluation. Direct hits on a binary-tagged entry must still match -// by name, so this always returns true. -func (r architectureQualifier) Satisfied(_ pkg.Package) (bool, error) { - return true, nil +// DefaultAliases returns a copy of the built-in architecture alias table (alias spelling -> +// canonical token). It is the single source of truth for both seeding the database's +// architecture_aliases table at build time and the match-time fallback for databases that +// predate that table. +func DefaultAliases() map[string]string { + out := make(map[string]string, len(defaultAliases)) + for alias, canonical := range defaultAliases { + out[alias] = canonical + } + return out } diff --git a/grype/pkg/qualifier/architecture/qualifier_test.go b/grype/pkg/qualifier/architecture/qualifier_test.go index e37baa41ef3..9556b7f9a14 100644 --- a/grype/pkg/qualifier/architecture/qualifier_test.go +++ b/grype/pkg/qualifier/architecture/qualifier_test.go @@ -8,28 +8,352 @@ import ( "github.com/anchore/grype/grype/pkg" ) -func TestArchitecture_ArchAndInertSatisfied(t *testing.T) { +// TestArchitecture_Satisfied pins down the per-package gating logic for the AFFECTED qualifier: +// match the package's arch against the arch the entry affects. Concrete arches compare exactly; +// the src entry matches only source packages; binary-no-arch matches any binary (any non-src) +// package; and an unset or arch-independent (rpm "noarch", deb "all") value on either side is +// inert (cannot/should not decide, so don't filter). +// +// A satisfied AFFECTED entry QUALIFIES (keeps) the match, so want==true means "qualifies". +// Leniency fails toward the vulnerability (a false positive) rather than dropping a real match. +// The cases below enumerate each distinct (package arch, entry arch) combination, then add +// supplementary cross-dialect spellings and the src/src and binary/src pairings. +func TestArchitecture_Satisfied(t *testing.T) { tests := []struct { - name string - arch string + name string + qualifierArch string // arch stored on the vulnerability entry + pkgArch string // arch read off the scanned package + want bool // does the affected entry qualify (keep) the package? }{ - {name: "source", arch: ArchSource}, - {name: "binary-no-arch-specified", arch: ArchBinaryNoArchSpecified}, - {name: "literal architecture", arch: "x86_64"}, - {name: "empty (provider does not distinguish)", arch: ""}, + { + // a binary record excludes the source package — a binary vuln must not + // match a package that isn't a binary. + name: "src package / binary-no-arch entry -> does not qualify", + qualifierArch: ArchBinaryNoArchSpecified, + pkgArch: ArchSource, + want: false, + }, + { + // a binary-no-arch vuln matches any binary package, whatever its arch. + name: "aarch64 binary / binary-no-arch entry -> qualifies", + qualifierArch: ArchBinaryNoArchSpecified, + pkgArch: "aarch64", + want: true, + }, + { + // an arch-independent package has no arch-specific content and is installed + // on every arch, so an arch-scoped entry still applies and must not drop it. + name: "noarch package / concrete (x86_64) entry -> qualifies", + qualifierArch: "x86_64", + pkgArch: archNoarch, + want: true, + }, + { + // "all" is debian's arch-independent marker; same reasoning as noarch. + name: "deb 'all' package / concrete (amd64) entry -> qualifies", + qualifierArch: "amd64", + pkgArch: archAll, + want: true, + }, + { + // a binary package matches a src entry — the binary is built from the + // vulnerable source and inherits its vulnerability. + name: "binary package / src entry -> qualifies (built from vulnerable src)", + qualifierArch: ArchSource, + pkgArch: "x86_64", + want: true, + }, + { + // matching concrete arches qualify. + name: "aarch64 package / aarch64 entry -> qualifies (arches match)", + qualifierArch: "aarch64", + pkgArch: "aarch64", + want: true, + }, + { + // mismatched concrete arches do not qualify. + name: "x86_64 package / aarch64 entry -> does not qualify (arches differ)", + qualifierArch: "aarch64", + pkgArch: "x86_64", + want: false, + }, + { + // unknown package arch — assume the vuln may apply (fail toward the + // vulnerable claim). Older DBs / non-PURL providers surface no arch. + name: "empty package arch / concrete entry -> qualifies (unknown, assume applies)", + qualifierArch: "x86_64", + pkgArch: "", + want: true, + }, + { + // an entry with no arch is not scoped to any architecture, so any package + // matches. Transformers should emit nil, but "" stays permissive too. + name: "concrete package / empty entry -> qualifies (vuln not arch-scoped)", + qualifierArch: "", + pkgArch: "x86_64", + want: true, + }, + { + // a concrete package vs an arch-independent entry qualifies — a binary rpm + // can be affected by a vuln that isn't in architecture-specific code. + name: "aarch64 package / noarch entry -> qualifies", + qualifierArch: archNoarch, + pkgArch: "aarch64", + want: true, + }, + + // supplementary: cross-dialect spellings and extra pairings + { + // cross-dialect: a deb/Go/OCI "amd64" package must match an RPM-dialect + // "x86_64" advisory — same architecture, different spelling. + name: "amd64 package matches x86_64 entry (canonicalized)", + qualifierArch: "x86_64", + pkgArch: "amd64", + want: true, + }, + { + name: "x86_64 package matches amd64 entry (canonicalized)", + qualifierArch: "amd64", + pkgArch: "x86_64", + want: true, + }, + { + name: "arm64 package matches aarch64 entry (canonicalized)", + qualifierArch: "aarch64", + pkgArch: "arm64", + want: true, + }, + { + // canonicalization must not collapse genuinely different architectures. + name: "amd64 package does not match arm64 entry", + qualifierArch: "arm64", + pkgArch: "amd64", + want: false, + }, + { + // an arch-independent entry doesn't constrain by arch either (rpm-dialect spelling). + name: "arch-independent entry is inert against a concrete package", + qualifierArch: "noarch", + pkgArch: "x86_64", + want: true, + }, + { + // A src entry matches a source package. Binary packages reach src entries via + // the rpm matcher's upstream search, which tags its synthesized package "src". + name: "src entry matches source package", + qualifierArch: ArchSource, + pkgArch: ArchSource, + want: true, + }, + { + // a src record is source-level, applying by name regardless of arch, so a + // same-name binary matches it. Required for same-name-source binaries, which + // have no synthesized upstream (e.g. CVE-2026-31790 openssl-fips-provider, + // source == binary name). + name: "src entry matches a same-name binary regardless of arch", + qualifierArch: ArchSource, + pkgArch: "x86_64", + want: true, + }, + { + // binary-no-arch matches an x86_64 binary too. + name: "binary-no-arch entry matches an x86_64 binary", + qualifierArch: ArchBinaryNoArchSpecified, + pkgArch: "x86_64", + want: true, + }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - q := New(tt.arch) - - ok, err := q.Satisfied(pkg.Package{Name: "anything"}) + q := New(tt.qualifierArch, nil) + got, err := q.Satisfied(pkg.Package{Metadata: pkg.RpmMetadata{Arch: tt.pkgArch}}) require.NoError(t, err) - require.True(t, ok, "Satisfied must always return true; the architecture value is consumed by SourceOrUnspecifiedArch, not by per-package qualifier evaluation") + require.Equal(t, tt.want, got) + }) + } +} + +// TestUnaffectedArchitecture_Satisfied pins the conservative polarity of NewUnaffected: a satisfied +// unaffected record SUPPRESSES a match, so it must never suppress on an arch it can't positively +// confirm. It mirrors TestArchitecture_Satisfied (the affected qualifier); the two differ only on +// the uncertain cases the affected side resolves leniently, which are called out below. +// +// want==true means the unaffected entry applies and SUPPRESSES the match. The arch-independent, +// empty-package-arch, and arch-independent-entry cases are where this qualifier diverges from the +// affected one: where the affected qualifier leniently keeps a match it can't decide, the +// unaffected qualifier conservatively declines to suppress (the safe direction is opposite). +func TestUnaffectedArchitecture_Satisfied(t *testing.T) { + tests := []struct { + name string + qualifierArch string // arch stored on the (unaffected/negation) vulnerability entry + pkgArch string // arch read off the scanned package + want bool // does the unaffected entry apply (suppress) the package? + }{ + { + // the UAP applies only to binary RPMs, so it does not suppress a source pkg. + name: "src package / binary-no-arch entry -> does not suppress", + qualifierArch: ArchBinaryNoArchSpecified, + pkgArch: ArchSource, + want: false, + }, + { + // the UAP applies to all binary RPMs, so it suppresses any binary package. + name: "aarch64 binary / binary-no-arch entry -> suppresses", + qualifierArch: ArchBinaryNoArchSpecified, + pkgArch: "aarch64", + want: true, + }, + { + // diverges from affected: a "not affected on x86_64" record says nothing + // about the arch-independent build, so it must not suppress it. + name: "noarch package / concrete (x86_64) entry -> does not suppress", + qualifierArch: "x86_64", + pkgArch: archNoarch, + want: false, + }, + { + // same reasoning for the deb-dialect "all". + name: "deb 'all' package / concrete (amd64) entry -> does not suppress", + qualifierArch: "amd64", + pkgArch: archAll, + want: false, + }, + { + // a src-level not-affected applies by name regardless of arch — the binary + // is built from the unaffected source — so it suppresses the binary. + name: "binary package / src entry -> suppresses (built from unaffected src)", + qualifierArch: ArchSource, + pkgArch: "x86_64", + want: true, + }, + { + // matching concrete arches — the UAP applies, suppress. + name: "aarch64 package / aarch64 entry -> suppresses (arches match)", + qualifierArch: "aarch64", + pkgArch: "aarch64", + want: true, + }, + { + // mismatched concrete arches — the UAP doesn't apply, don't suppress. + name: "x86_64 package / aarch64 entry -> does not suppress (arches differ)", + qualifierArch: "aarch64", + pkgArch: "x86_64", + want: false, + }, + { + // diverges from affected: package arch unknown — can't prove the UAP + // applies, so don't suppress. + name: "empty package arch / concrete entry -> does not suppress (can't prove it applies)", + qualifierArch: "x86_64", + pkgArch: "", + want: false, + }, + { + // a blanket no-arch unaffected record applies to every package. + name: "concrete package / empty entry -> suppresses (blanket not-affected)", + qualifierArch: "", + pkgArch: "x86_64", + want: true, + }, + { + // diverges from affected: the UAP is about an arch-independent RPM and we + // can't show it applies to a concrete arch, so don't suppress. + name: "aarch64 package / noarch entry -> does not suppress", + qualifierArch: archNoarch, + pkgArch: "aarch64", + want: false, + }, + + // supplementary: cross-dialect spellings and extra pairings + { + // Confirmed: an exact arch-independent match is a real correspondence, so suppress. + name: "noarch package is suppressed by a noarch entry", + qualifierArch: "noarch", + pkgArch: "noarch", + want: true, + }, + { + // arch-independent entry vs a concrete package, rpm-dialect spelling. + name: "concrete package is not suppressed by an arch-independent entry", + qualifierArch: "noarch", + pkgArch: "x86_64", + want: false, + }, + { + // binary-no-arch does not suppress a source package (the upstream + // search's synthesized package is tagged "src"). + name: "binary-no-arch entry does not suppress a source package", + qualifierArch: ArchBinaryNoArchSpecified, + pkgArch: ArchSource, + want: false, + }, + { + // cross-dialect: confirmed concrete match suppresses. + name: "matching concrete arch suppresses (canonicalized)", + qualifierArch: "x86_64", + pkgArch: "amd64", + want: true, + }, + { + // confirmed concrete mismatch does not suppress. + name: "mismatched concrete arch does not suppress", + qualifierArch: "x86_64", + pkgArch: "aarch64", + want: false, + }, + } - archer, ok := q.(interface{ Arch() string }) - require.True(t, ok, "qualifier must expose Arch() so criteria can read the stored value") - require.Equal(t, tt.arch, archer.Arch()) + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := NewUnaffected(tt.qualifierArch, nil).Satisfied(pkg.Package{Metadata: pkg.RpmMetadata{Arch: tt.pkgArch}}) + require.NoError(t, err) + require.Equal(t, tt.want, got) }) } } + +// TestArchitecture_Satisfied_NonRpmMetadataInert pins that a package whose metadata is absent +// or isn't RpmMetadata reports no arch and so is never filtered — arch lives on the rpm +// metadata contract, and the qualifier only ever sees rpm packages in practice. +func TestArchitecture_Satisfied_NonRpmMetadataInert(t *testing.T) { + for _, p := range []pkg.Package{ + {Name: "no-metadata"}, + {Name: "apk-metadata", Metadata: pkg.ApkMetadata{}}, + } { + got, err := New("x86_64", nil).Satisfied(p) + require.NoError(t, err) + require.True(t, got, "package %q should be inert (no rpm arch to decide on)", p.Name) + } +} + +// TestArchitecture_Satisfied_DataDrivenAliases pins that the DB-supplied alias table — not the +// built-in defaults — drives canonicalization when present. A non-empty table is trusted +// exclusively, so a default fold absent from it no longer applies. +func TestArchitecture_Satisfied_DataDrivenAliases(t *testing.T) { + // a DB whose table folds a dialect the built-in defaults don't know about ("riscv64" = + // "riscv") and intentionally omits the built-in amd64 fold. + aliases := map[string]string{"riscv64": "riscv"} + + // the DB-provided fold applies + got, err := New("riscv", aliases).Satisfied(pkg.Package{Metadata: pkg.RpmMetadata{Arch: "riscv64"}}) + require.NoError(t, err) + require.True(t, got, "DB-supplied alias should fold riscv64 -> riscv") + + // the built-in default fold (x86_64 -> amd64) is NOT merged in when the DB table is present + got, err = New("amd64", aliases).Satisfied(pkg.Package{Metadata: pkg.RpmMetadata{Arch: "x86_64"}}) + require.NoError(t, err) + require.False(t, got, "non-empty DB table is trusted exclusively; built-in defaults must not leak in") +} + +// TestDefaultAliases pins the built-in fold table (the match-time fallback for pre-table +// databases and the seed source for the DB) and that callers get an isolated copy. +func TestDefaultAliases(t *testing.T) { + a := DefaultAliases() + require.Equal(t, "amd64", a["x86_64"]) + require.Equal(t, "arm64", a["aarch64"]) + require.NotContains(t, a, "amd64", "canonical tokens resolve to themselves and must not be keyed") + + a["x86_64"] = "tampered" + require.Equal(t, "amd64", DefaultAliases()["x86_64"], "DefaultAliases must return an isolated copy") +} diff --git a/grype/pkg/rpm_metadata.go b/grype/pkg/rpm_metadata.go index 12cd4c8f2aa..4553cf78e09 100644 --- a/grype/pkg/rpm_metadata.go +++ b/grype/pkg/rpm_metadata.go @@ -3,4 +3,10 @@ package pkg type RpmMetadata struct { Epoch *int `json:"epoch" cyclonedx:"epoch"` ModularityLabel *string `json:"modularityLabel" cyclonedx:"modularityLabel"` + // Arch is the package's architecture in rpm's native spelling ("x86_64", "aarch64", + // "noarch", ...), copied verbatim from the syft metadata. It is read at match time by + // pkg/qualifier/architecture to match a package against the architecture a vulnerability + // entry applies to. Cross-dialect aliases (amd64↔x86_64, ...) are reconciled there, so + // this is intentionally a raw string rather than an enum. + Arch string `json:"architecture" cyclonedx:"architecture"` } diff --git a/grype/pkg/upstream_package.go b/grype/pkg/upstream_package.go index c841852b0af..e3c9e63eb07 100644 --- a/grype/pkg/upstream_package.go +++ b/grype/pkg/upstream_package.go @@ -49,7 +49,21 @@ func UpstreamPackages(p Package) (pkgs []Package) { } tmp.CPEs = updatedCPEs + setSrcArchOnUpstream(&tmp) + pkgs = append(pkgs, tmp) } return pkgs } + +func setSrcArchOnUpstream(upstream *Package) { + if upstream == nil { + return + } + // TODO: additional cases can be added here as their matching becomes dependent + // on architecture. + if r, ok := upstream.Metadata.(RpmMetadata); ok { + r.Arch = "src" + upstream.Metadata = r + } +} diff --git a/grype/pkg/zarf_provider.go b/grype/pkg/zarf_provider.go new file mode 100644 index 00000000000..4c76567b170 --- /dev/null +++ b/grype/pkg/zarf_provider.go @@ -0,0 +1,229 @@ +package pkg + +import ( + "archive/tar" + "bytes" + "errors" + "fmt" + "io" + "os" + "strings" + + "github.com/klauspost/compress/zstd" + + "github.com/anchore/grype/grype/distro" + "github.com/anchore/grype/internal/log" + "github.com/anchore/syft/syft/format/syftjson" + "github.com/anchore/syft/syft/sbom" + "github.com/anchore/syft/syft/source" +) + +// zarfSBOMSourceAnnotation identifies which bundled SBOM (and the image, file, +// or binary it describes) inside a Zarf archive a package came from. Surfaces +// in JSON output as `artifact.annotations["zarf-sbom-source"]`. +const zarfSBOMSourceAnnotation = "zarf-sbom-source" + +const zarfInputPrefix = "zarf:" + +// maxSBOMEntryBytes is the maximum size of a single SBOM entry in sboms.tar. +// This is arbitrarily selected and could be improved with empirical data from +// large production Zarf packages. +const maxSBOMEntryBytes = 256 << 20 // 256 MB + +// maxSBOMEntries is the maximum number of entries to process in sboms.tar, +// as a safeguard against malformed archives with an excessive entry count. +const maxSBOMEntries = 1000 + +// ZarfPackageMetadata holds context about the source Zarf package. +type ZarfPackageMetadata struct { + Path string +} + +func zarfProvider(userInput string, config ProviderConfig, applyChannel func(*distro.Distro) bool) ([]*Package, Context, *sbom.SBOM, error) { + if !strings.HasPrefix(userInput, zarfInputPrefix) { + return nil, Context{}, nil, errDoesNotProvide + } + + archivePath := strings.TrimPrefix(userInput, zarfInputPrefix) + + packages, s, err := readZarfPackage(archivePath, config, applyChannel) + if err != nil { + return nil, Context{}, nil, fmt.Errorf("failed to read Zarf package: %w", err) + } + + ctx := Context{ + Source: &source.Description{ + Metadata: ZarfPackageMetadata{ + Path: archivePath, + }, + }, + } + + return packages, ctx, s, nil +} + +// readZarfPackage opens a Zarf .tar.zst archive, locates sboms.tar within it, +// and decodes each SBOM entry into a merged package list. +func readZarfPackage(archivePath string, config ProviderConfig, applyChannel func(*distro.Distro) bool) ([]*Package, *sbom.SBOM, error) { + f, err := os.Open(archivePath) + if err != nil { + return nil, nil, fmt.Errorf("unable to open Zarf package %s: %w", archivePath, err) + } + defer f.Close() + + // NOTE: no decompressed-size limit is applied to the zstd reader. Zarf packages + // could exceed 50GB decompressed in production, and the outer tar stream includes + // OCI image layers that must be skipped to reach sboms.tar. The streaming reader + // does not hold the full decompressed output in memory, but a decompression bomb + // could cause sustained CPU usage. + zr, err := zstd.NewReader(f) + if err != nil { + return nil, nil, fmt.Errorf("unable to create zstd reader: %w", err) + } + defer zr.Close() + + tr := tar.NewReader(zr) + + for { + hdr, err := tr.Next() + if errors.Is(err, io.EOF) { + break + } + if err != nil { + return nil, nil, fmt.Errorf("error reading Zarf archive: %w", err) + } + + if hdr.Name == "sboms.tar" { + return readSBOMsFromTar(tr, config, applyChannel) + } + } + + return nil, nil, fmt.Errorf("sboms.tar not found in Zarf package %s", archivePath) +} + +// readSBOMsFromTar iterates over entries in sboms.tar, decoding each SBOM +// and merging all packages into a single result set. +func readSBOMsFromTar(r io.Reader, config ProviderConfig, applyChannel func(*distro.Distro) bool) ([]*Package, *sbom.SBOM, error) { + sbomTar := tar.NewReader(r) + + var allPackages []*Package + pkgIndex := map[ID]*Package{} + var mergedSBOM *sbom.SBOM + var decodedCount int + var entryCount int + + for { + hdr, err := sbomTar.Next() + if errors.Is(err, io.EOF) { + break + } + if err != nil { + return nil, nil, fmt.Errorf("error reading sboms.tar: %w", err) + } + + entryCount++ + if entryCount > maxSBOMEntries { + return nil, nil, fmt.Errorf("sboms.tar contains more than %d entries, which exceeds the maximum", maxSBOMEntries) + } + + if hdr.Typeflag != tar.TypeReg { + continue + } + + log.WithFields("entry", hdr.Name, "size", hdr.Size).Debug("reading SBOM from Zarf package") + + s, fmtID, ok := readSBOMEntry(sbomTar, hdr) + if !ok { + continue + } + decodedCount++ + + d, _ := distroFromSBOM(s, config, applyChannel) + + var enhancers []Enhancer + if fmtID != syftjson.ID { + enhancers = purlEnhancers(applyChannel) + } + + packages := FromCollection(s.Artifacts.Packages, s.Relationships, config.SynthesisConfig, enhancers...) + + allPackages = mergePackagesFromSBOM(allPackages, pkgIndex, packages, d, zarfSBOMSourceID(s, hdr.Name)) + + if mergedSBOM == nil { + mergedSBOM = s + } else { + mergeSBOM(mergedSBOM, s) + } + } + + if decodedCount == 0 { + return nil, nil, fmt.Errorf("no SBOMs could be parsed from Zarf package") + } + + if len(allPackages) == 0 { + log.WithFields("sboms", decodedCount).Debug("Zarf package SBOMs parsed successfully but contain no packages") + } + + return allPackages, mergedSBOM, nil +} + +// readSBOMEntry reads and decodes a single SBOM entry from sboms.tar, logging +// and returning ok=false if the entry should be skipped. +func readSBOMEntry(sbomTar *tar.Reader, hdr *tar.Header) (s *sbom.SBOM, fmtID sbom.FormatID, ok bool) { + if hdr.Size > maxSBOMEntryBytes { + log.WithFields("entry", hdr.Name, "size", hdr.Size, "max", maxSBOMEntryBytes).Debug("skipping SBOM entry in Zarf package: exceeds max entry size") + return nil, "", false + } + + buf, err := io.ReadAll(io.LimitReader(sbomTar, maxSBOMEntryBytes)) + if err != nil { + log.WithFields("entry", hdr.Name, "error", err).Debug("failed to read SBOM entry in Zarf package") + return nil, "", false + } + + s, fmtID, err = readSBOM(bytes.NewReader(buf)) + if err != nil { + log.WithFields("entry", hdr.Name, "error", err).Debug("failed to decode SBOM entry in Zarf package") + return nil, "", false + } + + return s, fmtID, true +} + +// mergeSBOM adds artifacts from src into dst for downstream formatting. +func mergeSBOM(dst, src *sbom.SBOM) { + for p := range src.Artifacts.Packages.Enumerate() { + dst.Artifacts.Packages.Add(p) + } + dst.Relationships = append(dst.Relationships, src.Relationships...) +} + +// mergePackagesFromSBOM annotates each package with provenance back to the originating +// SBOM and propagates the per-SBOM distro to packages that don't already carry one +func mergePackagesFromSBOM(allPackages []*Package, pkgIndex map[ID]*Package, packages []*Package, d *distro.Distro, sourceID string) []*Package { + for _, p := range packages { + p.AddAnnotation(zarfSBOMSourceAnnotation, sourceID) + if p.Distro == nil && d != nil { + p.Distro = d + } + + if existing, ok := pkgIndex[p.ID]; ok { + existing.AddAnnotation(zarfSBOMSourceAnnotation, sourceID) + continue + } + pkgIndex[p.ID] = p + allPackages = append(allPackages, p) + } + return allPackages +} + +// zarfSBOMSourceID returns the identifier used as the annotation value for a +// package extracted from the given SBOM, preferring the SBOM's declared source +// name and falling back to the tar entry filename. +func zarfSBOMSourceID(s *sbom.SBOM, entryName string) string { + identifier := s.Source.Name + if identifier == "" || strings.HasPrefix(identifier, "/") { + identifier = entryName + } + return identifier +} diff --git a/grype/pkg/zarf_provider_test.go b/grype/pkg/zarf_provider_test.go new file mode 100644 index 00000000000..63df848c6a8 --- /dev/null +++ b/grype/pkg/zarf_provider_test.go @@ -0,0 +1,252 @@ +package pkg + +import ( + "archive/tar" + "bytes" + "encoding/json" + "os" + "path/filepath" + "testing" + + "github.com/klauspost/compress/zstd" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func createTestZarfPackage(t *testing.T, sbomFiles map[string]string) string { + t.Helper() + + // build sboms.tar in memory + var sbomBuf bytes.Buffer + sbomTW := tar.NewWriter(&sbomBuf) + for name, content := range sbomFiles { + require.NoError(t, sbomTW.WriteHeader(&tar.Header{ + Name: name, + Size: int64(len(content)), + Mode: 0o600, + })) + _, err := sbomTW.Write([]byte(content)) + require.NoError(t, err) + } + require.NoError(t, sbomTW.Close()) + + // build outer .tar.zst with sboms.tar entry + outPath := filepath.Join(t.TempDir(), "test-package.tar.zst") + f, err := os.Create(outPath) + require.NoError(t, err) + defer f.Close() + + zw, err := zstd.NewWriter(f) + require.NoError(t, err) + + tw := tar.NewWriter(zw) + sbomBytes := sbomBuf.Bytes() + require.NoError(t, tw.WriteHeader(&tar.Header{ + Name: "sboms.tar", + Size: int64(len(sbomBytes)), + Mode: 0o600, + })) + _, err = tw.Write(sbomBytes) + require.NoError(t, err) + require.NoError(t, tw.Close()) + require.NoError(t, zw.Close()) + + return outPath +} + +func TestZarfProvider(t *testing.T) { + sbomContent, err := os.ReadFile("testdata/syft-multiple-ecosystems.json") + require.NoError(t, err) + + tests := []struct { + name string + userInput string + sbomFiles map[string]string + wantPkgs bool + wantErr bool + }{ + { + name: "reads single SBOM from Zarf package", + sbomFiles: map[string]string{ + "sbom-image.json": string(sbomContent), + }, + wantPkgs: true, + }, + { + name: "reads multiple SBOMs from Zarf package", + sbomFiles: map[string]string{ + "sbom-image-a.json": string(sbomContent), + "sbom-image-b.json": string(sbomContent), + }, + wantPkgs: true, + }, + { + name: "non-zarf prefix returns errDoesNotProvide", + userInput: "dir:/some/path", + wantErr: true, + }, + { + name: "missing file returns error", + userInput: "zarf:/nonexistent/file.tar.zst", + wantErr: true, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + userInput := tc.userInput + if userInput == "" && tc.sbomFiles != nil { + archivePath := createTestZarfPackage(t, tc.sbomFiles) + userInput = "zarf:" + archivePath + } + + applyChannel := getDistroChannelApplier(nil) + packages, ctx, _, err := zarfProvider(userInput, ProviderConfig{}, applyChannel) + + if tc.wantErr { + require.Error(t, err) + return + } + require.NoError(t, err) + + if tc.wantPkgs { + assert.NotEmpty(t, packages) + assert.NotNil(t, ctx.Source) + _, ok := ctx.Source.Metadata.(ZarfPackageMetadata) + assert.True(t, ok, "expected ZarfPackageMetadata in context source") + } + }) + } +} + +// TestZarfProvider_MultiSBOM_DedupesByID verifies that two copies of the same +// SBOM yield the same package count as one copy: shared package IDs are folded +// onto a single Package entry whose annotation list captures both sources. +func TestZarfProvider_MultiSBOM_DedupesByID(t *testing.T) { + sbomContent, err := os.ReadFile("testdata/syft-multiple-ecosystems.json") + require.NoError(t, err) + + singlePath := createTestZarfPackage(t, map[string]string{ + "sbom-a.json": string(sbomContent), + }) + + applyChannel := getDistroChannelApplier(nil) + singlePkgs, _, _, err := zarfProvider("zarf:"+singlePath, ProviderConfig{}, applyChannel) + require.NoError(t, err) + + doublePath := createTestZarfPackage(t, map[string]string{ + "sbom-a.json": string(sbomContent), + "sbom-b.json": string(sbomContent), + }) + + doublePkgs, _, _, err := zarfProvider("zarf:"+doublePath, ProviderConfig{}, applyChannel) + require.NoError(t, err) + + assert.Equal(t, len(singlePkgs), len(doublePkgs), "duplicate SBOMs should not increase package count after dedupe") + + for _, p := range doublePkgs { + sources := p.Annotations[zarfSBOMSourceAnnotation] + assert.Equal(t, []string{"sbom-a.json", "sbom-b.json"}, sources, "package %q should carry both source identifiers", p.Name) + } +} + +// TestZarfProvider_Annotations verifies that every package returned from a Zarf +// scan carries a `zarf-sbom-source` annotation identifying which bundled SBOM +// it came from. The fixture has source.name=null, so the tar entry filename is +// used as the fallback identifier. When the same package ID appears in multiple +// SBOMs, the annotation values are unioned onto a single package entry. +func TestZarfProvider_Annotations(t *testing.T) { + sbomContent, err := os.ReadFile("testdata/syft-multiple-ecosystems.json") + require.NoError(t, err) + + archivePath := createTestZarfPackage(t, map[string]string{ + "sbom-image-a.json": string(sbomContent), + "sbom-image-b.json": string(sbomContent), + }) + + applyChannel := getDistroChannelApplier(nil) + packages, _, _, err := zarfProvider("zarf:"+archivePath, ProviderConfig{}, applyChannel) + require.NoError(t, err) + require.NotEmpty(t, packages) + + for _, pkg := range packages { + sources := pkg.Annotations[zarfSBOMSourceAnnotation] + assert.NotEmpty(t, sources, "package %q missing zarf-sbom-source annotation (annotations=%v)", pkg.Name, pkg.Annotations) + // since both SBOMs are identical, every shared package ID should carry both source identifiers + assert.Equal(t, []string{"sbom-image-a.json", "sbom-image-b.json"}, sources, "package %q has unexpected source list", pkg.Name) + } +} + +// TestZarfProvider_PreservesPerSBOMDistro verifies that when a Zarf bundle +// contains SBOMs from different distros, each package retains its own distro +// context. This is the core correctness property the design hinges on. +func TestZarfProvider_PreservesPerSBOMDistro(t *testing.T) { + alpineSBOM, err := os.ReadFile("testdata/syft-multiple-ecosystems.json") // alpine 3.12.0 + require.NoError(t, err) + debianSBOM, err := os.ReadFile("testdata/syft-spring.json") // debian 9 + require.NoError(t, err) + + archivePath := createTestZarfPackage(t, map[string]string{ + "sbom-alpine.json": string(alpineSBOM), + "sbom-debian.json": string(debianSBOM), + }) + + applyChannel := getDistroChannelApplier(nil) + packages, _, _, err := zarfProvider("zarf:"+archivePath, ProviderConfig{}, applyChannel) + require.NoError(t, err) + require.NotEmpty(t, packages) + + distroCounts := map[string]int{} + for _, pkg := range packages { + if pkg.Distro == nil { + distroCounts[""]++ + continue + } + distroCounts[string(pkg.Distro.Type)]++ + } + + assert.Greater(t, distroCounts["alpine"], 0, "expected at least one package with alpine distro, got %v", distroCounts) + assert.Greater(t, distroCounts["debian"], 0, "expected at least one package with debian distro, got %v", distroCounts) +} + +// TestZarfProvider_EmptySBOMYieldsNoFindings verifies that a Zarf package +// containing only SBOMs with zero artifacts (e.g. FROM-scratch images) returns +// an empty package list without erroring. Matches the behavior of the direct +// `sbom:` provider on the same input. +func TestZarfProvider_EmptySBOMYieldsNoFindings(t *testing.T) { + sbomContent, err := os.ReadFile("testdata/syft-multiple-ecosystems.json") + require.NoError(t, err) + + // zero out the artifacts array to simulate an SBOM produced from a + // FROM-scratch image (no packages to catalog) + var raw map[string]any + require.NoError(t, json.Unmarshal(sbomContent, &raw)) + raw["artifacts"] = []any{} + emptied, err := json.Marshal(raw) + require.NoError(t, err) + + archivePath := createTestZarfPackage(t, map[string]string{ + "empty-sbom.json": string(emptied), + }) + + applyChannel := getDistroChannelApplier(nil) + packages, ctx, _, err := zarfProvider("zarf:"+archivePath, ProviderConfig{}, applyChannel) + require.NoError(t, err) + assert.Empty(t, packages, "expected no packages from a zero-artifact SBOM") + assert.NotNil(t, ctx.Source, "expected source context to be populated even with empty packages") +} + +// TestZarfProvider_AllEntriesInvalidReturnsError verifies that when no entries +// in sboms.tar can be parsed as SBOMs (e.g. only HTML reports), the provider +// returns an error so the user gets a signal that nothing was scanned. +func TestZarfProvider_AllEntriesInvalidReturnsError(t *testing.T) { + archivePath := createTestZarfPackage(t, map[string]string{ + "compare.html": "not an sbom", + "not-an-sbom.txt": "random junk that isn't an SBOM", + }) + + applyChannel := getDistroChannelApplier(nil) + _, _, _, err := zarfProvider("zarf:"+archivePath, ProviderConfig{}, applyChannel) + require.Error(t, err) + assert.Contains(t, err.Error(), "no SBOMs could be parsed") +} diff --git a/grype/presenter/cyclonedx/testdata/snapshot/TestCycloneDxPresenterDir.golden b/grype/presenter/cyclonedx/testdata/snapshot/TestCycloneDxPresenterDir.golden index dff67653e66..982716cb2e3 100644 --- a/grype/presenter/cyclonedx/testdata/snapshot/TestCycloneDxPresenterDir.golden +++ b/grype/presenter/cyclonedx/testdata/snapshot/TestCycloneDxPresenterDir.golden @@ -24,7 +24,7 @@ }, "components": [ { - "bom-ref": "bbb0ba712c2b94ea", + "bom-ref": "a246fd2054833c93", "type": "library", "name": "package-1", "version": "1.1.1", @@ -116,7 +116,7 @@ ], "affects": [ { - "ref": "bbb0ba712c2b94ea" + "ref": "a246fd2054833c93" } ] }, diff --git a/grype/presenter/cyclonedx/testdata/snapshot/TestCycloneDxPresenterImage.golden b/grype/presenter/cyclonedx/testdata/snapshot/TestCycloneDxPresenterImage.golden index 09939d4f6bc..712798adcdf 100644 --- a/grype/presenter/cyclonedx/testdata/snapshot/TestCycloneDxPresenterImage.golden +++ b/grype/presenter/cyclonedx/testdata/snapshot/TestCycloneDxPresenterImage.golden @@ -25,7 +25,7 @@ }, "components": [ { - "bom-ref": "bbb0ba712c2b94ea", + "bom-ref": "a246fd2054833c93", "type": "library", "name": "package-1", "version": "1.1.1", @@ -117,7 +117,7 @@ ], "affects": [ { - "ref": "bbb0ba712c2b94ea" + "ref": "a246fd2054833c93" } ] }, diff --git a/grype/presenter/internal/test_helpers.go b/grype/presenter/internal/test_helpers.go index 2bfafbc20d8..7334fcdad5d 100644 --- a/grype/presenter/internal/test_helpers.go +++ b/grype/presenter/internal/test_helpers.go @@ -400,6 +400,7 @@ func generatePackages(t *testing.T) []syftPkg.Package { Metadata: syftPkg.RpmDBEntry{ Epoch: &epoch, SourceRpm: "some-source-rpm", + Arch: "x86_64", }, }, { diff --git a/grype/presenter/json/testdata/snapshot/TestJsonDirsPresenter.golden b/grype/presenter/json/testdata/snapshot/TestJsonDirsPresenter.golden index 08b87fe32cd..021898d2d92 100644 --- a/grype/presenter/json/testdata/snapshot/TestJsonDirsPresenter.golden +++ b/grype/presenter/json/testdata/snapshot/TestJsonDirsPresenter.golden @@ -57,7 +57,7 @@ } ], "artifact": { - "id": "bbb0ba712c2b94ea", + "id": "a246fd2054833c93", "name": "package-1", "version": "1.1.1", "type": "rpm", @@ -77,7 +77,8 @@ "metadataType": "RpmMetadata", "metadata": { "epoch": 2, - "modularityLabel": null + "modularityLabel": null, + "architecture": "x86_64" } } }, diff --git a/grype/presenter/json/testdata/snapshot/TestJsonImgsPresenter.golden b/grype/presenter/json/testdata/snapshot/TestJsonImgsPresenter.golden index d6d4ce6ed97..890dcff2fb6 100644 --- a/grype/presenter/json/testdata/snapshot/TestJsonImgsPresenter.golden +++ b/grype/presenter/json/testdata/snapshot/TestJsonImgsPresenter.golden @@ -57,7 +57,7 @@ } ], "artifact": { - "id": "bbb0ba712c2b94ea", + "id": "a246fd2054833c93", "name": "package-1", "version": "1.1.1", "type": "rpm", @@ -77,7 +77,8 @@ "metadataType": "RpmMetadata", "metadata": { "epoch": 2, - "modularityLabel": null + "modularityLabel": null, + "architecture": "x86_64" } } }, diff --git a/grype/presenter/models/package.go b/grype/presenter/models/package.go index 79309060a78..ec308bab81c 100644 --- a/grype/presenter/models/package.go +++ b/grype/presenter/models/package.go @@ -9,18 +9,19 @@ import ( // Package is meant to be only the fields that are needed when displaying a single pkg.Package object for the JSON presenter. type Package struct { - ID string `json:"id"` - Name string `json:"name"` - Version string `json:"version"` - Type syftPkg.Type `json:"type"` - Locations file.Locations `json:"locations"` - Language syftPkg.Language `json:"language"` - Licenses []string `json:"licenses"` - CPEs []string `json:"cpes"` - PURL string `json:"purl"` - Upstreams []UpstreamPackage `json:"upstreams"` - MetadataType string `json:"metadataType,omitempty"` - Metadata any `json:"metadata,omitempty"` + ID string `json:"id"` + Name string `json:"name"` + Version string `json:"version"` + Type syftPkg.Type `json:"type"` + Locations file.Locations `json:"locations"` + Language syftPkg.Language `json:"language"` + Licenses []string `json:"licenses"` + CPEs []string `json:"cpes"` + PURL string `json:"purl"` + Upstreams []UpstreamPackage `json:"upstreams"` + MetadataType string `json:"metadataType,omitempty"` + Metadata any `json:"metadata,omitempty"` + Annotations map[string][]string `json:"annotations,omitempty"` } type UpstreamPackage struct { @@ -61,5 +62,6 @@ func newPackage(p pkg.Package) Package { Upstreams: upstreams, MetadataType: packagemetadata.JSONName(p.Metadata), Metadata: p.Metadata, + Annotations: p.Annotations, } } diff --git a/grype/presenter/models/source.go b/grype/presenter/models/source.go index 43fecc23d9a..53bc8ba92c4 100644 --- a/grype/presenter/models/source.go +++ b/grype/presenter/models/source.go @@ -30,6 +30,11 @@ func newSource(src syftSource.Description) (source, error) { Type: "cpe", Target: m.CPE, }, nil + case pkg.ZarfPackageMetadata: + return source{ + Type: "zarf-package", + Target: m.Path, + }, nil case syftSource.ImageMetadata: // ensure that empty collections are not shown as null if m.RepoDigests == nil { diff --git a/internal/dbtest/builder_test.go b/internal/dbtest/builder_test.go index 9cf594abdd3..70e98533967 100644 --- a/internal/dbtest/builder_test.go +++ b/internal/dbtest/builder_test.go @@ -61,6 +61,47 @@ func TestPackageBuilder_WithID(t *testing.T) { assert.Equal(t, pkg.ID("stable-id"), p.ID) } +// TestPackageBuilder_WithArchitecture pins down both the set path (concrete +// arch stamped onto pkg.RpmMetadata) and the default (unset → no arch), since +// the empty case is what triggers the architectureQualifier's inert-passthrough +// branch at match time. +func TestPackageBuilder_WithArchitecture(t *testing.T) { + tests := []struct { + name string + set func(*PackageBuilder) *PackageBuilder + want string + }{ + { + name: "concrete arch is stored", + set: func(b *PackageBuilder) *PackageBuilder { return b.WithArchitecture("x86_64") }, + want: "x86_64", + }, + { + name: "different arch overwrites prior value", + set: func(b *PackageBuilder) *PackageBuilder { + return b.WithArchitecture("x86_64").WithArchitecture("aarch64") + }, + want: "aarch64", + }, + { + name: "unset architecture defaults to empty string", + set: func(b *PackageBuilder) *PackageBuilder { return b }, + want: "", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + b := NewPackage("test-pkg", "1.0.0", syftPkg.RpmPkg) + p := tt.set(b).Build() + var got string + if m, ok := p.Metadata.(pkg.RpmMetadata); ok { + got = m.Arch + } + assert.Equal(t, tt.want, got) + }) + } +} + func TestRelativeResultPath(t *testing.T) { tests := []struct { name string diff --git a/internal/dbtest/package.go b/internal/dbtest/package.go index b58ba906fa8..fc9cc381ecd 100644 --- a/internal/dbtest/package.go +++ b/internal/dbtest/package.go @@ -48,7 +48,8 @@ var ( // PackageBuilder provides a fluent API for building test packages. type PackageBuilder struct { - pkg pkg.Package + pkg pkg.Package + arch string } // NewPackage creates a new PackageBuilder with the given name, version, and type. @@ -84,6 +85,15 @@ func (b *PackageBuilder) WithDistro(d *distro.Distro) *PackageBuilder { return b } +// WithArchitecture sets the package architecture (e.g., "x86_64", "aarch64"), stamped onto +// the rpm metadata at Build time. Read by the architectureQualifier at match time to match a +// package against the architecture a vulnerability entry applies to. Applied last so it +// composes with WithMetadata regardless of call order. +func (b *PackageBuilder) WithArchitecture(arch string) *PackageBuilder { + b.arch = arch + return b +} + // WithLanguage sets the package's language ecosystem. func (b *PackageBuilder) WithLanguage(lang syftPkg.Language) *PackageBuilder { b.pkg.Language = lang @@ -155,5 +165,12 @@ func (b *PackageBuilder) Build() pkg.Package { if b.pkg.ID == "" { b.pkg.ID = pkg.ID(uuid.New().String()) } + if b.arch != "" { + // arch lives on the rpm metadata contract; stamp it onto any existing RpmMetadata, + // otherwise synthesize one (arch-based matching is rpm-only today). + m, _ := b.pkg.Metadata.(pkg.RpmMetadata) + m.Arch = b.arch + b.pkg.Metadata = m + } return b.pkg } diff --git a/test/quality/test-db b/test/quality/test-db index f1522f8b0b9..7da545bad96 100644 --- a/test/quality/test-db +++ b/test/quality/test-db @@ -1 +1 @@ -https://grype.anchore.io/databases/v6/vulnerability-db_v6.1.4_2026-05-01T00:49:33Z_1777619544.tar.zst +https://grype.anchore.io/databases/v6/vulnerability-db_v6.1.7_2026-07-01T00:54:38Z_1782911969.tar.zst From d006be2270c7cf72dba7095ac61a8d35047784d4 Mon Sep 17 00:00:00 2001 From: Vaibhav Thatai Date: Wed, 1 Jul 2026 15:13:38 +0530 Subject: [PATCH 3/5] feat(rapidfort): integrate curated image advisories - Add RapidFort-specific matcher that identifies containers via image labels and routes vulnerability lookups through the RapidFort DB - Refactor matcher selection into the matcher package - Add Red Hat advisory support: parse OS FixedIn identifiers and emit release-identifier refs with vendor advisory URLs in v6 transform - Fix binary lookup match type (ExactDirectMatch) and expand test coverage for label detection and distro version parsing Signed-off-by: Vaibhav Thatai --- cmd/grype/cli/commands/root.go | 8 +- .../provider/unmarshal/os_vulnerability.go | 1 + .../os/testdata/rapidfort-redhat-9.json | 44 +++ .../os/testdata/rapidfort-ubuntu-20.04.json | 26 ++ .../db/v6/build/transformers/os/transform.go | 61 ++- .../build/transformers/os/transform_test.go | 180 +++++++++ grype/distro/distro_test.go | 3 + grype/distro/type.go | 119 +++--- grype/match/matcher_type.go | 1 + grype/matcher/matchers.go | 43 ++ grype/matcher/matchers_test.go | 62 +++ grype/matcher/rapidfort/label.go | 32 ++ grype/matcher/rapidfort/label_test.go | 89 +++++ grype/matcher/rapidfort/matcher.go | 371 ++++++++++++++++++ grype/matcher/rapidfort/matcher_test.go | 172 ++++++++ 15 files changed, 1138 insertions(+), 74 deletions(-) create mode 100644 grype/db/v6/build/transformers/os/testdata/rapidfort-redhat-9.json create mode 100644 grype/db/v6/build/transformers/os/testdata/rapidfort-ubuntu-20.04.json create mode 100644 grype/matcher/matchers_test.go create mode 100644 grype/matcher/rapidfort/label.go create mode 100644 grype/matcher/rapidfort/label_test.go create mode 100644 grype/matcher/rapidfort/matcher.go create mode 100644 grype/matcher/rapidfort/matcher_test.go diff --git a/cmd/grype/cli/commands/root.go b/cmd/grype/cli/commands/root.go index b39a841404e..0f1dcbd6716 100644 --- a/cmd/grype/cli/commands/root.go +++ b/cmd/grype/cli/commands/root.go @@ -223,12 +223,14 @@ func runGrype(ctx context.Context, app clio.Application, opts *options.Grype, us return fmt.Errorf("failed to create VEX processor: %w", err) } + matchers := getMatchers(opts, pkgContext) + vulnMatcher := grype.VulnerabilityMatcher{ VulnerabilityProvider: vp, IgnoreRules: opts.Ignore, NormalizeByCVE: opts.ByCVE, FailSeverity: opts.FailOnSeverity(), - Matchers: getMatchers(opts), + Matchers: matchers, VexProcessor: vexProcessor, Alerts: grype.AlertsConfig{ EnableEOLDistroWarnings: opts.Alerts.EnableEOLDistroWarnings, @@ -394,8 +396,8 @@ func getMatcherConfig(opts *options.Grype) matcher.Config { } } -func getMatchers(opts *options.Grype) []match.Matcher { - return matcher.NewDefaultMatchers(getMatcherConfig(opts)) +func getMatchers(opts *options.Grype, pkgContext pkg.Context) []match.Matcher { + return matcher.ApplySelectionPolicy(matcher.NewDefaultMatchers(getMatcherConfig(opts)), pkgContext) } func getProviderConfig(opts *options.Grype) pkg.ProviderConfig { diff --git a/grype/db/internal/provider/unmarshal/os_vulnerability.go b/grype/db/internal/provider/unmarshal/os_vulnerability.go index 80b42d7a9b3..f44d1c791fd 100644 --- a/grype/db/internal/provider/unmarshal/os_vulnerability.go +++ b/grype/db/internal/provider/unmarshal/os_vulnerability.go @@ -14,6 +14,7 @@ type OSFixedIn struct { Module *string `json:"Module,omitempty"` Name string `json:"Name"` NamespaceName string `json:"NamespaceName"` + Identifier string `json:"Identifier,omitempty"` VendorAdvisory struct { AdvisorySummary []struct { ID string `json:"ID"` diff --git a/grype/db/v6/build/transformers/os/testdata/rapidfort-redhat-9.json b/grype/db/v6/build/transformers/os/testdata/rapidfort-redhat-9.json new file mode 100644 index 00000000000..f283410223e --- /dev/null +++ b/grype/db/v6/build/transformers/os/testdata/rapidfort-redhat-9.json @@ -0,0 +1,44 @@ +[ + { + "Vulnerability": { + "CVSS": [], + "Description": "curl wildcard certificate validation issue", + "FixedIn": [ + { + "Identifier": "el9", + "Name": "curl", + "NamespaceName": "rapidfort-redhat:9", + "Version": "None", + "VersionFormat": "rpm", + "VulnerableRange": ">= 0", + "VendorAdvisory": { + "AdvisorySummary": [ + { + "ID": "RHSA-TEST-EL9", + "Link": "https://access.redhat.com/errata/RHSA-TEST-EL9" + } + ], + "NoAdvisory": false + } + }, + { + "Identifier": "fc36", + "Name": "curl", + "NamespaceName": "rapidfort-redhat:9", + "Version": "7.78.0-4.fc36", + "VersionFormat": "rpm", + "VulnerableRange": ">= 0, < 7.78.0-4.fc36", + "Available": { + "Date": "2024-01-01T00:00:00Z", + "Kind": "advisory" + } + } + ], + "Link": "https://www.cve.org/CVERecord?id=CVE-2014-0139", + "Metadata": {}, + "Name": "CVE-2014-0139", + "NamespaceName": "rapidfort-redhat:9", + "Severity": "Low" + } + } +] diff --git a/grype/db/v6/build/transformers/os/testdata/rapidfort-ubuntu-20.04.json b/grype/db/v6/build/transformers/os/testdata/rapidfort-ubuntu-20.04.json new file mode 100644 index 00000000000..8999f2437da --- /dev/null +++ b/grype/db/v6/build/transformers/os/testdata/rapidfort-ubuntu-20.04.json @@ -0,0 +1,26 @@ +[ + { + "Vulnerability": { + "CVSS": [], + "Description": "curl: OAUTH2 bearer bypass in connection re-use", + "FixedIn": [ + { + "Name": "curl", + "NamespaceName": "rapidfort-ubuntu:20.04", + "Version": "7.68.0-1ubuntu2.10", + "VersionFormat": "dpkg", + "VulnerableRange": ">= 7.68.0, < 7.68.0-1ubuntu2.10", + "Available": { + "Date": "2022-05-01T00:00:00Z", + "Kind": "advisory" + } + } + ], + "Link": "https://cve.mitre.org/cgi-bin/cvename.cgi?name=CVE-2022-22576", + "Metadata": {}, + "Name": "CVE-2022-22576", + "NamespaceName": "rapidfort-ubuntu:20.04", + "Severity": "Medium" + } + } +] diff --git a/grype/db/v6/build/transformers/os/transform.go b/grype/db/v6/build/transformers/os/transform.go index be135ca42bf..0b8c42c3b2f 100644 --- a/grype/db/v6/build/transformers/os/transform.go +++ b/grype/db/v6/build/transformers/os/transform.go @@ -170,6 +170,21 @@ func getFix(fixedInEntry unmarshal.OSFixedIn) *db.Fix { } var refs []db.Reference + if fixedInEntry.Identifier != "" { + rid := releaseIdentifierReferenceID(fixedInEntry.Identifier) + refURL := rid + for _, adv := range advisoryOrder { + if adv.link != "" { + refURL = adv.link + break + } + } + refs = append(refs, db.Reference{ + ID: rid, + URL: refURL, // prefer vendor advisory URL; else rid so toAdvisories includes the ref + Tags: []string{db.AdvisoryReferenceTag}, + }) + } for _, adv := range advisoryOrder { refs = append(refs, db.Reference{ ID: adv.id, @@ -211,6 +226,10 @@ func getFixAvailability(fixedInEntry unmarshal.OSFixedIn) *db.FixAvailability { } } +func releaseIdentifierReferenceID(identifier string) string { + return "release-identifier:" + strings.ToLower(strings.TrimSpace(identifier)) +} + func enforceConstraint(fixedVersion, vulnerableRange, format, vulnerabilityID string) string { if len(vulnerableRange) > 0 { return vulnerableRange @@ -250,15 +269,18 @@ func deriveConstraintFromFix(fixVersion, vulnerabilityID string) string { } type groupIndex struct { - name string - id string - osName string - osVersion string - osChannel string - hasModule bool module string format string - arch string + name string + id string + osName string + osVersion string + osChannel string + identifier string + hasModule bool + module string + format string + arch string } func groupFixedIns(vuln unmarshal.OSVulnerability) map[groupIndex][]unmarshal.OSFixedIn { @@ -275,17 +297,18 @@ func groupFixedIns(vuln unmarshal.OSVulnerability) map[groupIndex][]unmarshal.OS arch = *fixedIn.Arch } g := groupIndex{ - name: fixedIn.Name, - id: oi.id, - osName: oi.name, - osVersion: oi.version, - osChannel: oi.channel, - hasModule: fixedIn.Module != nil, - module: mod, - format: fixedIn.VersionFormat, // arch splits a per-arch fix into its own affected package handle so the architecture // qualifier can scope it; empty means the fix applies to all arches. - arch: arch, + name: fixedIn.Name, + id: oi.id, + osName: oi.name, + osVersion: oi.version, + osChannel: oi.channel, + identifier: fixedIn.Identifier, + hasModule: fixedIn.Module != nil, + module: mod, + format: fixedIn.VersionFormat, + arch: arch, } grouped[g] = append(grouped[g], fixedIn) @@ -305,6 +328,12 @@ func getPackageType(osName string) pkg.Type { return pkg.ApkPkg case "windows": return pkg.KbPkg + case "rapidfort-ubuntu": + return pkg.DebPkg + case "rapidfort-alpine": + return pkg.ApkPkg + case "rapidfort-redhat": + return pkg.RpmPkg } return "" diff --git a/grype/db/v6/build/transformers/os/transform_test.go b/grype/db/v6/build/transformers/os/transform_test.go index e536656d296..c6fb1dcd271 100644 --- a/grype/db/v6/build/transformers/os/transform_test.go +++ b/grype/db/v6/build/transformers/os/transform_test.go @@ -44,6 +44,18 @@ func expectedProvider(name string) *db.Provider { func TestTransform(t *testing.T) { + rapidfortUbuntu2004OS := &db.OperatingSystem{ + Name: "rapidfort-ubuntu", + ReleaseID: "rapidfort-ubuntu", + MajorVersion: "20", + MinorVersion: "04", + } + rapidfortRedHat9OS := &db.OperatingSystem{ + Name: "rapidfort-redhat", + ReleaseID: "rapidfort-redhat", + MajorVersion: "9", + } + alpineOS := &db.OperatingSystem{ Name: "alpine", ReleaseID: "alpine", @@ -1138,6 +1150,147 @@ func TestTransform(t *testing.T) { }, }, }, + { + name: "testdata/rapidfort-ubuntu-20.04.json", + provider: "rapidfort", + want: []transformers.RelatedEntries{ + { + VulnerabilityHandle: &db.VulnerabilityHandle{ + Name: "CVE-2022-22576", + ProviderID: "rapidfort", + Provider: expectedProvider("rapidfort"), + Status: "active", + BlobValue: &db.VulnerabilityBlob{ + ID: "CVE-2022-22576", + Description: "curl: OAUTH2 bearer bypass in connection re-use", + References: []db.Reference{ + {URL: "https://cve.mitre.org/cgi-bin/cvename.cgi?name=CVE-2022-22576"}, + }, + Severities: []db.Severity{ + {Scheme: db.SeveritySchemeCHMLN, Value: "medium", Rank: 1}, + }, + }, + }, + Related: affectedPkgSlice( + db.AffectedPackageHandle{ + OperatingSystem: rapidfortUbuntu2004OS, + Package: &db.Package{Ecosystem: "deb", Name: "curl"}, + BlobValue: &db.PackageBlob{ + Ranges: []db.Range{ + { + Version: db.Version{ + Type: "dpkg", + Constraint: ">= 7.68.0, < 7.68.0-1ubuntu2.10", + }, + Fix: &db.Fix{ + Version: "7.68.0-1ubuntu2.10", + State: db.FixedStatus, + Detail: &db.FixDetail{ + Available: &db.FixAvailability{ + Date: timeRef(time.Date(2022, 5, 1, 0, 0, 0, 0, time.UTC)), + Kind: "advisory", + }, + }, + }, + }, + }, + }, + }, + ), + }, + }, + }, + { + name: "testdata/rapidfort-redhat-9.json", + provider: "rapidfort", + want: []transformers.RelatedEntries{ + { + VulnerabilityHandle: &db.VulnerabilityHandle{ + Name: "CVE-2014-0139", + ProviderID: "rapidfort", + Provider: expectedProvider("rapidfort"), + Status: "active", + BlobValue: &db.VulnerabilityBlob{ + ID: "CVE-2014-0139", + Description: "curl wildcard certificate validation issue", + References: []db.Reference{ + {URL: "https://www.cve.org/CVERecord?id=CVE-2014-0139"}, + }, + Severities: []db.Severity{ + {Scheme: db.SeveritySchemeCHMLN, Value: "low", Rank: 1}, + }, + }, + }, + Related: affectedPkgSlice( + db.AffectedPackageHandle{ + OperatingSystem: rapidfortRedHat9OS, + Package: &db.Package{Ecosystem: "rpm", Name: "curl"}, + BlobValue: &db.PackageBlob{ + Qualifiers: &db.PackageQualifiers{RpmModularity: strRef("")}, + Ranges: []db.Range{ + { + Version: db.Version{ + Type: "rpm", + Constraint: ">= 0", + }, + Fix: &db.Fix{ + Version: "", + State: db.NotFixedStatus, + Detail: &db.FixDetail{ + References: []db.Reference{ + { + ID: "release-identifier:el9", + URL: "https://access.redhat.com/errata/RHSA-TEST-EL9", + Tags: []string{db.AdvisoryReferenceTag}, + }, + { + ID: "RHSA-TEST-EL9", + URL: "https://access.redhat.com/errata/RHSA-TEST-EL9", + Tags: []string{db.AdvisoryReferenceTag}, + }, + }, + }, + }, + }, + }, + }, + }, + db.AffectedPackageHandle{ + OperatingSystem: rapidfortRedHat9OS, + Package: &db.Package{Ecosystem: "rpm", Name: "curl"}, + BlobValue: &db.PackageBlob{ + Qualifiers: &db.PackageQualifiers{RpmModularity: strRef("")}, + Ranges: []db.Range{ + { + Version: db.Version{ + Type: "rpm", + Constraint: ">= 0, < 7.78.0-4.fc36", + }, + Fix: &db.Fix{ + Version: "7.78.0-4.fc36", + State: db.FixedStatus, + Detail: &db.FixDetail{ + Available: &db.FixAvailability{ + Date: timeRef(time.Date(2024, 1, 1, 0, 0, 0, 0, time.UTC)), + Kind: "advisory", + }, + References: []db.Reference{ + { + ID: "release-identifier:fc36", + URL: "release-identifier:fc36", + Tags: []string{db.AdvisoryReferenceTag}, + }, + }, + }, + }, + }, + }, + }, + }, + ), + }, + }, + }, { name: "testdata/fedora-39.json", provider: "fedora", @@ -1506,6 +1659,33 @@ func TestGetOSInfo(t *testing.T) { channel: "eus", }, }, + { + name: "rapidfort ubuntu 20.04 (provider-curated, 2-part namespace)", + group: "rapidfort-ubuntu:20.04", + expected: osInfo{ + name: "rapidfort-ubuntu", + id: "rapidfort-ubuntu", + version: "20.04", + }, + }, + { + name: "rapidfort alpine 3.20 (provider-curated, 2-part namespace)", + group: "rapidfort-alpine:3.20", + expected: osInfo{ + name: "rapidfort-alpine", + id: "rapidfort-alpine", + version: "3.20", + }, + }, + { + name: "rapidfort redhat 9 (provider-curated, 2-part namespace)", + group: "rapidfort-redhat:9", + expected: osInfo{ + name: "rapidfort-redhat", + id: "rapidfort-redhat", + version: "9", + }, + }, } for _, tt := range tests { diff --git a/grype/distro/distro_test.go b/grype/distro/distro_test.go index f7ed8373c77..8f8d8f45528 100644 --- a/grype/distro/distro_test.go +++ b/grype/distro/distro_test.go @@ -342,6 +342,9 @@ func Test_NewDistroFromRelease_Coverage(t *testing.T) { // Somewhat cheating with Windows. There is no support for detecting/parsing a Windows OS, so it is not // possible to comply with this test unless it is added manually to the "observed distros" definedDistros.Remove(string(Windows)) + definedDistros.Remove(string(RapidFortUbuntu)) + definedDistros.Remove(string(RapidFortAlpine)) + definedDistros.Remove(string(RapidFortRedHat)) tests := []struct { Name string diff --git a/grype/distro/type.go b/grype/distro/type.go index efac2c7900d..5acdcaffe1f 100644 --- a/grype/distro/type.go +++ b/grype/distro/type.go @@ -10,34 +10,37 @@ type Type string const ( // represents the set of supported Linux Distributions - Debian Type = "debian" - Ubuntu Type = "ubuntu" - RedHat Type = "redhat" - CentOS Type = "centos" - Fedora Type = "fedora" - Alpine Type = "alpine" - Busybox Type = "busybox" - AmazonLinux Type = "amazonlinux" - OracleLinux Type = "oraclelinux" - ArchLinux Type = "archlinux" - OpenSuseLeap Type = "opensuseleap" - SLES Type = "sles" - Photon Type = "photon" - Echo Type = "echo" - Windows Type = "windows" - Mariner Type = "mariner" - Azure Type = "azurelinux" - RockyLinux Type = "rockylinux" - AlmaLinux Type = "almalinux" - Gentoo Type = "gentoo" - Wolfi Type = "wolfi" - Chainguard Type = "chainguard" - MinimOS Type = "minimos" - Raspbian Type = "raspbian" - Scientific Type = "scientific" - SecureOS Type = "secureos" - PostmarketOS Type = "postmarketos" - Hummingbird Type = "hummingbird" + Debian Type = "debian" + Ubuntu Type = "ubuntu" + RedHat Type = "redhat" + CentOS Type = "centos" + Fedora Type = "fedora" + Alpine Type = "alpine" + Busybox Type = "busybox" + AmazonLinux Type = "amazonlinux" + OracleLinux Type = "oraclelinux" + ArchLinux Type = "archlinux" + OpenSuseLeap Type = "opensuseleap" + SLES Type = "sles" + Photon Type = "photon" + Echo Type = "echo" + Windows Type = "windows" + Mariner Type = "mariner" + Azure Type = "azurelinux" + RockyLinux Type = "rockylinux" + AlmaLinux Type = "almalinux" + Gentoo Type = "gentoo" + Wolfi Type = "wolfi" + Chainguard Type = "chainguard" + MinimOS Type = "minimos" + Raspbian Type = "raspbian" + Scientific Type = "scientific" + SecureOS Type = "secureos" + PostmarketOS Type = "postmarketos" + Hummingbird Type = "hummingbird" + RapidFortUbuntu Type = "rapidfort-ubuntu" + RapidFortAlpine Type = "rapidfort-alpine" + RapidFortRedHat Type = "rapidfort-redhat" ) // All contains all Linux distribution options @@ -70,37 +73,43 @@ var All = []Type{ SecureOS, PostmarketOS, Hummingbird, + RapidFortUbuntu, + RapidFortAlpine, + RapidFortRedHat, } // IDMapping maps a distro ID from the /etc/os-release (e.g. like "ubuntu") to a Distro type. var IDMapping = map[string]Type{ - "debian": Debian, - "ubuntu": Ubuntu, - "rhel": RedHat, - "centos": CentOS, - "fedora": Fedora, - "alpine": Alpine, - "busybox": Busybox, - "amzn": AmazonLinux, - "ol": OracleLinux, - "arch": ArchLinux, - "opensuse-leap": OpenSuseLeap, - "sles": SLES, - "photon": Photon, - "echo": Echo, - "mariner": Mariner, - "azurelinux": Azure, - "rocky": RockyLinux, - "almalinux": AlmaLinux, - "gentoo": Gentoo, - "wolfi": Wolfi, - "chainguard": Chainguard, - "minimos": MinimOS, - "raspbian": Raspbian, - "scientific": Scientific, - "secureos": SecureOS, - "postmarketos": PostmarketOS, - "hummingbird": Hummingbird, + "debian": Debian, + "ubuntu": Ubuntu, + "rhel": RedHat, + "centos": CentOS, + "fedora": Fedora, + "alpine": Alpine, + "busybox": Busybox, + "amzn": AmazonLinux, + "ol": OracleLinux, + "arch": ArchLinux, + "opensuse-leap": OpenSuseLeap, + "sles": SLES, + "photon": Photon, + "echo": Echo, + "mariner": Mariner, + "azurelinux": Azure, + "rocky": RockyLinux, + "almalinux": AlmaLinux, + "gentoo": Gentoo, + "wolfi": Wolfi, + "chainguard": Chainguard, + "minimos": MinimOS, + "raspbian": Raspbian, + "scientific": Scientific, + "secureos": SecureOS, + "postmarketos": PostmarketOS, + "hummingbird": Hummingbird, + "rapidfort-ubuntu": RapidFortUbuntu, + "rapidfort-alpine": RapidFortAlpine, + "rapidfort-redhat": RapidFortRedHat, } // aliasTypes maps common aliases to their corresponding Type. diff --git a/grype/match/matcher_type.go b/grype/match/matcher_type.go index 58a864918a6..a8948356500 100644 --- a/grype/match/matcher_type.go +++ b/grype/match/matcher_type.go @@ -20,6 +20,7 @@ const ( BitnamiMatcher MatcherType = "bitnami-matcher" PacmanMatcher MatcherType = "pacman-matcher" HexMatcher MatcherType = "hex-matcher" + RapidFortMatcher MatcherType = "rapidfort-matcher" ) var AllMatcherTypes = []MatcherType{ diff --git a/grype/matcher/matchers.go b/grype/matcher/matchers.go index 7ca44d65804..ce09744dce5 100644 --- a/grype/matcher/matchers.go +++ b/grype/matcher/matchers.go @@ -14,10 +14,12 @@ import ( "github.com/anchore/grype/grype/matcher/pacman" "github.com/anchore/grype/grype/matcher/portage" "github.com/anchore/grype/grype/matcher/python" + "github.com/anchore/grype/grype/matcher/rapidfort" "github.com/anchore/grype/grype/matcher/rpm" "github.com/anchore/grype/grype/matcher/ruby" "github.com/anchore/grype/grype/matcher/rust" "github.com/anchore/grype/grype/matcher/stock" + "github.com/anchore/grype/grype/pkg" ) // Config contains values used by individual matcher structs for advanced configuration @@ -55,3 +57,44 @@ func NewDefaultMatchers(mc Config) []match.Matcher { &pacman.Matcher{}, } } + +func ApplySelectionPolicy(matchers []match.Matcher, ctx pkg.Context) []match.Matcher { + if !rapidfort.IsRapidFortImage(ctx.Source) { + return matchers + } + + rfMatcher := rapidfort.NewMatcher() + replacements := map[match.MatcherType]match.Matcher{ + match.DpkgMatcher: rfMatcher, + match.ApkMatcher: rfMatcher, + match.RpmMatcher: rfMatcher, + } + + return applyMatcherOverrides(matchers, replacements) +} + +func applyMatcherOverrides(matchers []match.Matcher, replacements map[match.MatcherType]match.Matcher) []match.Matcher { + if len(replacements) == 0 { + return matchers + } + + var out []match.Matcher + added := make(map[match.MatcherType]struct{}) + + for _, m := range matchers { + replacement, ok := replacements[m.Type()] + if !ok { + out = append(out, m) + continue + } + + if _, exists := added[replacement.Type()]; exists { + continue + } + + out = append(out, replacement) + added[replacement.Type()] = struct{}{} + } + + return out +} diff --git a/grype/matcher/matchers_test.go b/grype/matcher/matchers_test.go new file mode 100644 index 00000000000..9792a5c7d80 --- /dev/null +++ b/grype/matcher/matchers_test.go @@ -0,0 +1,62 @@ +package matcher + +import ( + "testing" + + "github.com/stretchr/testify/assert" + + "github.com/anchore/grype/grype/match" + "github.com/anchore/grype/grype/pkg" + "github.com/anchore/syft/syft/source" +) + +func TestApplySelectionPolicy_UsesDefaultOSMatchersForNonRapidFortSources(t *testing.T) { + matchers := ApplySelectionPolicy(NewDefaultMatchers(Config{}), pkg.Context{}) + + assert.Contains(t, matcherTypes(matchers), match.DpkgMatcher) + assert.Contains(t, matcherTypes(matchers), match.ApkMatcher) + assert.NotContains(t, matcherTypes(matchers), match.RapidFortMatcher) +} + +func TestApplySelectionPolicy_UsesDefaultOSMatchersForNonRapidFortImage(t *testing.T) { + matchers := ApplySelectionPolicy(NewDefaultMatchers(Config{}), pkg.Context{ + Source: &source.Description{ + Metadata: source.ImageMetadata{ + Labels: map[string]string{ + "maintainer": "Other Vendor ", + }, + }, + }, + }) + + assert.Contains(t, matcherTypes(matchers), match.DpkgMatcher) + assert.Contains(t, matcherTypes(matchers), match.ApkMatcher) + assert.NotContains(t, matcherTypes(matchers), match.RapidFortMatcher) +} + +func TestApplySelectionPolicy_UsesRapidFortMatcherInsteadOfDebAndApkMatchers(t *testing.T) { + matchers := ApplySelectionPolicy(NewDefaultMatchers(Config{}), pkg.Context{ + Source: &source.Description{ + Metadata: source.ImageMetadata{ + Labels: map[string]string{ + "maintainer": "RapidFort Curation Team ", + }, + }, + }, + }) + + assert.Contains(t, matcherTypes(matchers), match.RapidFortMatcher) + assert.NotContains(t, matcherTypes(matchers), match.DpkgMatcher) + assert.NotContains(t, matcherTypes(matchers), match.ApkMatcher) + assert.NotContains(t, matcherTypes(matchers), match.RpmMatcher) + assert.Contains(t, matcherTypes(matchers), match.JavaMatcher) + assert.Contains(t, matcherTypes(matchers), match.StockMatcher) +} + +func matcherTypes(matchers []match.Matcher) []match.MatcherType { + out := make([]match.MatcherType, 0, len(matchers)) + for _, m := range matchers { + out = append(out, m.Type()) + } + return out +} diff --git a/grype/matcher/rapidfort/label.go b/grype/matcher/rapidfort/label.go new file mode 100644 index 00000000000..2e57519580a --- /dev/null +++ b/grype/matcher/rapidfort/label.go @@ -0,0 +1,32 @@ +package rapidfort + +import ( + "strings" + + "github.com/anchore/syft/syft/source" +) + +const maintainerLabel = "maintainer" + +// rapidFortMaintainerPrefix is the case-insensitive prefix used by all RapidFort +// maintainer label values (e.g. "RapidFort Curation Team "). +const rapidFortMaintainerPrefix = "rapidfort" + +// IsRapidFortImage returns true when the source is a container image whose +// "maintainer" label starts with "RapidFort" (case-insensitive) — the indicator +// that it is a RapidFort-curated image and should be scanned against RF advisories. +func IsRapidFortImage(src *source.Description) bool { + if src == nil { + return false + } + meta, ok := src.Metadata.(source.ImageMetadata) + if !ok { + return false + } + for k, v := range meta.Labels { + if strings.EqualFold(k, maintainerLabel) && strings.HasPrefix(strings.ToLower(v), rapidFortMaintainerPrefix) { + return true + } + } + return false +} diff --git a/grype/matcher/rapidfort/label_test.go b/grype/matcher/rapidfort/label_test.go new file mode 100644 index 00000000000..223d12639c9 --- /dev/null +++ b/grype/matcher/rapidfort/label_test.go @@ -0,0 +1,89 @@ +package rapidfort + +import ( + "testing" + + "github.com/stretchr/testify/assert" + + "github.com/anchore/syft/syft/source" +) + +func TestIsRapidFortImage(t *testing.T) { + tests := []struct { + name string + src *source.Description + expected bool + }{ + { + name: "nil source returns false", + src: nil, + expected: false, + }, + { + name: "non-image source (no ImageMetadata) returns false", + src: &source.Description{ + Metadata: source.DirectoryMetadata{}, + }, + expected: false, + }, + { + name: "image with rapidfort maintainer label matches", + src: &source.Description{ + Metadata: source.ImageMetadata{ + Labels: map[string]string{ + "maintainer": "RapidFort Curation Team ", + }, + }, + }, + expected: true, + }, + { + name: "label key is case-insensitive", + src: &source.Description{ + Metadata: source.ImageMetadata{ + Labels: map[string]string{ + "Maintainer": "RapidFort Curation Team ", + }, + }, + }, + expected: true, + }, + { + name: "label value prefix is case-insensitive", + src: &source.Description{ + Metadata: source.ImageMetadata{ + Labels: map[string]string{ + "maintainer": "RAPIDFORT Automation", + }, + }, + }, + expected: true, + }, + { + name: "non-rapidfort maintainer label does not match", + src: &source.Description{ + Metadata: source.ImageMetadata{ + Labels: map[string]string{ + "maintainer": "Other Vendor ", + }, + }, + }, + expected: false, + }, + { + name: "image with no labels returns false", + src: &source.Description{ + Metadata: source.ImageMetadata{ + Labels: map[string]string{}, + }, + }, + expected: false, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + assert.Equal(t, test.expected, IsRapidFortImage(test.src)) + }) + } +} diff --git a/grype/matcher/rapidfort/matcher.go b/grype/matcher/rapidfort/matcher.go new file mode 100644 index 00000000000..ab98ae69f32 --- /dev/null +++ b/grype/matcher/rapidfort/matcher.go @@ -0,0 +1,371 @@ +// Package rapidfort provides vulnerability matching for RapidFort-curated images. +// It is activated only when the scanned image is identified as RapidFort-curated +// (via the "maintainer=RapidFort" Docker label). It queries the DB for advisories +// stored under the "rapidfort-" OS name (e.g. "rapidfort-ubuntu"), +// which are never returned for standard distro scans. +package rapidfort + +import ( + "fmt" + "regexp" + "strings" + + "github.com/anchore/grype/grype/distro" + "github.com/anchore/grype/grype/match" + "github.com/anchore/grype/grype/matcher/internal" + "github.com/anchore/grype/grype/pkg" + "github.com/anchore/grype/grype/search" + "github.com/anchore/grype/grype/version" + "github.com/anchore/grype/grype/vulnerability" + "github.com/anchore/grype/internal/log" + syftPkg "github.com/anchore/syft/syft/pkg" +) + +// rfDistroMap maps a detected base OS type to its RF-prefixed counterpart in the DB. +// Extend this (plus getPackageType in v6 transform.go) when RF curates a new base distro. +var rfDistroMap = map[distro.Type]distro.Type{ + distro.Ubuntu: distro.RapidFortUbuntu, + distro.Alpine: distro.RapidFortAlpine, + distro.RedHat: distro.RapidFortRedHat, +} + +var ( + fedoraReleasePattern = regexp.MustCompile(`\.fc\d+(?:[._-].*)?$`) + rhelReleasePattern = regexp.MustCompile(`\.el(\d+)`) + rfReleasePattern = regexp.MustCompile(`\.rf(?:[._-].*)?$`) +) + +const ( + reasonNoReleaseIdentifier = "no release identifier on vulnerability" + reasonCannotDeriveReleaseID = "unable to derive rpm release identifier and no el release identifier on vulnerability" + reasonReleaseIdentifierMismatch = "package release identifier did not match vulnerability" +) + +// Matcher matches packages against RapidFort-specific advisories. +// It is only instantiated when the image under scan is detected as RF-curated. +type Matcher struct{} + +func NewMatcher() *Matcher { + return &Matcher{} +} + +func (m *Matcher) PackageTypes() []syftPkg.Type { + return []syftPkg.Type{syftPkg.DebPkg, syftPkg.ApkPkg, syftPkg.RpmPkg} +} + +func (m *Matcher) Type() match.MatcherType { + return match.RapidFortMatcher +} + +func (m *Matcher) Match(store vulnerability.Provider, p pkg.Package) ([]match.Match, []match.IgnoreFilter, error) { + if p.Distro == nil { + return nil, nil, nil + } + + // Only match against supported RF-curated base distros (ubuntu/alpine/redhat). + rfDistroType, ok := rfDistroMap[p.Distro.Type] + if !ok { + return nil, nil, nil + } + + // Build the RF-prefixed distro for DB queries. + // Intentionally omit the codename (e.g. "jammy") — the RF DB stores no codenames, + // and passing one would add a codename filter that yields 0 results. + rfDistro := distro.New(rfDistroType, rapidfortDistroVersion(*p.Distro, rfDistroType), "") + log.WithFields( + "package", p.Name, + "version", p.Version, + "sourceDistro", p.Distro.String(), + "rapidfortDistro", rfDistro.String(), + ).Debug("rapidfort matcher: remapped distro for RapidFort query") + + var all []match.Match + + // 1. Match by source/upstream package names first, when syft populated Upstreams (same idea as + // dpkg/rpm matchers): e.g. Debian binary → source, or RPM subpackage → source name from + // SourceRPM metadata. If Upstreams is empty, this step is a no-op. + sourceMatches, err := m.matchUpstreamPackages(store, p, rfDistro) + if err != nil { + return nil, nil, fmt.Errorf("rapidfort matcher failed upstream lookup for %q: %w", p.Name, err) + } + all = append(all, sourceMatches...) + + // 2. Direct lookup by installed package name (covers RF-prefixed binaries and advisories + // keyed on the subpackage/binary name rather than the source). + rfPkg := p + rfPkg.Distro = rfDistro + binaryMatches, err := m.matchPackageByDistro(store, rfPkg, &p, match.ExactDirectMatch) + if err != nil { + return nil, nil, fmt.Errorf("rapidfort matcher failed binary lookup for %q: %w", p.Name, err) + } + all = append(all, binaryMatches...) + + // 3. Post-filter: suppress any match where the installed version is already at or + // beyond the recorded fix version. This is an explicit safety net — well-formed + // RF advisories use strict "< fixVersion" constraints so the DB query already + // excludes fixed packages, but this guards against edge cases (e.g. advisory + // has only an "introduced" event and was later patched without updating the DB). + // + // Priority: fix-version check first, range constraint second. + format := pkg.VersionFormat(p) + filtered := filterAlreadyFixed(all, p.Version, format) + + // 4. Dedup: upstream and direct lookups can surface the same CVE for the same package. + return dedupMatches(filtered), nil, nil +} + +func rapidfortDistroVersion(baseDistro distro.Distro, rfDistroType distro.Type) string { + if rfDistroType == distro.RapidFortRedHat && baseDistro.MajorVersion() != "" { + return baseDistro.MajorVersion() + } + return baseDistro.Version +} + +// matchUpstreamPackages searches the RF DB using pkg.UpstreamPackages(p) — names from syft’s +// Upstreams field (Debian source packages, RPM source names derived from SourceRPM, etc.). +// Works for DebPkg, ApkPkg, and RpmPkg whenever the catalog includes upstream metadata. +func (m *Matcher) matchUpstreamPackages(store vulnerability.Provider, p pkg.Package, rfDistro *distro.Distro) ([]match.Match, error) { + var matches []match.Match + + for _, upstream := range pkg.UpstreamPackages(p) { + upstream.Distro = rfDistro + found, err := m.matchPackageByDistro(store, upstream, &p, match.ExactIndirectMatch) + if err != nil { + return nil, fmt.Errorf("failed RF upstream lookup for source %q: %w", upstream.Name, err) + } + matches = append(matches, found...) + } + + // Mark all upstream-sourced matches as indirect (the artifact is the binary pkg). + match.ConvertToIndirectMatches(matches, p) + return matches, nil +} + +func (m *Matcher) matchPackageByDistro(store vulnerability.Provider, searchPkg pkg.Package, catalogPkg *pkg.Package, ty match.Type) ([]match.Match, error) { + if searchPkg.Distro == nil { + return nil, nil + } + + pkgVersion := version.New(searchPkg.Version, pkg.VersionFormat(searchPkg)) + criteria := []vulnerability.Criteria{ + search.ByPackageName(searchPkg.Name), + search.ByDistro(*searchPkg.Distro), + internal.OnlyQualifiedPackages(searchPkg), + internal.OnlyVulnerableVersions(pkgVersion), + } + + if searchPkg.Distro.Type == distro.RapidFortRedHat { + criteria = append(criteria, byRPMReleaseIdentifier(searchPkg)) + } + + vulns, err := store.FindVulnerabilities(criteria...) + if err != nil { + return nil, fmt.Errorf("matcher failed to fetch distro=%q pkg=%q: %w", searchPkg.Distro, searchPkg.Name, err) + } + + log.WithFields( + "package", searchPkg.Name, + "version", searchPkg.Version, + "distro", searchPkg.Distro.String(), + "matches", len(vulns), + ).Debug("rapidfort matcher: fetched vulnerabilities for package") + + var matches []match.Match + for _, vuln := range vulns { + matches = append(matches, match.Match{ + Vulnerability: vuln, + Package: matchPackage(searchPkg, catalogPkg), + Details: distroMatchDetails(m.Type(), ty, searchPkg, catalogPkg, vuln), + }) + } + + return matches, nil +} + +func byRPMReleaseIdentifier(p pkg.Package) vulnerability.Criteria { + return search.ByFunc(func(vuln vulnerability.Vulnerability) (bool, string, error) { + expected := installedReleaseIdentifier(p) + if expected == "" { + for _, advisory := range vuln.Advisories { + id := advisoryID(advisory) + if strings.HasPrefix(id, "release-identifier:el") { + return true, "", nil + } + } + return false, reasonCannotDeriveReleaseID, nil + } + + found := false + for _, advisory := range vuln.Advisories { + id := advisoryID(advisory) + if !strings.HasPrefix(id, "release-identifier:") { + continue + } + found = true + if strings.TrimPrefix(id, "release-identifier:") == expected { + return true, "", nil + } + } + + if !found { + return false, reasonNoReleaseIdentifier, nil + } + + return false, reasonReleaseIdentifierMismatch, nil + }) +} + +func advisoryID(advisory vulnerability.Advisory) string { + return strings.ToLower(strings.TrimSpace(advisory.ID)) +} + +func installedReleaseIdentifier(p pkg.Package) string { + version := strings.ToLower(strings.TrimSpace(p.Version)) + + if id := fedoraReleaseID(p, version); id != "" { + return id + } + if id := rfReleaseID(p, version); id != "" { + return id + } + if id := rhelReleaseID(p, version); id != "" { + return id + } + if id := rfNameReleaseID(p); id != "" { + return id + } + + return "" +} + +func fedoraReleaseID(p pkg.Package, version string) string { + if !fedoraReleasePattern.MatchString(version) { + return "" + } + + idx := strings.LastIndex(version, ".fc") + if idx < 0 { + return "" + } + + id := version[idx+1:] + return id +} + +func rfReleaseID(p pkg.Package, version string) string { + if !rfReleasePattern.MatchString(version) { + return "" + } + + return "rf" +} + +func rhelReleaseID(p pkg.Package, version string) string { + if !rhelReleasePattern.MatchString(version) { + return "" + } + + match := rhelReleasePattern.FindStringSubmatch(version) + if len(match) != 2 { + return "" + } + id := "el" + match[1] + return id +} + +func rfNameReleaseID(p pkg.Package) string { + if !strings.HasPrefix(strings.ToLower(p.Name), "rf-") { + return "" + } + return "rf" +} + +func matchPackage(searchPkg pkg.Package, catalogPkg *pkg.Package) pkg.Package { + if catalogPkg != nil { + return *catalogPkg + } + return searchPkg +} + +func distroMatchDetails(upstreamMatcher match.MatcherType, ty match.Type, searchPkg pkg.Package, catalogPkg *pkg.Package, vuln vulnerability.Vulnerability) []match.Detail { + return []match.Detail{ + { + Type: ty, + Matcher: upstreamMatcher, + SearchedBy: match.DistroParameters{ + Distro: match.DistroIdentification{ + Type: searchPkg.Distro.Type.String(), + Version: searchPkg.Distro.Version, + }, + Package: match.PackageParameter{ + Name: searchPkg.Name, + Version: searchPkg.Version, + }, + Namespace: vuln.Namespace, + }, + Found: match.DistroResult{ + VulnerabilityID: vuln.ID, + VersionConstraint: vuln.Constraint.String(), + }, + Confidence: 1.0, + }, + } +} + +// filterAlreadyFixed removes matches where the installed version exactly equals +// a recorded fix version, meaning the package has been patched. +// +// Logic (in priority order): +// 1. If Fix.State == FixStateFixed AND installedVersion == fixVersion → suppress. +// 2. Otherwise the range constraint (already evaluated by FindVulnerabilities) decides. +func filterAlreadyFixed(matches []match.Match, installedVer string, format version.Format) []match.Match { + installed := version.New(installedVer, format) + var out []match.Match + for _, m := range matches { + fix := m.Vulnerability.Fix + if fix.State == vulnerability.FixStateFixed { + suppressed := false + for _, fixVer := range fix.Versions { + if fixVer == "" || fixVer == "None" { + continue + } + // cmp = fixVersion.Compare(installed): 0 means installed == fixVersion + cmp, err := version.New(fixVer, format).Compare(installed) + if err != nil { + log.WithFields("pkg", installedVer, "fixVersion", fixVer, "err", err). + Trace("rapidfort: could not compare fix version, keeping match") + continue + } + if cmp == 0 { + // installed version equals the fix — package is patched + log.WithFields("vuln", m.Vulnerability.ID, "pkg", installedVer, "fixVersion", fixVer). + Trace("rapidfort: suppressing match — installed version == fix version") + suppressed = true + break + } + } + if suppressed { + vulnerability.LogDropped(m.Vulnerability.ID, "rapidfort-matcher", + "installed version equals fix version", installedVer) + continue + } + } + out = append(out, m) + } + return out +} + +// dedupMatches removes duplicate matches with the same vulnerability ID. +// Source and binary package lookups can surface the same CVE for the same package. +func dedupMatches(matches []match.Match) []match.Match { + seen := make(map[string]struct{}, len(matches)) + out := make([]match.Match, 0, len(matches)) + for _, m := range matches { + if _, ok := seen[m.Vulnerability.ID]; ok { + continue + } + seen[m.Vulnerability.ID] = struct{}{} + out = append(out, m) + } + return out +} diff --git a/grype/matcher/rapidfort/matcher_test.go b/grype/matcher/rapidfort/matcher_test.go new file mode 100644 index 00000000000..72b2167ca2b --- /dev/null +++ b/grype/matcher/rapidfort/matcher_test.go @@ -0,0 +1,172 @@ +package rapidfort + +import ( + "testing" + + "github.com/stretchr/testify/assert" + + "github.com/anchore/grype/grype/distro" + "github.com/anchore/grype/grype/match" + "github.com/anchore/grype/grype/pkg" + "github.com/anchore/grype/grype/version" + "github.com/anchore/grype/grype/vulnerability" +) + +func TestInstalledReleaseIdentifier(t *testing.T) { + tests := []struct { + name string + pkg pkg.Package + expected string + }{ + { + name: "fedora release", + pkg: pkg.Package{Name: "curl", Version: "8.6.0-7.fc41"}, + expected: "fc41", + }, + { + name: "rapidfort release from version suffix", + pkg: pkg.Package{Name: "curl", Version: "3.6.1-11.rf"}, + expected: "rf", + }, + { + name: "rhel release with minor suffix", + pkg: pkg.Package{Name: "curl", Version: "7.88.1-5.el9_4.1"}, + expected: "el9", + }, + { + name: "rf package fallback", + pkg: pkg.Package{Name: "rf-curl", Version: "1.2.3-1"}, + expected: "rf", + }, + { + name: "unknown release", + pkg: pkg.Package{Name: "curl", Version: "1.2.3-1"}, + expected: "", + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + assert.Equal(t, test.expected, installedReleaseIdentifier(test.pkg)) + }) + } +} + +func TestByRPMReleaseIdentifier(t *testing.T) { + criteria := byRPMReleaseIdentifier(pkg.Package{ + Name: "curl", + Version: "8.6.0-7.fc41", + Distro: distro.New(distro.RapidFortRedHat, "9", ""), + }) + + matched, reason, err := criteria.MatchesVulnerability(vulnerability.Vulnerability{ + Advisories: []vulnerability.Advisory{ + {ID: "release-identifier:fc41"}, + }, + }) + assert.NoError(t, err) + assert.True(t, matched) + assert.Empty(t, reason) + + matched, reason, err = criteria.MatchesVulnerability(vulnerability.Vulnerability{ + Advisories: []vulnerability.Advisory{ + {ID: "release-identifier:el9"}, + }, + }) + assert.NoError(t, err) + assert.False(t, matched) + assert.Equal(t, reasonReleaseIdentifierMismatch, reason) +} + +func TestByRPMReleaseIdentifier_FallsBackToELWhenInstalledIdentifierUnknown(t *testing.T) { + criteria := byRPMReleaseIdentifier(pkg.Package{ + Name: "curl", + Version: "1.2.3-1", + Distro: distro.New(distro.RapidFortRedHat, "9", ""), + }) + + matched, reason, err := criteria.MatchesVulnerability(vulnerability.Vulnerability{ + Advisories: []vulnerability.Advisory{ + {ID: "release-identifier:el9"}, + }, + }) + assert.NoError(t, err) + assert.True(t, matched) + assert.Empty(t, reason) + + matched, reason, err = criteria.MatchesVulnerability(vulnerability.Vulnerability{ + Advisories: []vulnerability.Advisory{ + {ID: "release-identifier:fc41"}, + }, + }) + assert.NoError(t, err) + assert.False(t, matched) + assert.Contains(t, reason, "no el release identifier") +} + +func TestRapidfortDistroVersion(t *testing.T) { + tests := []struct { + name string + baseDistro distro.Distro + rfDistro distro.Type + expectedVer string + }{ + { + name: "rapidfort redhat uses major version only", + baseDistro: *distro.New(distro.RedHat, "9.5", ""), + rfDistro: distro.RapidFortRedHat, + expectedVer: "9", + }, + { + name: "rapidfort redhat keeps major only when already major", + baseDistro: *distro.New(distro.RedHat, "9", ""), + rfDistro: distro.RapidFortRedHat, + expectedVer: "9", + }, + { + name: "rapidfort ubuntu keeps full version", + baseDistro: *distro.New(distro.Ubuntu, "20.04", ""), + rfDistro: distro.RapidFortUbuntu, + expectedVer: "20.04", + }, + { + name: "rapidfort alpine keeps full version", + baseDistro: *distro.New(distro.Alpine, "3.15", ""), + rfDistro: distro.RapidFortAlpine, + expectedVer: "3.15", + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + assert.Equal(t, test.expectedVer, rapidfortDistroVersion(test.baseDistro, test.rfDistro)) + }) + } +} + +func TestFilterAlreadyFixed_KeepsVulnBeforeFixVersionAcrossReleases(t *testing.T) { + installed := "1.7.2-1.fc41" + format := version.RpmFormat + + matches := []match.Match{ + { + Vulnerability: vulnerability.Vulnerability{ + Reference: vulnerability.Reference{ + ID: "CVE-TEST-FC-CROSS-RELEASE", + }, + Fix: vulnerability.Fix{ + State: vulnerability.FixStateFixed, + Versions: []string{"1.7.5-1.fc45"}, + }, + }, + }, + } + + filtered := filterAlreadyFixed(matches, installed, format) + + // Installed version is before the fixed version in a later Fedora release, + // so the match must be kept and fix metadata preserved for reporting. + if assert.Len(t, filtered, 1) { + assert.Equal(t, matches[0].Vulnerability.Fix, filtered[0].Vulnerability.Fix) + } +} From 130fdb2bd18c341d4792f4d8c4b3dd07c3bca59e Mon Sep 17 00:00:00 2001 From: Vaibhav Thatai Date: Mon, 6 Jul 2026 17:36:19 +0530 Subject: [PATCH 4/5] fix(rapidfort): remove duplicate groupIndex fields left by rebase Signed-off-by: Vaibhav Thatai --- grype/db/v6/build/transformers/os/transform.go | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/grype/db/v6/build/transformers/os/transform.go b/grype/db/v6/build/transformers/os/transform.go index 0b8c42c3b2f..48023c9aa57 100644 --- a/grype/db/v6/build/transformers/os/transform.go +++ b/grype/db/v6/build/transformers/os/transform.go @@ -269,8 +269,6 @@ func deriveConstraintFromFix(fixVersion, vulnerabilityID string) string { } type groupIndex struct { - module string - format string name string id string osName string @@ -297,8 +295,6 @@ func groupFixedIns(vuln unmarshal.OSVulnerability) map[groupIndex][]unmarshal.OS arch = *fixedIn.Arch } g := groupIndex{ - // arch splits a per-arch fix into its own affected package handle so the architecture - // qualifier can scope it; empty means the fix applies to all arches. name: fixedIn.Name, id: oi.id, osName: oi.name, @@ -308,7 +304,9 @@ func groupFixedIns(vuln unmarshal.OSVulnerability) map[groupIndex][]unmarshal.OS hasModule: fixedIn.Module != nil, module: mod, format: fixedIn.VersionFormat, - arch: arch, + // arch splits a per-arch fix into its own affected package handle so the architecture + // qualifier can scope it; empty means the fix applies to all arches. + arch: arch, } grouped[g] = append(grouped[g], fixedIn) From a64ba52f6ea500c143d9ddb4c1ce455af7024d55 Mon Sep 17 00:00:00 2001 From: Janit Lodha Date: Wed, 8 Jul 2026 15:26:12 +0530 Subject: [PATCH 5/5] Adding GoReleaser Changes + Updating DB URL --- .goreleaser.yaml | 421 ++++++++++++----------------- grype/db/v6/distribution/client.go | 2 +- 2 files changed, 176 insertions(+), 247 deletions(-) diff --git a/.goreleaser.yaml b/.goreleaser.yaml index aca74945112..f401e400e8f 100644 --- a/.goreleaser.yaml +++ b/.goreleaser.yaml @@ -1,11 +1,13 @@ version: 2 +project_name: grype + release: prerelease: auto draft: false env: - # required to support multi architecture docker builds + # required to support multi-architecture docker builds - DOCKER_CLI_EXPERIMENTAL=enabled - CGO_ENABLED=0 @@ -16,10 +18,18 @@ builds: goos: - linux goarch: + # Full arch matrix matches the RapidFort Trivy fork's release surface — + # covers 386/arm for legacy systems, amd64/arm64 for the majority, and + # s390x/ppc64le for IBM enterprise targets. modernc.org/sqlite (via + # glebarez/sqlite in go.mod) supports all six on linux. + - "386" + - arm - amd64 - arm64 - - ppc64le - s390x + - ppc64le + goarm: + - "7" # set the modified timestamp on the output binary to the git timestamp to ensure a reproducible build mod_timestamp: &build-timestamp '{{ .CommitTimestamp }}' ldflags: &build-ldflags | @@ -31,6 +41,19 @@ builds: -X main.buildDate={{.Date}} -X main.gitDescription={{.Summary}} + - id: freebsd-build + dir: ./cmd/grype + binary: grype + goos: + - freebsd + goarch: + # modernc.org/sqlite doesn't support freebsd/arm64. + # freebsd/386 is no longer supported in modernc.org/libc. + # See https://gitlab.com/cznic/libc/-/issues/45 + - amd64 + mod_timestamp: *build-timestamp + ldflags: *build-ldflags + - id: darwin-build dir: ./cmd/grype binary: grype @@ -43,7 +66,13 @@ builds: ldflags: *build-ldflags hooks: post: - - cmd: .tool/quill sign-and-notarize "{{ .Path }}" --dry-run={{ .IsSnapshot }} --ad-hoc={{ .IsSnapshot }} -vv + # RapidFort fork: hard-code --ad-hoc=true so we never require an Apple + # Developer ID cert (which anchore's QUILL_SIGN_P12 secret would provide + # but we don't have). End-user Macs will show a Gatekeeper warning on + # first run; right-click → Open works around it. For proper signing + # later, buy a Developer ID ($99/yr), store the P12 as QUILL_SIGN_P12 + # in the release workflow, and change --ad-hoc back to {{ .IsSnapshot }}. + - cmd: .tool/quill sign-and-notarize "{{ .Path }}" --dry-run={{ .IsSnapshot }} --ad-hoc=true -vv env: - QUILL_LOG_FILE=/tmp/quill-{{ .Target }}.log @@ -53,308 +82,208 @@ builds: goos: - windows goarch: + # modernc.org/sqlite doesn't support windows/386 or windows/arm. - amd64 mod_timestamp: *build-timestamp ldflags: *build-ldflags archives: - - id: linux-archives - ids: - - linux-build - - - id: darwin-archives - ids: - - darwin-build - - - id: windows-archives - formats: [zip] - ids: - - windows-build + - id: archive + formats: [tar.gz] + # Humanized filenames: `grype_0.115.0-rf.1_Linux-64bit.tar.gz` matches the + # convention used by the RapidFort Trivy fork so users of both scanners see + # the same shape. Original grype convention was `grype_0.115.0-rf.1_linux_amd64.tar.gz`. + name_template: >- + {{ .ProjectName }}_{{ .Version }}_ + {{- if eq .Os "darwin" }}macOS + {{- else if eq .Os "linux" }}Linux + {{- else if eq .Os "freebsd" }}FreeBSD + {{- else if eq .Os "windows" }}Windows + {{- else }}{{ .Os }}{{ end }}- + {{- if eq .Arch "amd64" }}64bit + {{- else if eq .Arch "386" }}32bit + {{- else if eq .Arch "arm" }}ARM + {{- else if eq .Arch "arm64" }}ARM64 + {{- else if eq .Arch "ppc64le" }}PPC64LE + {{- else }}{{ .Arch }}{{ end }} + files: + - README.md + - LICENSE + - templates/*.tmpl + format_overrides: + - goos: windows + formats: [zip] nfpms: - - license: "Apache 2.0" - maintainer: "Anchore, Inc" - homepage: &website "https://github.com/anchore/grype" - description: &description "A vulnerability scanner for container images and filesystems" + - license: "Apache-2.0" + maintainer: "RapidFort" + vendor: "RapidFort" + homepage: "https://github.com/rfJanit/grype" + description: "RapidFort's Grype fork — A vulnerability scanner for container images and filesystems, extended with RapidFort curated advisories." formats: - rpm - deb - -homebrew_casks: - - repository: - owner: anchore - name: homebrew-grype - token: "{{.Env.GITHUB_BREW_TOKEN}}" - ids: - - darwin-archives - - linux-archives - homepage: *website - description: *description - license: "Apache License 2.0" - conflicts: - # claim that the previous grype formula (in the root of homebrew-grype) conflicts with the new cask - # see https://goreleaser.com/deprecations/#brews for more information - - formula: grype - + file_name_template: >- + {{ .ProjectName }}_{{ .Version }}_ + {{- if eq .Os "darwin" }}macOS + {{- else if eq .Os "linux" }}Linux + {{- else if eq .Os "freebsd" }}FreeBSD + {{- else }}{{ title .Os }}{{ end }}- + {{- if eq .Arch "amd64" }}64bit + {{- else if eq .Arch "386" }}32bit + {{- else if eq .Arch "arm" }}ARM + {{- else if eq .Arch "arm64" }}ARM64 + {{- else if eq .Arch "ppc64le" }}PPC64LE + {{- else }}{{ .Arch }}{{ end }} + # Ship the output-format templates so `grype -o template -t ` works + # out of the box for anyone who installed via .deb/.rpm. + contents: + - src: templates/*.tmpl + dst: /usr/local/share/grype/templates + # RPM signing intentionally omitted — no signing key configured for the first release. + +# Per-arch container images pushed only to GHCR under the fork's owner. +# Docker Hub removed for the first release; add back later if a rapidfort/grype +# repo is created with DOCKERHUB_USERNAME/TOKEN secrets in the release workflow. +# +# Only production images ship — -nonroot and -debug variants dropped for first +# release (add back on request if users need PodSecurityPolicy-friendly image +# or in-container shell tools). +# +# Note: grype's Dockerfile has hardcoded LABELs including image.vendor="Anchore, Inc." +# and a generic image.description. The `--label=` flags below override these at build +# time — docker takes the latest label with a matching key. To fix at the source +# instead of overriding, edit the LABEL directives in grype/Dockerfile directly. dockers: - # production images... - image_templates: - - anchore/grype:{{.Tag}}-amd64 - - ghcr.io/anchore/grype:{{.Tag}}-amd64 + - "ghcr.io/rfjanit/grype:{{ .Version }}-amd64" + - "ghcr.io/rfjanit/grype:latest-amd64" + use: buildx + goos: linux goarch: amd64 + ids: + - linux-build dockerfile: Dockerfile - use: buildx build_flag_templates: - "--platform=linux/amd64" - "--provenance=false" + - "--label=org.opencontainers.image.title={{ .ProjectName }}" + - "--label=org.opencontainers.image.description=RapidFort's Grype fork with curated advisories" + - "--label=org.opencontainers.image.vendor=RapidFort" + - "--label=org.opencontainers.image.version={{ .Version }}" + - "--label=org.opencontainers.image.created={{ .Date }}" + - "--label=org.opencontainers.image.source=https://github.com/rfJanit/grype" + - "--label=org.opencontainers.image.revision={{ .FullCommit }}" + - "--label=org.opencontainers.image.url=https://github.com/rfJanit/grype" + # Grype's Dockerfile has ARG BUILD_DATE / BUILD_VERSION / VCS_REF / VCS_URL + # that populate additional LABELs. Missing --build-arg's would leave those + # labels empty; not fatal but visually ugly on `docker inspect`. - "--build-arg=BUILD_DATE={{.Date}}" - "--build-arg=BUILD_VERSION={{.Version}}" - "--build-arg=VCS_REF={{.FullCommit}}" - "--build-arg=VCS_URL={{.GitURL}}" - image_templates: - - anchore/grype:{{.Tag}}-arm64v8 - - ghcr.io/anchore/grype:{{.Tag}}-arm64v8 + - "ghcr.io/rfjanit/grype:{{ .Version }}-arm64" + - "ghcr.io/rfjanit/grype:latest-arm64" + use: buildx + goos: linux goarch: arm64 + ids: + - linux-build dockerfile: Dockerfile - use: buildx build_flag_templates: - - "--platform=linux/arm64/v8" + - "--platform=linux/arm64" - "--provenance=false" + - "--label=org.opencontainers.image.title={{ .ProjectName }}" + - "--label=org.opencontainers.image.description=RapidFort's Grype fork with curated advisories" + - "--label=org.opencontainers.image.vendor=RapidFort" + - "--label=org.opencontainers.image.version={{ .Version }}" + - "--label=org.opencontainers.image.created={{ .Date }}" + - "--label=org.opencontainers.image.source=https://github.com/rfJanit/grype" + - "--label=org.opencontainers.image.revision={{ .FullCommit }}" + - "--label=org.opencontainers.image.url=https://github.com/rfJanit/grype" - "--build-arg=BUILD_DATE={{.Date}}" - "--build-arg=BUILD_VERSION={{.Version}}" - "--build-arg=VCS_REF={{.FullCommit}}" - "--build-arg=VCS_URL={{.GitURL}}" - image_templates: - - anchore/grype:{{.Tag}}-ppc64le - - ghcr.io/anchore/grype:{{.Tag}}-ppc64le - goarch: ppc64le - dockerfile: Dockerfile + - "ghcr.io/rfjanit/grype:{{ .Version }}-s390x" + - "ghcr.io/rfjanit/grype:latest-s390x" use: buildx - build_flag_templates: - - "--platform=linux/ppc64le" - - "--provenance=false" - - "--build-arg=BUILD_DATE={{.Date}}" - - "--build-arg=BUILD_VERSION={{.Version}}" - - "--build-arg=VCS_REF={{.FullCommit}}" - - "--build-arg=VCS_URL={{.GitURL}}" - - - image_templates: - - anchore/grype:{{.Tag}}-s390x - - ghcr.io/anchore/grype:{{.Tag}}-s390x + goos: linux goarch: s390x + ids: + - linux-build dockerfile: Dockerfile - use: buildx build_flag_templates: - "--platform=linux/s390x" - "--provenance=false" - - "--build-arg=BUILD_DATE={{.Date}}" - - "--build-arg=BUILD_VERSION={{.Version}}" - - "--build-arg=VCS_REF={{.FullCommit}}" - - "--build-arg=VCS_URL={{.GitURL}}" - - # nonroot images... - - image_templates: - - anchore/grype:{{.Tag}}-nonroot-amd64 - - ghcr.io/anchore/grype:{{.Tag}}-nonroot-amd64 - goarch: amd64 - dockerfile: Dockerfile.nonroot - use: buildx - build_flag_templates: - - "--platform=linux/amd64" - - "--provenance=false" + - "--label=org.opencontainers.image.title={{ .ProjectName }}" + - "--label=org.opencontainers.image.description=RapidFort's Grype fork with curated advisories" + - "--label=org.opencontainers.image.vendor=RapidFort" + - "--label=org.opencontainers.image.version={{ .Version }}" + - "--label=org.opencontainers.image.created={{ .Date }}" + - "--label=org.opencontainers.image.source=https://github.com/rfJanit/grype" + - "--label=org.opencontainers.image.revision={{ .FullCommit }}" + - "--label=org.opencontainers.image.url=https://github.com/rfJanit/grype" - "--build-arg=BUILD_DATE={{.Date}}" - "--build-arg=BUILD_VERSION={{.Version}}" - "--build-arg=VCS_REF={{.FullCommit}}" - "--build-arg=VCS_URL={{.GitURL}}" - image_templates: - - anchore/grype:{{.Tag}}-nonroot-arm64v8 - - ghcr.io/anchore/grype:{{.Tag}}-nonroot-arm64v8 - goarch: arm64 - dockerfile: Dockerfile.nonroot + - "ghcr.io/rfjanit/grype:{{ .Version }}-ppc64le" + - "ghcr.io/rfjanit/grype:latest-ppc64le" use: buildx - build_flag_templates: - - "--platform=linux/arm64/v8" - - "--provenance=false" - - "--build-arg=BUILD_DATE={{.Date}}" - - "--build-arg=BUILD_VERSION={{.Version}}" - - "--build-arg=VCS_REF={{.FullCommit}}" - - "--build-arg=VCS_URL={{.GitURL}}" - - - image_templates: - - anchore/grype:{{.Tag}}-nonroot-ppc64le - - ghcr.io/anchore/grype:{{.Tag}}-nonroot-ppc64le + goos: linux goarch: ppc64le - dockerfile: Dockerfile.nonroot - use: buildx - build_flag_templates: - - "--platform=linux/ppc64le" - - "--provenance=false" - - "--build-arg=BUILD_DATE={{.Date}}" - - "--build-arg=BUILD_VERSION={{.Version}}" - - "--build-arg=VCS_REF={{.FullCommit}}" - - "--build-arg=VCS_URL={{.GitURL}}" - - - image_templates: - - anchore/grype:{{.Tag}}-nonroot-s390x - - ghcr.io/anchore/grype:{{.Tag}}-nonroot-s390x - goarch: s390x - dockerfile: Dockerfile.nonroot - use: buildx - build_flag_templates: - - "--platform=linux/s390x" - - "--provenance=false" - - "--build-arg=BUILD_DATE={{.Date}}" - - "--build-arg=BUILD_VERSION={{.Version}}" - - "--build-arg=VCS_REF={{.FullCommit}}" - - "--build-arg=VCS_URL={{.GitURL}}" - - # debug images... - - image_templates: - - anchore/grype:{{.Tag}}-debug-amd64 - - ghcr.io/anchore/grype:{{.Tag}}-debug-amd64 - goarch: amd64 - dockerfile: Dockerfile.debug - use: buildx - build_flag_templates: - - "--platform=linux/amd64" - - "--provenance=false" - - "--build-arg=BUILD_DATE={{.Date}}" - - "--build-arg=BUILD_VERSION={{.Version}}" - - "--build-arg=VCS_REF={{.FullCommit}}" - - "--build-arg=VCS_URL={{.GitURL}}" - - - image_templates: - - anchore/grype:{{.Tag}}-debug-arm64v8 - - ghcr.io/anchore/grype:{{.Tag}}-debug-arm64v8 - goarch: arm64 - dockerfile: Dockerfile.debug - use: buildx - build_flag_templates: - - "--platform=linux/arm64/v8" - - "--provenance=false" - - "--build-arg=BUILD_DATE={{.Date}}" - - "--build-arg=BUILD_VERSION={{.Version}}" - - "--build-arg=VCS_REF={{.FullCommit}}" - - "--build-arg=VCS_URL={{.GitURL}}" - - - image_templates: - - anchore/grype:{{.Tag}}-debug-ppc64le - - ghcr.io/anchore/grype:{{.Tag}}-debug-ppc64le - goarch: ppc64le - dockerfile: Dockerfile.debug - use: buildx + ids: + - linux-build + dockerfile: Dockerfile build_flag_templates: - "--platform=linux/ppc64le" - "--provenance=false" + - "--label=org.opencontainers.image.title={{ .ProjectName }}" + - "--label=org.opencontainers.image.description=RapidFort's Grype fork with curated advisories" + - "--label=org.opencontainers.image.vendor=RapidFort" + - "--label=org.opencontainers.image.version={{ .Version }}" + - "--label=org.opencontainers.image.created={{ .Date }}" + - "--label=org.opencontainers.image.source=https://github.com/rfJanit/grype" + - "--label=org.opencontainers.image.revision={{ .FullCommit }}" + - "--label=org.opencontainers.image.url=https://github.com/rfJanit/grype" - "--build-arg=BUILD_DATE={{.Date}}" - "--build-arg=BUILD_VERSION={{.Version}}" - "--build-arg=VCS_REF={{.FullCommit}}" - "--build-arg=VCS_URL={{.GitURL}}" - - image_templates: - - anchore/grype:{{.Tag}}-debug-s390x - - ghcr.io/anchore/grype:{{.Tag}}-debug-s390x - goarch: s390x - dockerfile: Dockerfile.debug - use: buildx - build_flag_templates: - - "--platform=linux/s390x" - - "--provenance=false" - - "--build-arg=BUILD_DATE={{.Date}}" - - "--build-arg=BUILD_VERSION={{.Version}}" - - "--build-arg=VCS_REF={{.FullCommit}}" - - "--build-arg=VCS_URL={{.GitURL}}" - +# Multi-arch manifests published only to GHCR under the fork's owner. +# End users pull one of: +# docker pull ghcr.io/rfjanit/grype:latest # newest release +# docker pull ghcr.io/rfjanit/grype:0.115.0-rf.1 # pinned version +# The daemon picks the arch that matches its host. docker_manifests: - - name_template: anchore/grype:latest - image_templates: - - anchore/grype:{{.Tag}}-amd64 - - anchore/grype:{{.Tag}}-arm64v8 - - anchore/grype:{{.Tag}}-ppc64le - - anchore/grype:{{.Tag}}-s390x - - - name_template: ghcr.io/anchore/grype:latest - image_templates: - - ghcr.io/anchore/grype:{{.Tag}}-amd64 - - ghcr.io/anchore/grype:{{.Tag}}-arm64v8 - - ghcr.io/anchore/grype:{{.Tag}}-ppc64le - - ghcr.io/anchore/grype:{{.Tag}}-s390x - - - name_template: anchore/grype:{{.Tag}} + - name_template: 'ghcr.io/rfjanit/grype:{{ .Version }}' image_templates: - - anchore/grype:{{.Tag}}-amd64 - - anchore/grype:{{.Tag}}-arm64v8 - - anchore/grype:{{.Tag}}-ppc64le - - anchore/grype:{{.Tag}}-s390x + - 'ghcr.io/rfjanit/grype:{{ .Version }}-amd64' + - 'ghcr.io/rfjanit/grype:{{ .Version }}-arm64' + - 'ghcr.io/rfjanit/grype:{{ .Version }}-s390x' + - 'ghcr.io/rfjanit/grype:{{ .Version }}-ppc64le' - - name_template: ghcr.io/anchore/grype:{{.Tag}} + - name_template: 'ghcr.io/rfjanit/grype:latest' image_templates: - - ghcr.io/anchore/grype:{{.Tag}}-amd64 - - ghcr.io/anchore/grype:{{.Tag}}-arm64v8 - - ghcr.io/anchore/grype:{{.Tag}}-ppc64le - - ghcr.io/anchore/grype:{{.Tag}}-s390x - - # nonroot images... - - name_template: anchore/grype:nonroot - image_templates: - - anchore/grype:{{.Tag}}-nonroot-amd64 - - anchore/grype:{{.Tag}}-nonroot-arm64v8 - - anchore/grype:{{.Tag}}-nonroot-ppc64le - - anchore/grype:{{.Tag}}-nonroot-s390x - - - name_template: ghcr.io/anchore/grype:nonroot - image_templates: - - ghcr.io/anchore/grype:{{.Tag}}-nonroot-amd64 - - ghcr.io/anchore/grype:{{.Tag}}-nonroot-arm64v8 - - ghcr.io/anchore/grype:{{.Tag}}-nonroot-ppc64le - - ghcr.io/anchore/grype:{{.Tag}}-nonroot-s390x - - - name_template: anchore/grype:{{.Tag}}-nonroot - image_templates: - - anchore/grype:{{.Tag}}-nonroot-amd64 - - anchore/grype:{{.Tag}}-nonroot-arm64v8 - - anchore/grype:{{.Tag}}-nonroot-ppc64le - - anchore/grype:{{.Tag}}-nonroot-s390x - - - name_template: ghcr.io/anchore/grype:{{.Tag}}-nonroot - image_templates: - - ghcr.io/anchore/grype:{{.Tag}}-nonroot-amd64 - - ghcr.io/anchore/grype:{{.Tag}}-nonroot-arm64v8 - - ghcr.io/anchore/grype:{{.Tag}}-nonroot-ppc64le - - ghcr.io/anchore/grype:{{.Tag}}-nonroot-s390x - - # debug images... - - name_template: anchore/grype:debug - image_templates: - - anchore/grype:{{.Tag}}-debug-amd64 - - anchore/grype:{{.Tag}}-debug-arm64v8 - - anchore/grype:{{.Tag}}-debug-ppc64le - - anchore/grype:{{.Tag}}-debug-s390x - - - name_template: ghcr.io/anchore/grype:debug - image_templates: - - ghcr.io/anchore/grype:{{.Tag}}-debug-amd64 - - ghcr.io/anchore/grype:{{.Tag}}-debug-arm64v8 - - ghcr.io/anchore/grype:{{.Tag}}-debug-ppc64le - - ghcr.io/anchore/grype:{{.Tag}}-debug-s390x - - - name_template: anchore/grype:{{.Tag}}-debug - image_templates: - - anchore/grype:{{.Tag}}-debug-amd64 - - anchore/grype:{{.Tag}}-debug-arm64v8 - - anchore/grype:{{.Tag}}-debug-ppc64le - - anchore/grype:{{.Tag}}-debug-s390x - - - name_template: ghcr.io/anchore/grype:{{.Tag}}-debug - image_templates: - - ghcr.io/anchore/grype:{{.Tag}}-debug-amd64 - - ghcr.io/anchore/grype:{{.Tag}}-debug-arm64v8 - - ghcr.io/anchore/grype:{{.Tag}}-debug-ppc64le - - ghcr.io/anchore/grype:{{.Tag}}-debug-s390x - + - 'ghcr.io/rfjanit/grype:{{ .Version }}-amd64' + - 'ghcr.io/rfjanit/grype:{{ .Version }}-arm64' + - 'ghcr.io/rfjanit/grype:{{ .Version }}-s390x' + - 'ghcr.io/rfjanit/grype:{{ .Version }}-ppc64le' + +# Cosign keyless signing of the checksums file. Works with GitHub's OIDC +# provider — no external secrets needed, but the release workflow MUST +# declare `permissions: id-token: write` (§3.2 of RELEASE-GUIDE-GRYPE.md). +# This is one place where the Grype fork diverges from the Trivy fork: Trivy +# disabled signing for the first release; Grype keeps it since it's zero-cost +# once the workflow permission is in place. signs: - cmd: .tool/cosign signature: "${artifact}.sig" diff --git a/grype/db/v6/distribution/client.go b/grype/db/v6/distribution/client.go index 92ec2e4afb7..188096335ad 100644 --- a/grype/db/v6/distribution/client.go +++ b/grype/db/v6/distribution/client.go @@ -53,7 +53,7 @@ type client struct { func DefaultConfig() Config { return Config{ - LatestURL: "https://grype.anchore.io/databases", + LatestURL: "https://github.com/rfJanit/grype-db-rf/releases/download", RequireUpdateCheck: false, CheckTimeout: 30 * time.Second, UpdateTimeout: 300 * time.Second,