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 :
+ * 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 :