From 866b61984d5eadf27d63bf7a4dad77aa90f0b4f1 Mon Sep 17 00:00:00 2001 From: Matt Van Horn <455140+mvanhorn@users.noreply.github.com> Date: Tue, 9 Jun 2026 01:48:05 -0700 Subject: [PATCH] fix: match Red Hat OS distro in OSV vulnerability analysis (#6156) Signed-off-by: Matt Van Horn <455140+mvanhorn@users.noreply.github.com> --- .../distrometadata/OsDistribution.java | 6 +- .../distrometadata/RedhatDistribution.java | 130 ++++++++++++++++ .../RedhatDistributionTest.java | 144 ++++++++++++++++++ .../vulndatasource/osv/OsvEcosystems.java | 7 + .../vulndatasource/osv/OsvEcosystemsTest.java | 12 ++ 5 files changed, 298 insertions(+), 1 deletion(-) create mode 100644 support/os-distro-metadata/src/main/java/org/dependencytrack/support/distrometadata/RedhatDistribution.java create mode 100644 support/os-distro-metadata/src/test/java/org/dependencytrack/support/distrometadata/RedhatDistributionTest.java diff --git a/support/os-distro-metadata/src/main/java/org/dependencytrack/support/distrometadata/OsDistribution.java b/support/os-distro-metadata/src/main/java/org/dependencytrack/support/distrometadata/OsDistribution.java index 2da5be67e5..7c21e717f4 100644 --- a/support/os-distro-metadata/src/main/java/org/dependencytrack/support/distrometadata/OsDistribution.java +++ b/support/os-distro-metadata/src/main/java/org/dependencytrack/support/distrometadata/OsDistribution.java @@ -26,7 +26,7 @@ /** * @since 4.14.0 */ -public sealed interface OsDistribution permits AlpineDistribution, DebianDistribution, UbuntuDistribution { +public sealed interface OsDistribution permits AlpineDistribution, DebianDistribution, RedhatDistribution, UbuntuDistribution { String purlQualifierValue(); @@ -60,6 +60,10 @@ public sealed interface OsDistribution permits AlpineDistribution, DebianDistrib } } + if ("rpm".equals(purl.getType()) && "redhat".equalsIgnoreCase(purl.getNamespace())) { + return RedhatDistribution.of(distroQualifier); + } + return null; } diff --git a/support/os-distro-metadata/src/main/java/org/dependencytrack/support/distrometadata/RedhatDistribution.java b/support/os-distro-metadata/src/main/java/org/dependencytrack/support/distrometadata/RedhatDistribution.java new file mode 100644 index 0000000000..8359f3ac51 --- /dev/null +++ b/support/os-distro-metadata/src/main/java/org/dependencytrack/support/distrometadata/RedhatDistribution.java @@ -0,0 +1,130 @@ +/* + * This file is part of Dependency-Track. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + * SPDX-License-Identifier: Apache-2.0 + * Copyright (c) OWASP Foundation. All Rights Reserved. + */ +package org.dependencytrack.support.distrometadata; + +import org.jspecify.annotations.Nullable; + +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +import static java.util.Objects.requireNonNull; + +/** + * Models a Red Hat product stream as scoped by the RHEL major version. + *

+ * Component PURLs carry the distro as a {@code distro} qualifier (e.g. + * {@code pkg:rpm/redhat/libsolv@0.7.24-3.el9?distro=redhat-9.7}), whereas OSV + * advisories encode the product stream in the ecosystem string via a + * {@code :} suffix (e.g. {@code Red Hat:rhel_aus:8.4::appstream}). Both forms + * carry a RHEL major version, which is the smallest reliably comparable scope: + * an advisory for RHEL 8 must not be matched against a RHEL 9 component. + * + * @since 4.14.0 + */ +public record RedhatDistribution(String majorVersion) implements OsDistribution { + + // The RHEL OS target embedded in a CPE's version-of-target field, e.g. + // "el8" in "openshift:4.18::el8" or "satellite:6.16::el8". This is the + // authoritative OS scope and takes precedence over a product version that + // happens to differ from the RHEL major (OpenShift 4.18 runs on RHEL 8). + private static final Pattern EL_TARGET_PATTERN = + Pattern.compile(".*\\bel(\\d+)\\b.*", Pattern.CASE_INSENSITIVE); + + // The major version of a base RHEL product itself, e.g. "8" in + // "rhel:8::appstream" or "rhel_aus:8.4::appstream". Used only when the product + // is a base RHEL stream whose version IS the RHEL major, and no explicit "elN" + // target is present. Deliberately excludes layered products (e.g. + // "rhel_application_stack:2", "rhel_atomic:7"), whose version is a product + // stream number rather than the RHEL major. + private static final Pattern RHEL_PRODUCT_PATTERN = + Pattern.compile("^(?:rhel|rhel_aus|rhel_eus|rhel_els|rhel_tus|rhel_e4s|enterprise_linux):(\\d+)(?:[.:].*)?$", + Pattern.CASE_INSENSITIVE); + + // The leading major version of a PURL "distro" qualifier value, e.g. "9" in + // "redhat-9.7" or "9.7". PURL qualifiers carry a bare RHEL version, not a CPE. + private static final Pattern PURL_VERSION_PATTERN = + Pattern.compile("^(\\d+)(?:\\..*)?$"); + + public RedhatDistribution { + requireNonNull(majorVersion, "majorVersion must not be null"); + } + + @Override + public String purlQualifierValue() { + return "redhat-" + majorVersion; + } + + @Override + public boolean matches(OsDistribution other) { + return other instanceof RedhatDistribution(final String otherMajorVersion) + && this.majorVersion.equals(otherMajorVersion); + } + + /** + * Resolves a Red Hat distro from a PURL {@code distro} qualifier value, + * e.g. {@code redhat-9.7} or {@code 9.7}. + */ + public static @Nullable RedhatDistribution of(@Nullable String qualifierValue) { + if (qualifierValue == null || qualifierValue.isEmpty()) { + return null; + } + + final String value = qualifierValue.toLowerCase().startsWith("redhat-") + ? qualifierValue.substring("redhat-".length()) + : qualifierValue; + + final Matcher matcher = PURL_VERSION_PATTERN.matcher(value); + if (!matcher.matches()) { + return null; + } + + return new RedhatDistribution(matcher.group(1)); + } + + /** + * Resolves a Red Hat distro from the {@code } suffix of an OSV + * Red Hat ecosystem string. The string is a CPE with the + * {@code cpe:/[oa]:redhat:} prefix removed, e.g. {@code rhel_aus:8.4::appstream}, + * {@code openshift:4.18::el8}, or {@code satellite:6.16::el8}. + *

+ * An explicit {@code elN} OS target (when present) is authoritative, since a + * product's own version (OpenShift 4.18, Satellite 6.16) is not the RHEL major. + * Otherwise the version is taken from a RHEL-family product + * ({@code rhel*}, {@code enterprise_linux}). Non-RHEL products without an + * {@code elN} target cannot be scoped to a RHEL major and return {@code null}. + */ + public static @Nullable RedhatDistribution ofCpe(@Nullable String cpe) { + if (cpe == null || cpe.isEmpty()) { + return null; + } + + final Matcher elMatcher = EL_TARGET_PATTERN.matcher(cpe); + if (elMatcher.matches()) { + return new RedhatDistribution(elMatcher.group(1)); + } + + final Matcher productMatcher = RHEL_PRODUCT_PATTERN.matcher(cpe); + if (productMatcher.matches()) { + return new RedhatDistribution(productMatcher.group(1)); + } + + return null; + } + +} diff --git a/support/os-distro-metadata/src/test/java/org/dependencytrack/support/distrometadata/RedhatDistributionTest.java b/support/os-distro-metadata/src/test/java/org/dependencytrack/support/distrometadata/RedhatDistributionTest.java new file mode 100644 index 0000000000..e82547a94b --- /dev/null +++ b/support/os-distro-metadata/src/test/java/org/dependencytrack/support/distrometadata/RedhatDistributionTest.java @@ -0,0 +1,144 @@ +/* + * This file is part of Dependency-Track. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + * SPDX-License-Identifier: Apache-2.0 + * Copyright (c) OWASP Foundation. All Rights Reserved. + */ +package org.dependencytrack.support.distrometadata; + +import com.github.packageurl.PackageURL; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.CsvSource; +import org.junit.jupiter.params.provider.NullAndEmptySource; +import org.junit.jupiter.params.provider.ValueSource; + +import static org.assertj.core.api.Assertions.assertThat; + +class RedhatDistributionTest { + + @ParameterizedTest + @CsvSource(value = { + "pkg:rpm/redhat/libsolv@0.7.24-3.el9?distro=redhat-9.7, redhat-9", + "pkg:rpm/redhat/libsolv@0.7.24-3.el9?distro=9.7, redhat-9", + "pkg:rpm/redhat/libsolv@0.7.24-3.el8?distro=redhat-8, redhat-8", + "pkg:rpm/redhat/libsolv@0.7.24-3.el8?distro=redhat-8.4, redhat-8", + }) + void shouldParseFromPurl(String purl, String expectedQualifier) throws Exception { + final OsDistribution distro = OsDistribution.of(new PackageURL(purl)); + assertThat(distro).isNotNull(); + assertThat(distro).isInstanceOf(RedhatDistribution.class); + assertThat(distro.purlQualifierValue()).isEqualTo(expectedQualifier); + } + + @ParameterizedTest + @CsvSource(value = { + "redhat-9.7, redhat-9", + "9.7, redhat-9", + "8, redhat-8", + }) + void shouldParseFromQualifierValue(String value, String expectedQualifier) { + final RedhatDistribution distro = RedhatDistribution.of(value); + assertThat(distro).isNotNull(); + assertThat(distro.purlQualifierValue()).isEqualTo(expectedQualifier); + } + + @ParameterizedTest + @CsvSource(value = { + // RHEL-family products carry the RHEL major in their version field. + "rhel_aus:8.4::appstream, redhat-8", + "rhel:9::appstream, redhat-9", + "rhel_eus:8.6::baseos, redhat-8", + "enterprise_linux:8::baseos, redhat-8", + // Layered products carry their own version; the RHEL major is the + // explicit "elN" OS target, NOT the product version (#6156 review). + "openshift:4.18::el8, redhat-8", + "openshift_container_platform:4.18::el9, redhat-9", + "satellite:6.16::el8, redhat-8", + "satellite_capsule:6.16::el8, redhat-8", + "rhel_sat:6.15::el8, redhat-8", + }) + void shouldParseFromCpe(String cpe, String expectedQualifier) { + final RedhatDistribution distro = RedhatDistribution.ofCpe(cpe); + assertThat(distro).isNotNull(); + assertThat(distro.purlQualifierValue()).isEqualTo(expectedQualifier); + } + + @ParameterizedTest + @NullAndEmptySource + @ValueSource(strings = { + "rhel_aus", // RHEL product, but no version + "appstream", // no product, no version + "noversion", + "openshift:4.18", // layered product without an elN OS target + "satellite:6.16", + "rhel_application_stack:2", // product stream version is NOT the RHEL major + "rhel_application_server:1", + "rhel_atomic:7", // RHEL Atomic stream, not a base RHEL major + }) + void shouldReturnNullForCpeWithoutResolvableRhelMajor(String cpe) { + assertThat(RedhatDistribution.ofCpe(cpe)).isNull(); + } + + @Test + void shouldMatchSameMajorVersion() throws Exception { + final OsDistribution component = OsDistribution.of( + new PackageURL("pkg:rpm/redhat/libsolv@0.7.24-3.el9?distro=redhat-9.7")); + final RedhatDistribution advisory = RedhatDistribution.ofCpe("rhel_aus:9.0::appstream"); + + assertThat(component).isNotNull(); + assertThat(advisory).isNotNull(); + assertThat(component.matches(advisory)).isTrue(); + } + + @Test + void shouldNotMatchDifferentMajorVersion() throws Exception { + // Regression for #6156: a RHEL 9 component must not match an advisory + // scoped to a RHEL 8 (e.g. el8sat) product stream. + final OsDistribution component = OsDistribution.of( + new PackageURL("pkg:rpm/redhat/libsolv@0.7.24-3.el9?distro=redhat-9.7")); + final RedhatDistribution advisory = RedhatDistribution.ofCpe("rhel_aus:8.0::baseos"); + + assertThat(component).isNotNull(); + assertThat(advisory).isNotNull(); + assertThat(component.matches(advisory)).isFalse(); + } + + @Test + void shouldReturnNullForRpmWithoutRedhatNamespace() throws Exception { + final var purl = new PackageURL("pkg:rpm/fedora/curl@8.5.0?distro=fedora-38"); + assertThat(OsDistribution.of(purl)).isNull(); + } + + @Test + void shouldReturnNullForRedhatPurlWithoutDistroQualifier() throws Exception { + final var purl = new PackageURL("pkg:rpm/redhat/libsolv@0.7.24-3.el9?arch=x86_64"); + assertThat(OsDistribution.of(purl)).isNull(); + } + + @Test + void shouldNotMatchRedhatWithAlpine() throws Exception { + final OsDistribution redhat = OsDistribution.of( + new PackageURL("pkg:rpm/redhat/libsolv@0.7.24-3.el9?distro=redhat-9.7")); + final OsDistribution alpine = OsDistribution.of( + new PackageURL("pkg:apk/alpine/curl@8.5.0?distro=3.16")); + + assertThat(redhat).isNotNull(); + assertThat(alpine).isNotNull(); + assertThat(redhat.matches(alpine)).isFalse(); + assertThat(alpine.matches(redhat)).isFalse(); + } + +} diff --git a/vuln-data-source/osv/src/main/java/org/dependencytrack/vulndatasource/osv/OsvEcosystems.java b/vuln-data-source/osv/src/main/java/org/dependencytrack/vulndatasource/osv/OsvEcosystems.java index f274d83754..c544977c3e 100644 --- a/vuln-data-source/osv/src/main/java/org/dependencytrack/vulndatasource/osv/OsvEcosystems.java +++ b/vuln-data-source/osv/src/main/java/org/dependencytrack/vulndatasource/osv/OsvEcosystems.java @@ -21,6 +21,7 @@ import org.dependencytrack.support.distrometadata.AlpineDistribution; import org.dependencytrack.support.distrometadata.DebianDistribution; import org.dependencytrack.support.distrometadata.OsDistribution; +import org.dependencytrack.support.distrometadata.RedhatDistribution; import org.dependencytrack.support.distrometadata.UbuntuDistribution; import org.jspecify.annotations.Nullable; @@ -51,6 +52,12 @@ private OsvEcosystems() { final String versionOrSeries = suffix.replaceAll(":(LTS|Pro)", ""); yield UbuntuDistribution.of(versionOrSeries); } + // Red Hat carries a : suffix scoping the RPM to a specific + // Red Hat product stream, e.g. "Red Hat:rhel_aus:8.4::appstream". + // The CPE is everything after the ecosystem name; its RHEL major + // version is the comparable scope. + // https://ossf.github.io/osv-schema/#defined-ecosystems + case "red hat", "redhat" -> RedhatDistribution.ofCpe(suffix); default -> null; }; } diff --git a/vuln-data-source/osv/src/test/java/org/dependencytrack/vulndatasource/osv/OsvEcosystemsTest.java b/vuln-data-source/osv/src/test/java/org/dependencytrack/vulndatasource/osv/OsvEcosystemsTest.java index f4343af113..591769b422 100644 --- a/vuln-data-source/osv/src/test/java/org/dependencytrack/vulndatasource/osv/OsvEcosystemsTest.java +++ b/vuln-data-source/osv/src/test/java/org/dependencytrack/vulndatasource/osv/OsvEcosystemsTest.java @@ -53,6 +53,13 @@ class OsvEcosystemsTest { "Alpine:v3.22, AlpineDistribution, alpine-3.22", "alpine:v3.18, AlpineDistribution, alpine-3.18", "Alpine:3.16, AlpineDistribution, alpine-3.16", + "Red Hat:rhel_aus:8.4::appstream, RedhatDistribution, redhat-8", + "Red Hat:rhel:9::appstream, RedhatDistribution, redhat-9", + "Red Hat:rhel_eus:8.6::baseos, RedhatDistribution, redhat-8", + "Red Hat:enterprise_linux:8::baseos, RedhatDistribution, redhat-8", + "Red Hat:openshift:4.18::el8, RedhatDistribution, redhat-8", + "Red Hat:satellite:6.16::el8, RedhatDistribution, redhat-8", + "redhat:rhel:9::appstream, RedhatDistribution, redhat-9", }) void shouldResolve(String ecosystem, String expectedType, String expectedQualifier) { final OsDistribution distro = OsvEcosystems.toOsDistribution(ecosystem); @@ -73,6 +80,11 @@ void shouldReturnNullForUnknownEcosystem() { assertThat(OsvEcosystems.toOsDistribution("Fedora:38")).isNull(); } + @Test + void shouldReturnNullForRedHatCpeWithoutVersion() { + assertThat(OsvEcosystems.toOsDistribution("Red Hat:rhel_aus")).isNull(); + } + @Test void shouldFallbackForUnknownDebianVersion() { final OsDistribution distro = OsvEcosystems.toOsDistribution("Debian:666");