From 1fa81ae659a50a33f8cc207650ccf193db56a878 Mon Sep 17 00:00:00 2001 From: crozzy Date: Wed, 7 Oct 2026 16:20:01 -0700 Subject: [PATCH 1/4] java: move the product CPE repository key to rhel/java RepositoryKey is the key for a Red Hat product CPE attributed to a Java component. Signed-off-by: crozzy --- java/coalescer.go | 3 ++- java/coalescer_test.go | 7 ++++--- java/detector.go | 3 ++- java/packagescanner.go | 5 ----- rhel/java/repository.go | 6 ++++++ 5 files changed, 14 insertions(+), 10 deletions(-) create mode 100644 rhel/java/repository.go diff --git a/java/coalescer.go b/java/coalescer.go index 02ae97909..7c9fb2099 100644 --- a/java/coalescer.go +++ b/java/coalescer.go @@ -8,6 +8,7 @@ import ( "github.com/quay/claircore" "github.com/quay/claircore/indexer" + rheljava "github.com/quay/claircore/rhel/java" ) type coalescer struct{} @@ -144,7 +145,7 @@ func sbomRepositoryIDs(ctx context.Context, pkg *claircore.Package, repos []*cla for _, want := range wants { var matched bool for _, r := range repos { - if r.Key != RedHatCPERepositoryKey { + if r.Key != rheljava.RepositoryKey { continue } if r.Name != want && r.CPE.String() != want { diff --git a/java/coalescer_test.go b/java/coalescer_test.go index 5cceac167..ababe42b4 100644 --- a/java/coalescer_test.go +++ b/java/coalescer_test.go @@ -7,6 +7,7 @@ import ( "github.com/quay/claircore" "github.com/quay/claircore/indexer" + rheljava "github.com/quay/claircore/rhel/java" "github.com/quay/claircore/test" "github.com/quay/claircore/toolkit/types/cpe" ) @@ -18,7 +19,7 @@ func TestCoalescerSplitsRepositories(t *testing.T) { cpeRepo := &claircore.Repository{ ID: "1", Name: w.String(), - Key: RedHatCPERepositoryKey, + Key: rheljava.RepositoryKey, CPE: w, } maven := Repository @@ -64,8 +65,8 @@ func TestCoalescerMultipleProductCPEs(t *testing.T) { ctx := test.Logging(t) q := cpe.MustUnbind("cpe:/a:redhat:quarkus:3.33") c := cpe.MustUnbind("cpe:/a:redhat:apache_camel_quarkus:3.33") - quarkus := &claircore.Repository{ID: "1", Name: q.String(), Key: RedHatCPERepositoryKey, CPE: q} - camel := &claircore.Repository{ID: "2", Name: c.String(), Key: RedHatCPERepositoryKey, CPE: c} + quarkus := &claircore.Repository{ID: "1", Name: q.String(), Key: rheljava.RepositoryKey, CPE: q} + camel := &claircore.Repository{ID: "2", Name: c.String(), Key: rheljava.RepositoryKey, CPE: c} maven := Repository maven.ID = "3" pkg := &claircore.Package{ diff --git a/java/detector.go b/java/detector.go index 5fe3cef99..e1f7c7736 100644 --- a/java/detector.go +++ b/java/detector.go @@ -13,6 +13,7 @@ import ( "github.com/quay/claircore/indexer" "github.com/quay/claircore/java/bom" "github.com/quay/claircore/java/jar" + rheljava "github.com/quay/claircore/rhel/java" "github.com/quay/claircore/rpm" "github.com/quay/claircore/toolkit/types/cpe" ) @@ -54,7 +55,7 @@ func (*Detector) Scan(ctx context.Context, layer *claircore.Layer) ([]*claircore } seen[s] = &claircore.Repository{ Name: s, - Key: RedHatCPERepositoryKey, + Key: rheljava.RepositoryKey, CPE: w, } } diff --git a/java/packagescanner.go b/java/packagescanner.go index d7e20e69a..1d8d7e9bc 100644 --- a/java/packagescanner.go +++ b/java/packagescanner.go @@ -44,11 +44,6 @@ var ( } ) -// RedHatCPERepositoryKey is the repository key for a Red Hat product CPE -// attributed to a Java component. It is distinct from the Maven Central -// repository and from the RPM CPE repository. -const RedHatCPERepositoryKey = "redhat-java-cpe-repository" - // DefaultSearchAPI is a maven-like REST API that may be used to do // reverse lookups based on an archive's sha1 sum. // diff --git a/rhel/java/repository.go b/rhel/java/repository.go new file mode 100644 index 000000000..8f761ebc1 --- /dev/null +++ b/rhel/java/repository.go @@ -0,0 +1,6 @@ +package java + +// RepositoryKey is the repository key for a Red Hat product CPE attributed +// to a Java component. It is distinct from the Maven Central repository and +// from the RPM CPE repository. +const RepositoryKey = "redhat-java-cpe-repository" From d6d7e8175d4c0fef416b53e46d07b9a790d3c68e Mon Sep 17 00:00:00 2001 From: crozzy Date: Wed, 7 Oct 2026 16:12:20 -0700 Subject: [PATCH 2/4] rhel-vex: ingest Maven PURLs pkg:maven rows are now accepted. The package name is namespace:name, ignoring repository_url and type. FixedInVersion is the PURL version for a fixed row. Maven known_not_affected rows are skipped, and a versioned known_affected Maven PURL is not ingested. TODO: resolve what a version on a known_affected Maven PURL means. TODO: move the feed URL back to the production vex-feed once Maven data is published there. Signed-off-by: crozzy --- rhel/vex/parser.go | 41 +++++-- rhel/vex/parser_test.go | 97 ++++++++++++++++- rhel/vex/testdata/cve-2026-maven.json | 150 ++++++++++++++++++++++++++ rhel/vex/updater.go | 2 +- 4 files changed, 280 insertions(+), 10 deletions(-) create mode 100644 rhel/vex/testdata/cve-2026-maven.json diff --git a/rhel/vex/parser.go b/rhel/vex/parser.go index a2e1aac69..a1ab20db2 100644 --- a/rhel/vex/parser.go +++ b/rhel/vex/parser.go @@ -25,6 +25,7 @@ import ( "github.com/quay/claircore" "github.com/quay/claircore/pkg/rhctag" "github.com/quay/claircore/rhel/internal/common" + "github.com/quay/claircore/rhel/java" "github.com/quay/claircore/rhel/rhcc" "github.com/quay/claircore/toolkit/types" "github.com/quay/claircore/toolkit/types/cpe" @@ -801,6 +802,12 @@ func (c *creator) knownAffectedVulnerabilities(ctx context.Context, v *csaf.Vuln if err != nil { return nil, err } + // A version on a Maven known_affected purl is not a match rule yet. + // Leave those rows out. An unversioned purl matches any installed version. + if st.PURL.Type == packageurl.TypeMaven && st.PURL.Version != "" { + log.DebugContext(ctx, "skipping versioned maven known_affected", "purl", st.PURL) + continue + } pkgName, err := st.PackageName() if err != nil { @@ -846,6 +853,10 @@ func (c *creator) knownAffectedVulnerabilities(ctx context.Context, v *csaf.Vuln } case packageurl.TypeRPM: vuln.Repo = c.rc.Get(st.WFN, repoKey) + case packageurl.TypeMaven: + // The Java indexer records binary packages and does not set Source. + vuln.Package.Kind = types.BinaryPackage + vuln.Repo = c.rc.Get(st.WFN, java.RepositoryKey) } if c.productIDInLinks && c.docLink != "" { @@ -993,6 +1004,8 @@ func (c *creator) fixedVulnerabilities(ctx context.Context, v *csaf.Vulnerabilit "reason", err, "version", vuln.FixedInVersion) continue } + case packageurl.TypeMaven: + vuln.Repo = c.rc.Get(st.WFN, java.RepositoryKey) default: panic("unreachable") } @@ -1006,6 +1019,10 @@ func (c *creator) fixedVulnerabilities(ctx context.Context, v *csaf.Vulnerabilit commit(key, vuln) } + // Maven has no arch check. repository_url and type are not coordinates. + if st.PURL.Type == packageurl.TypeMaven { + continue + } if arch := extractArch(st.PURL); arch != "" { if vuln.Package.Arch == "" { vuln.Package.Arch = arch @@ -1028,8 +1045,8 @@ func (c *creator) fixedVulnerabilities(ctx context.Context, v *csaf.Vulnerabilit } // KnownNotAffectedVulnerabilities processes the "known_not_affected" array of -// products in the VEX object. Only OCI (container) assertions are ingested; -// RPM known_not_affected rows are skipped. +// products in the VEX object. Only OCI (container) assertions are ingested. +// RPM and Maven known_not_affected rows are skipped. func (c *creator) knownNotAffectedVulnerabilities(ctx context.Context, v *csaf.Vulnerability, init vulnHook) ([]*claircore.Vulnerability, error) { log := slog.With("link", c.docLink) var backing rope[claircore.Vulnerability] @@ -1060,7 +1077,7 @@ func (c *creator) knownNotAffectedVulnerabilities(ctx context.Context, v *csaf.V continue } - if st.PURL.Type == packageurl.TypeRPM { + if st.PURL.Type == packageurl.TypeRPM || st.PURL.Type == packageurl.TypeMaven { continue } @@ -1224,10 +1241,11 @@ func componentPURLToModuleName(p *packageurl.PackageURL) (string, error) { return name + ":" + stream, nil } -// ExtractFixedInVersion deals with 2 pURL types, TypeRPM and TypeOCI +// ExtractFixedInVersion deals with pURL types TypeRPM, TypeOCI, and TypeMaven. // - TypeOCI: return the tag qualifier. // - TypeRPM: check for an epoch qualifier and prepend it to the purl.Version. // If no epoch qualifier, default to 0. +// - TypeMaven: return the purl version, with no epoch. Qualifiers are ignored. // // Embedders may rewrite the result via [FactoryConfig.FixedInVersionCEL]. func extractFixedInVersion(p *packageurl.PackageURL) (string, error) { @@ -1247,17 +1265,20 @@ func extractFixedInVersion(p *packageurl.PackageURL) (string, error) { epoch = e } return epoch + ":" + p.Version, nil + case packageurl.TypeMaven: + return p.Version, nil default: return "", fmt.Errorf("unexpected purl type: %q", p.Type) } } -// ExtractPackageName deals with 2 pURL types, TypeRPM and TypeOCI +// ExtractPackageName deals with pURL types TypeRPM, TypeOCI, and TypeMaven. // - TypeOCI: check if there is Namespace and Name i.e. rhel7/rhel-atomic // and return that, if not, check for a repository_url qualifier. If the // repository_url exists then use the namespace/name part, if not, use // the purl.Name. // - TypeRPM: Just return the purl.Name. +// - TypeMaven: return namespace:name. repository_url and type are ignored. func extractPackageName(p *packageurl.PackageURL) (string, error) { switch p.Type { case packageurl.TypeOCI: @@ -1276,14 +1297,20 @@ func extractPackageName(p *packageurl.PackageURL) (string, error) { return image, nil case packageurl.TypeRPM: return p.Name, nil + case packageurl.TypeMaven: + if p.Namespace == "" { + return p.Name, nil + } + return p.Namespace + ":" + p.Name, nil default: return "", fmt.Errorf("unexpected purl type: %q", p.Type) } } var acceptedTypes = map[string]bool{ - packageurl.TypeOCI: true, - packageurl.TypeRPM: true, + packageurl.TypeOCI: true, + packageurl.TypeRPM: true, + packageurl.TypeMaven: true, } // CheckPURL checks if purl is something we're interested in. diff --git a/rhel/vex/parser_test.go b/rhel/vex/parser_test.go index 8bdc27067..f96f1139b 100644 --- a/rhel/vex/parser_test.go +++ b/rhel/vex/parser_test.go @@ -16,6 +16,7 @@ import ( "github.com/package-url/packageurl-go" "github.com/quay/claircore" + "github.com/quay/claircore/rhel/java" "github.com/quay/claircore/test" "github.com/quay/claircore/toolkit/types" "github.com/quay/claircore/toolkit/types/cpe" @@ -497,6 +498,53 @@ func TestParse(t *testing.T) { } } +func TestParseMaven(t *testing.T) { + ctx := test.Logging(t) + b, err := os.ReadFile("testdata/cve-2026-maven.json") + if err != nil { + t.Fatal(err) + } + vulns, err := NewParser().Parse(ctx, b) + if err != nil { + t.Fatal(err) + } + quarkus := cpe.MustUnbind("cpe:/a:redhat:quarkus:3.33") + eap := cpe.MustUnbind("cpe:/a:redhat:jboss_enterprise_application_platform:8") + + var sawFixed, sawAffected bool + for _, v := range vulns { + if v.Package == nil { + t.Fatalf("missing package: %+v", v) + } + if v.Package.Kind != types.BinaryPackage { + t.Errorf("%s kind: got %s, want binary", v.Package.Name, v.Package.Kind) + } + if v.Repo == nil || v.Repo.Key != java.RepositoryKey { + t.Errorf("%s repo: %+v", v.Package.Name, v.Repo) + } + if v.Package.Arch != "" { + t.Errorf("%s arch: got %q", v.Package.Name, v.Package.Arch) + } + switch { + case v.Package.Name == "io.netty:netty-codec" && v.FixedInVersion == "4.1.115.Final-redhat-00002": + sawFixed = true + if v.Repo.Name != quarkus.String() { + t.Errorf("fixed repo name: got %q, want %q", v.Repo.Name, quarkus.String()) + } + case v.Package.Name == "io.netty:netty-codec" && v.FixedInVersion == "": + sawAffected = true + if v.Repo.Name != eap.String() { + t.Errorf("affected repo name: got %q, want %q", v.Repo.Name, eap.String()) + } + default: + t.Errorf("unexpected vulnerability %s %s %q", v.Name, v.Package.Name, v.FixedInVersion) + } + } + if !sawFixed || !sawAffected { + t.Fatalf("missing maven rows: fixed=%v affected=%v (%d vulns)", sawFixed, sawAffected, len(vulns)) + } +} + func TestExtractVersion(t *testing.T) { testcases := []struct { name string @@ -561,6 +609,29 @@ func TestExtractVersion(t *testing.T) { expectedErr: true, }, + { + name: "maven_version", + purl: packageurl.PackageURL{ + Type: packageurl.TypeMaven, + Namespace: "io.netty", + Name: "netty-codec", + Version: "4.1.115.Final-redhat-00002", + Qualifiers: packageurl.QualifiersFromMap(map[string]string{ + "repository_url": "https://maven.repository.redhat.com/ga/", + "type": "jar", + }), + }, + want: "4.1.115.Final-redhat-00002", + }, + { + name: "maven_without_version", + purl: packageurl.PackageURL{ + Type: packageurl.TypeMaven, + Namespace: "io.netty", + Name: "netty-codec", + }, + want: "", + }, { name: "unsupported_type", purl: packageurl.PackageURL{ @@ -668,6 +739,28 @@ func TestExtractPackageName(t *testing.T) { }, want: "something/keepalived-rhel9", }, + { + name: "maven_namespace", + purl: packageurl.PackageURL{ + Type: packageurl.TypeMaven, + Namespace: "io.netty", + Name: "netty-codec", + Version: "4.1.115.Final-redhat-00002", + Qualifiers: packageurl.QualifiersFromMap(map[string]string{ + "repository_url": "https://maven.repository.redhat.com/ga/", + "type": "jar", + }), + }, + want: "io.netty:netty-codec", + }, + { + name: "maven_without_namespace", + purl: packageurl.PackageURL{ + Type: packageurl.TypeMaven, + Name: "netty-codec", + }, + want: "netty-codec", + }, { name: "unsupported_type", purl: packageurl.PackageURL{ @@ -746,7 +839,7 @@ func TestCheckKernelPackage(t *testing.T) { func TestCreatorDocLink(t *testing.T) { const name = "CVE-2023-4911" - defaultURL := "https://security.access.redhat.com/data/csaf/v2/vex-feed/2023/cve-2023-4911.json" + defaultURL := BaseURL + "2023/cve-2023-4911.json" legacyBase, err := url.Parse("https://security.access.redhat.com/data/csaf/v2/vex/") if err != nil { t.Fatal(err) @@ -808,7 +901,7 @@ func TestDocLinkFromBase(t *testing.T) { { name: "cve", id: "CVE-2023-4911", - want: "https://security.access.redhat.com/data/csaf/v2/vex-feed/2023/cve-2023-4911.json", + want: BaseURL + "2023/cve-2023-4911.json", }, { name: "non-cve", diff --git a/rhel/vex/testdata/cve-2026-maven.json b/rhel/vex/testdata/cve-2026-maven.json new file mode 100644 index 000000000..f21e3a6fb --- /dev/null +++ b/rhel/vex/testdata/cve-2026-maven.json @@ -0,0 +1,150 @@ +{ + "document": { + "category": "csaf_vex", + "csaf_version": "2.0", + "title": "maven fixture", + "tracking": { + "id": "CVE-2026-59889", + "status": "final", + "version": "1" + } + }, + "product_tree": { + "branches": [ + { + "category": "product_version", + "name": "netty-codec", + "product": { + "name": "netty-codec", + "product_id": "netty-fixed", + "product_identification_helper": { + "purl": "pkg:maven/io.netty/netty-codec@4.1.115.Final-redhat-00002?repository_url=https://maven.repository.redhat.com/ga/&type=jar" + } + } + }, + { + "category": "product_version", + "name": "netty-codec", + "product": { + "name": "netty-codec", + "product_id": "netty-affected", + "product_identification_helper": { + "purl": "pkg:maven/io.netty/netty-codec" + } + } + }, + { + "category": "product_version", + "name": "jackson-core", + "product": { + "name": "jackson-core", + "product_id": "jackson-affected", + "product_identification_helper": { + "purl": "pkg:maven/com.fasterxml.jackson.core/jackson-core@2.18.2.redhat-00004" + } + } + }, + { + "category": "product_version", + "name": "smallrye-graphql", + "product": { + "name": "smallrye-graphql", + "product_id": "graphql-kna", + "product_identification_helper": { + "purl": "pkg:maven/io.smallrye/smallrye-graphql@2.17.0.redhat-00003" + } + } + }, + { + "category": "product_name", + "name": "Red Hat build of Quarkus 3.33", + "product": { + "name": "Red Hat build of Quarkus 3.33", + "product_id": "quarkus", + "product_identification_helper": { + "cpe": "cpe:/a:redhat:quarkus:3.33" + } + } + }, + { + "category": "product_name", + "name": "Red Hat JBoss Enterprise Application Platform 8", + "product": { + "name": "Red Hat JBoss Enterprise Application Platform 8", + "product_id": "eap8", + "product_identification_helper": { + "cpe": "cpe:/a:redhat:jboss_enterprise_application_platform:8" + } + } + } + ], + "relationships": [ + { + "category": "default_component_of", + "full_product_name": { + "name": "netty-codec as a component of Quarkus 3.33", + "product_id": "quarkus:netty-fixed" + }, + "product_reference": "netty-fixed", + "relates_to_product_reference": "quarkus" + }, + { + "category": "default_component_of", + "full_product_name": { + "name": "netty-codec as a component of EAP 8", + "product_id": "eap8:netty-affected" + }, + "product_reference": "netty-affected", + "relates_to_product_reference": "eap8" + }, + { + "category": "default_component_of", + "full_product_name": { + "name": "jackson-core as a component of Quarkus 3.33", + "product_id": "quarkus:jackson-affected" + }, + "product_reference": "jackson-affected", + "relates_to_product_reference": "quarkus" + }, + { + "category": "default_component_of", + "full_product_name": { + "name": "smallrye-graphql as a component of EAP 8", + "product_id": "eap8:graphql-kna" + }, + "product_reference": "graphql-kna", + "relates_to_product_reference": "eap8" + } + ] + }, + "vulnerabilities": [ + { + "cve": "CVE-2026-59889", + "product_status": { + "fixed": [ + "quarkus:netty-fixed", + "quarkus-3.15.7" + ], + "known_affected": [ + "eap8:netty-affected", + "quarkus:jackson-affected" + ], + "known_not_affected": [ + "eap8:graphql-kna" + ] + }, + "scores": [ + { + "cvss_v3": { + "baseScore": 5.9, + "vectorString": "CVSS:3.1/AV:N/AC:H/PR:N/UI:N/S:U/C:N/I:N/A:H", + "version": "3.1" + }, + "products": [ + "quarkus:netty-fixed" + ] + } + ] + } + ] +} diff --git a/rhel/vex/updater.go b/rhel/vex/updater.go index ce5235f26..852c3e2bd 100644 --- a/rhel/vex/updater.go +++ b/rhel/vex/updater.go @@ -36,7 +36,7 @@ const ( deletionsFile = "deletions.csv" lookBackToYear = 2015 repoKey = "rhel-cpe-repository" - updaterVersion = "8" + updaterVersion = "9" ) // Factory creates an Updater to process all of the Red Hat VEX data. From f58ddfd84867ff49e84828bcad615599e37c1815 Mon Sep 17 00:00:00 2001 From: crozzy Date: Wed, 7 Oct 2026 16:20:01 -0700 Subject: [PATCH 3/4] java: add a Red Hat Matcher The matcher selects redhat-java-cpe-repository records. It applies the RHEL CPE check, then compares Maven versions: an installed package is vulnerable when it is strictly older than FixedInVersion, and an empty FixedInVersion matches any installed version. Signed-off-by: crozzy --- java/coalescer_test.go | 2 +- matchers/defaults/defaults.go | 2 + rhel/java/matcher.go | 65 ++++++++++++++++++++ rhel/java/matcher_test.go | 110 ++++++++++++++++++++++++++++++++++ 4 files changed, 178 insertions(+), 1 deletion(-) create mode 100644 rhel/java/matcher.go create mode 100644 rhel/java/matcher_test.go diff --git a/java/coalescer_test.go b/java/coalescer_test.go index ababe42b4..6120bdeb2 100644 --- a/java/coalescer_test.go +++ b/java/coalescer_test.go @@ -101,7 +101,7 @@ func TestCoalescerDropsCoveredJars(t *testing.T) { cpeRepo := &claircore.Repository{ ID: "1", Name: w.String(), - Key: RedHatCPERepositoryKey, + Key: rheljava.RepositoryKey, CPE: w, } maven := Repository diff --git a/matchers/defaults/defaults.go b/matchers/defaults/defaults.go index 774e068f6..5df1d8058 100644 --- a/matchers/defaults/defaults.go +++ b/matchers/defaults/defaults.go @@ -17,6 +17,7 @@ import ( "github.com/quay/claircore/photon" "github.com/quay/claircore/python" "github.com/quay/claircore/rhel" + rheljava "github.com/quay/claircore/rhel/java" "github.com/quay/claircore/rhel/rhcc" "github.com/quay/claircore/ruby" "github.com/quay/claircore/suse" @@ -52,6 +53,7 @@ var defaultMatchers = []driver.Matcher{ &oracle.Matcher{}, &photon.Matcher{}, &python.Matcher{}, + &rheljava.RedHatMatcher{}, rhcc.Matcher, &ruby.Matcher{}, &suse.Matcher{}, diff --git a/rhel/java/matcher.go b/rhel/java/matcher.go new file mode 100644 index 000000000..13d1ab1da --- /dev/null +++ b/rhel/java/matcher.go @@ -0,0 +1,65 @@ +// Package java matches Java packages recorded under a Red Hat product CPE. +package java + +import ( + "context" + "log/slog" + + "github.com/quay/claircore" + "github.com/quay/claircore/internal/maven" + "github.com/quay/claircore/libvuln/driver" + "github.com/quay/claircore/rhel" + "github.com/quay/claircore/toolkit/types/cpe" +) + +// RedHatMatcher matches Java packages recorded under a Red Hat product CPE. +type RedHatMatcher struct{} + +var _ driver.Matcher = (*RedHatMatcher)(nil) + +// Name implements [driver.Matcher]. +func (*RedHatMatcher) Name() string { return "redhat-java" } + +// Filter implements [driver.Matcher]. +func (*RedHatMatcher) Filter(record *claircore.IndexRecord) bool { + return record.Repository != nil && record.Repository.Key == RepositoryKey +} + +// Query implements [driver.Matcher]. +func (*RedHatMatcher) Query() []driver.MatchConstraint { + return []driver.MatchConstraint{driver.RepositoryKey} +} + +// Vulnerable implements [driver.Matcher]. +// +// The vulnerability repository name is a normalized CPE. It must be a +// superset of the indexed CPE, or match it with the Red Hat substring +// check. An empty FixedInVersion matches any installed version. Otherwise +// the installed Maven version is vulnerable when it is strictly older. +func (*RedHatMatcher) Vulnerable(ctx context.Context, record *claircore.IndexRecord, vuln *claircore.Vulnerability) (bool, error) { + if vuln.Repo == nil || record.Repository == nil || vuln.Repo.Key != RepositoryKey { + return false, nil + } + var err error + // Vulnerability repositories do not persist CPE. The name is the normalized form. + vuln.Repo.CPE, err = cpe.Unbind(vuln.Repo.Name) + if err != nil { + slog.WarnContext(ctx, "unable to unbind repo CPE", "reason", err, "vulnerability name", vuln.Name) + return false, nil + } + if !cpe.Compare(vuln.Repo.CPE, record.Repository.CPE).IsSuperset() && !rhel.IsCPESubstringMatch(record.Repository.CPE, vuln.Repo.CPE) { + return false, nil + } + if vuln.FixedInVersion == "" { + return true, nil + } + installed, err := maven.ParseVersion(record.Package.Version) + if err != nil { + return false, err + } + fixed, err := maven.ParseVersion(vuln.FixedInVersion) + if err != nil { + return false, err + } + return installed.Compare(fixed) < 0, nil +} diff --git a/rhel/java/matcher_test.go b/rhel/java/matcher_test.go new file mode 100644 index 000000000..04836ff7d --- /dev/null +++ b/rhel/java/matcher_test.go @@ -0,0 +1,110 @@ +package java + +import ( + "testing" + + "github.com/quay/claircore" + "github.com/quay/claircore/libvuln/driver" + "github.com/quay/claircore/test" + "github.com/quay/claircore/toolkit/types/cpe" +) + +func TestRedHatMatcherVulnerable(t *testing.T) { + ctx := test.Logging(t) + m := &RedHatMatcher{} + quarkus := cpe.MustUnbind("cpe:/a:redhat:quarkus:3.33") + quarkusEl := cpe.MustUnbind("cpe:/a:redhat:quarkus:3.33::el8") + quarkusMajor := cpe.MustUnbind("cpe:/a:redhat:quarkus:3") + eap := cpe.MustUnbind("cpe:/a:redhat:jboss_enterprise_application_platform:8.1") + eapMajor := cpe.MustUnbind("cpe:/a:redhat:jboss_enterprise_application_platform:8") + + record := func(version string, w cpe.WFN) *claircore.IndexRecord { + return &claircore.IndexRecord{ + Package: &claircore.Package{Name: "io.netty:netty-codec", Version: version}, + Repository: &claircore.Repository{ + Name: w.String(), + Key: RepositoryKey, + CPE: w, + }, + } + } + vuln := func(fixed string, name string) *claircore.Vulnerability { + return &claircore.Vulnerability{ + Name: "CVE-2026-59889", + FixedInVersion: fixed, + Repo: &claircore.Repository{ + Name: name, + Key: RepositoryKey, + }, + } + } + + tests := []struct { + name string + record *claircore.IndexRecord + vuln *claircore.Vulnerability + want bool + }{ + { + name: "older than fixed", + record: record("4.1.100.Final-redhat-00001", quarkus), + vuln: vuln("4.1.115.Final-redhat-00002", quarkus.String()), + want: true, + }, + { + name: "equal to fixed", + record: record("4.1.115.Final-redhat-00002", quarkus), + vuln: vuln("4.1.115.Final-redhat-00002", quarkus.String()), + }, + { + name: "newer than fixed", + record: record("4.1.120.Final-redhat-00001", quarkus), + vuln: vuln("4.1.115.Final-redhat-00002", quarkus.String()), + }, + { + name: "unversioned known_affected", + record: record("9.9.9", eap), + vuln: vuln("", eapMajor.String()), + want: true, + }, + { + name: "major CPE pattern", + record: record("1.0", quarkus), + vuln: vuln("", quarkusMajor.String()), + want: true, + }, + { + name: "edition makes the advisory narrower", + record: record("1.0", quarkus), + vuln: vuln("", quarkusEl.String()), + }, + { + name: "different product", + record: record("1.0", quarkus), + vuln: vuln("", eapMajor.String()), + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := m.Vulnerable(ctx, tt.record, tt.vuln) + if err != nil { + t.Fatal(err) + } + if got != tt.want { + t.Fatalf("Vulnerable() = %v, want %v", got, tt.want) + } + }) + } + + if !m.Filter(record("1.0", quarkus)) { + t.Fatal("product CPE record was filtered out") + } + central := &claircore.IndexRecord{Repository: &claircore.Repository{Name: "maven"}} + if m.Filter(central) { + t.Fatal("Maven Central record was accepted") + } + q := m.Query() + if len(q) != 1 || q[0] != driver.RepositoryKey { + t.Fatalf("Query() = %v", q) + } +} From 868672d32b3f4c967ee1d4d71dae6637107910e6 Mon Sep 17 00:00:00 2001 From: crozzy Date: Wed, 7 Oct 2026 16:20:01 -0700 Subject: [PATCH 4/4] rhel: limit CPE pattern dots to the version attribute A whole-string prefix matched version 1 to version 10 and treated a dot in any attribute as a boundary. Extend the pattern version when the indexed version continues with a dot, then compare the CPEs once with the normal superset rules. Version 4 still matches 4.13. Signed-off-by: crozzy --- rhel/java/matcher.go | 18 ++++++++--- rhel/java/matcher_test.go | 5 +++ rhel/matcher.go | 19 ++++++++--- rhel/matcher_test.go | 66 +++++++++++++++++++++++++++++++++++++++ rhel/rhcc/matcher.go | 2 +- 5 files changed, 100 insertions(+), 10 deletions(-) diff --git a/rhel/java/matcher.go b/rhel/java/matcher.go index 13d1ab1da..1bc1c16cb 100644 --- a/rhel/java/matcher.go +++ b/rhel/java/matcher.go @@ -32,10 +32,12 @@ func (*RedHatMatcher) Query() []driver.MatchConstraint { // Vulnerable implements [driver.Matcher]. // -// The vulnerability repository name is a normalized CPE. It must be a -// superset of the indexed CPE, or match it with the Red Hat substring -// check. An empty FixedInVersion matches any installed version. Otherwise -// the installed Maven version is vulnerable when it is strictly older. +// The vulnerability repository name is a normalized CPE. It must match +// the indexed CPE, including the Red Hat version-stream rule. An empty +// FixedInVersion matches any installed version. An empty installed version +// is not compared when a fix exists: a Maven null version is older than +// every real fix. Otherwise the installed Maven version is vulnerable when +// it is strictly older. func (*RedHatMatcher) Vulnerable(ctx context.Context, record *claircore.IndexRecord, vuln *claircore.Vulnerability) (bool, error) { if vuln.Repo == nil || record.Repository == nil || vuln.Repo.Key != RepositoryKey { return false, nil @@ -47,12 +49,18 @@ func (*RedHatMatcher) Vulnerable(ctx context.Context, record *claircore.IndexRec slog.WarnContext(ctx, "unable to unbind repo CPE", "reason", err, "vulnerability name", vuln.Name) return false, nil } - if !cpe.Compare(vuln.Repo.CPE, record.Repository.CPE).IsSuperset() && !rhel.IsCPESubstringMatch(record.Repository.CPE, vuln.Repo.CPE) { + if !rhel.IsCPESubstringMatch(record.Repository.CPE, vuln.Repo.CPE) { return false, nil } if vuln.FixedInVersion == "" { return true, nil } + // An empty version parses as a Maven null version, which compares older + // than every real fix. There is no installed version to compare. + if record.Package == nil || record.Package.Version == "" { + slog.WarnContext(ctx, "empty package version", "vulnerability name", vuln.Name) + return false, nil + } installed, err := maven.ParseVersion(record.Package.Version) if err != nil { return false, err diff --git a/rhel/java/matcher_test.go b/rhel/java/matcher_test.go index 04836ff7d..cd8f12733 100644 --- a/rhel/java/matcher_test.go +++ b/rhel/java/matcher_test.go @@ -83,6 +83,11 @@ func TestRedHatMatcherVulnerable(t *testing.T) { record: record("1.0", quarkus), vuln: vuln("", eapMajor.String()), }, + { + name: "empty package version", + record: record("", quarkus), + vuln: vuln("4.1.115.Final-redhat-00002", quarkus.String()), + }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { diff --git a/rhel/matcher.go b/rhel/matcher.go index f29e3aa54..a5800d99e 100644 --- a/rhel/matcher.go +++ b/rhel/matcher.go @@ -56,13 +56,24 @@ func (m *Matcher) Query() []driver.MatchConstraint { // "cpe:/a:redhat:openshift:4.13::el8". // // This is defined (citation needed) to be a substring match on the "pattern" -// and "target" CPEs. Since we always normalize CPEs into v2.3 "Formatted -// String" form, we need to trim the added "ANY" attributes from the pattern. +// and "target" CPEs. Attributes other than version use the normal superset +// comparison. The version matches when it is equal or the record continues +// with ".", so version 4 matches 4.13 and version 1 does not match 10. A dot +// in another attribute, such as a product node.js, does not match. // // TODO(crozzy) Remove once RH VEX data updates CPEs with standard matching // expressions. func IsCPESubstringMatch(recordCPE cpe.WFN, vulnCPE cpe.WFN) bool { - return strings.HasPrefix(recordCPE.String(), strings.TrimRight(vulnCPE.String(), ":*")) + p := vulnCPE.Attr[cpe.Version] + r := recordCPE.Attr[cpe.Version] + // A dotted extension of the pattern version is the same version for this hack. + if p.Kind == cpe.ValueSet && r.Kind == cpe.ValueSet { + rest, ok := strings.CutPrefix(strings.ToLower(r.String()), strings.ToLower(p.String())) + if ok && strings.HasPrefix(rest, ".") { + vulnCPE.Attr[cpe.Version] = r + } + } + return cpe.Compare(vulnCPE, recordCPE).IsSuperset() } // Vulnerable implements [driver.Matcher]. @@ -86,7 +97,7 @@ func (m *Matcher) Vulnerable(ctx context.Context, record *claircore.IndexRecord, slog.WarnContext(ctx, "unable to unbind repo CPE", "reason", err, "vulnerability name", vuln.Name) return false, nil } - if !cpe.Compare(vuln.Repo.CPE, record.Repository.CPE).IsSuperset() && !IsCPESubstringMatch(record.Repository.CPE, vuln.Repo.CPE) { + if !IsCPESubstringMatch(record.Repository.CPE, vuln.Repo.CPE) { return false, nil } diff --git a/rhel/matcher_test.go b/rhel/matcher_test.go index 9b701afbe..efa02cbff 100644 --- a/rhel/matcher_test.go +++ b/rhel/matcher_test.go @@ -185,6 +185,72 @@ func TestIsCPEStringSubsetMatch(t *testing.T) { vulnCPE: cpe.MustUnbind("cpe:/a:redhat:openshift:4.1::el8"), match: false, }, + { + name: "DigitPrefix", + recordCPE: cpe.MustUnbind("cpe:/a:redhat:quarkus:10"), + vulnCPE: cpe.MustUnbind("cpe:/a:redhat:quarkus:1"), + match: false, + }, + { + name: "MinorDigitPrefix", + recordCPE: cpe.MustUnbind("cpe:/a:redhat:quarkus:3.33"), + vulnCPE: cpe.MustUnbind("cpe:/a:redhat:quarkus:3.3"), + match: false, + }, + { + name: "VersionPatch", + recordCPE: cpe.MustUnbind("cpe:/a:redhat:quarkus:4.13.1"), + vulnCPE: cpe.MustUnbind("cpe:/a:redhat:quarkus:4.13"), + match: true, + }, + { + name: "ProductDot", + recordCPE: cpe.MustUnbind("cpe:/a:redhat:node.js:1"), + vulnCPE: cpe.MustUnbind("cpe:/a:redhat:node:1"), + match: false, + }, + { + name: "EditionMismatch", + recordCPE: cpe.MustUnbind("cpe:/a:redhat:openshift:4.13::el8"), + vulnCPE: cpe.MustUnbind("cpe:/a:redhat:openshift:4::el9"), + match: false, + }, + { + name: "ExactVersion", + recordCPE: cpe.MustUnbind("cpe:/a:redhat:openshift:4::el8"), + vulnCPE: cpe.MustUnbind("cpe:/a:redhat:openshift:4::el8"), + match: true, + }, + { + name: "RecordVersionShorter", + recordCPE: cpe.MustUnbind("cpe:/a:redhat:openshift:4"), + vulnCPE: cpe.MustUnbind("cpe:/a:redhat:openshift:4.13"), + match: false, + }, + { + name: "HyphenSuffix", + recordCPE: cpe.MustUnbind("cpe:/a:redhat:openshift:4-13"), + vulnCPE: cpe.MustUnbind("cpe:/a:redhat:openshift:4"), + match: false, + }, + { + name: "AnyVersion", + recordCPE: cpe.MustUnbind("cpe:/a:redhat:openshift:4.13::el8"), + vulnCPE: cpe.MustUnbind("cpe:/a:redhat:openshift"), + match: true, + }, + { + name: "WildcardVersion", + recordCPE: cpe.MustUnbind("cpe:/a:redhat:openshift:4.13::el8"), + vulnCPE: cpe.MustUnbind("cpe:/a:redhat:openshift:4.%02::el8"), + match: true, + }, + { + name: "WildcardWrongMajor", + recordCPE: cpe.MustUnbind("cpe:/a:redhat:openshift:5.1::el8"), + vulnCPE: cpe.MustUnbind("cpe:/a:redhat:openshift:4.%02::el8"), + match: false, + }, } for _, tc := range testcases { diff --git a/rhel/rhcc/matcher.go b/rhel/rhcc/matcher.go index 55baebea8..f26d5ca13 100644 --- a/rhel/rhcc/matcher.go +++ b/rhel/rhcc/matcher.go @@ -47,7 +47,7 @@ func (*matcher) Vulnerable(ctx context.Context, record *claircore.IndexRecord, v slog.WarnContext(ctx, "unable to unbind repo CPE", "reason", err, "vulnerability name", vuln.Name) return false, nil } - if !cpe.Compare(vuln.Repo.CPE, record.Repository.CPE).IsSuperset() && !rhel.IsCPESubstringMatch(record.Repository.CPE, vuln.Repo.CPE) { + if !rhel.IsCPESubstringMatch(record.Repository.CPE, vuln.Repo.CPE) { return false, nil } }