From 9a52c46889f43779c9d6b70890e14857138b93a9 Mon Sep 17 00:00:00 2001 From: Ruben Romero Montes Date: Fri, 8 May 2026 14:02:16 +0200 Subject: [PATCH 1/6] feat: add PEP 691 registry client for PyPI trusted library recommendations Query the Red Hat Trusted Libraries Python registry using PEP 691 JSON API to generate trusted library recommendations for pkg:pypi packages. When a package exists in the registry but its SHA-256 hash differs from the SBOM hash, a recommendation PURL with repository_url qualifier is emitted. The feature is disabled when PYPI_REGISTRY_HOST is empty or unset. Addresses TC-4335 Co-Authored-By: Claude Opus 4.6 --- .../backend/ExhortIntegration.java | 1 + .../registry/Pep691Integration.java | 224 ++++++++++++++++++ .../model/registry/Pep691Response.java | 34 +++ src/main/resources/application.properties | 3 + .../extensions/WiremockExtension.java | 3 +- .../integration/AbstractAnalysisTest.java | 6 + .../integration/Pep691AnalysisTest.java | 123 ++++++++++ .../registry/Pep691IntegrationTest.java | 197 +++++++++++++++ .../model/registry/Pep691ResponseTest.java | 105 ++++++++ .../__files/depsdev/pypi_request.json | 7 + .../__files/depsdev/pypi_response.json | 123 ++++++++++ .../pypi-registry/requests_response.json | 20 ++ .../__files/reports/pypi_report.json | 107 +++++++++ .../__files/trustify/pypi_report.json | 50 ++++ .../__files/trustify/pypi_request.json | 6 + .../cyclonedx/pypi-sbom-with-hashes.json | 52 ++++ 16 files changed, 1060 insertions(+), 1 deletion(-) create mode 100644 src/main/java/io/github/guacsec/trustifyda/integration/registry/Pep691Integration.java create mode 100644 src/main/java/io/github/guacsec/trustifyda/model/registry/Pep691Response.java create mode 100644 src/test/java/io/github/guacsec/trustifyda/integration/Pep691AnalysisTest.java create mode 100644 src/test/java/io/github/guacsec/trustifyda/integration/registry/Pep691IntegrationTest.java create mode 100644 src/test/java/io/github/guacsec/trustifyda/model/registry/Pep691ResponseTest.java create mode 100644 src/test/resources/__files/depsdev/pypi_request.json create mode 100644 src/test/resources/__files/depsdev/pypi_response.json create mode 100644 src/test/resources/__files/pypi-registry/requests_response.json create mode 100644 src/test/resources/__files/reports/pypi_report.json create mode 100644 src/test/resources/__files/trustify/pypi_report.json create mode 100644 src/test/resources/__files/trustify/pypi_request.json create mode 100644 src/test/resources/cyclonedx/pypi-sbom-with-hashes.json diff --git a/src/main/java/io/github/guacsec/trustifyda/integration/backend/ExhortIntegration.java b/src/main/java/io/github/guacsec/trustifyda/integration/backend/ExhortIntegration.java index 04318b64..942d82e4 100644 --- a/src/main/java/io/github/guacsec/trustifyda/integration/backend/ExhortIntegration.java +++ b/src/main/java/io/github/guacsec/trustifyda/integration/backend/ExhortIntegration.java @@ -218,6 +218,7 @@ public void configure() { .process(this::processAnalysisRequest) .process(monitoringProcessor::processOriginalRequest) .to(direct("analyzeSbom")) + .to(direct("enrichPypiRecommendations")) .to(direct("report")) .to(direct("postProcessAnalysisRequest")); diff --git a/src/main/java/io/github/guacsec/trustifyda/integration/registry/Pep691Integration.java b/src/main/java/io/github/guacsec/trustifyda/integration/registry/Pep691Integration.java new file mode 100644 index 00000000..3c663090 --- /dev/null +++ b/src/main/java/io/github/guacsec/trustifyda/integration/registry/Pep691Integration.java @@ -0,0 +1,224 @@ +/* + * Copyright 2023-2025 Trustify Dependency Analytics Authors + * + * 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. + */ + +package io.github.guacsec.trustifyda.integration.registry; + +import java.util.Map; +import java.util.Optional; + +import org.apache.camel.Exchange; +import org.apache.camel.builder.endpoint.EndpointRouteBuilder; +import org.eclipse.microprofile.config.inject.ConfigProperty; +import org.jboss.logging.Logger; + +import com.fasterxml.jackson.databind.ObjectMapper; + +import io.github.guacsec.trustifyda.api.PackageRef; +import io.github.guacsec.trustifyda.api.v5.AnalysisReport; +import io.github.guacsec.trustifyda.api.v5.Remediation; +import io.github.guacsec.trustifyda.api.v5.RemediationTrustedContent; +import io.github.guacsec.trustifyda.integration.Constants; +import io.github.guacsec.trustifyda.model.DependencyTree; +import io.github.guacsec.trustifyda.model.registry.Pep691Response; + +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.inject.Inject; + +@ApplicationScoped +public class Pep691Integration extends EndpointRouteBuilder { + + private static final Logger LOGGER = Logger.getLogger(Pep691Integration.class); + + private static final String PEP691_ACCEPT = "application/vnd.pypi.simple.v1+json"; + private static final String PKG_PYPI_PREFIX = "pkg:pypi/"; + private static final String HASH_ALG_SHA256 = "SHA-256"; + + @ConfigProperty(name = "api.pypi.registry.host", defaultValue = "") + String registryHost; + + @ConfigProperty(name = "api.pypi.registry.timeout", defaultValue = "10s") + String timeout; + + @Inject ObjectMapper objectMapper; + + @Override + public void configure() { + // fmt:off + from(direct("enrichPypiRecommendations")) + .routeId("enrichPypiRecommendations") + .process(this::enrichRecommendations); + // fmt:on + } + + void enrichRecommendations(Exchange exchange) { + if (registryHost == null || registryHost.isBlank()) { + return; + } + + var body = exchange.getIn().getBody(); + if (!(body instanceof AnalysisReport report)) { + return; + } + + DependencyTree tree = + exchange.getProperty(Constants.DEPENDENCY_TREE_PROPERTY, DependencyTree.class); + if (tree == null || tree.componentHashes() == null || tree.componentHashes().isEmpty()) { + return; + } + + Map> hashes = tree.componentHashes(); + var providers = report.getProviders(); + if (providers == null || providers.isEmpty()) { + return; + } + + for (var providerEntry : providers.entrySet()) { + var providerReport = providerEntry.getValue(); + if (providerReport == null + || providerReport.getSources() == null + || providerReport.getSources().isEmpty()) { + continue; + } + + for (var sourceEntry : providerReport.getSources().entrySet()) { + var sourceReport = sourceEntry.getValue(); + if (sourceReport == null || sourceReport.getDependencies() == null) { + continue; + } + + for (var depReport : sourceReport.getDependencies()) { + if (depReport == null || depReport.getRef() == null) { + continue; + } + + String purlRef = depReport.getRef().ref(); + if (purlRef == null || !purlRef.startsWith(PKG_PYPI_PREFIX)) { + continue; + } + + if (depReport.getRecommendation() != null) { + continue; + } + + Map purlHashes = hashes.get(purlRef); + if (purlHashes == null || !purlHashes.containsKey(HASH_ALG_SHA256)) { + continue; + } + + String sbomSha256 = purlHashes.get(HASH_ALG_SHA256); + Optional recommendedRef = queryRegistryAndCompare(purlRef, sbomSha256); + if (recommendedRef.isEmpty()) { + continue; + } + + var trustedContent = new RemediationTrustedContent().ref(recommendedRef.get()); + + if (depReport.getIssues() != null) { + depReport + .getIssues() + .forEach( + issue -> issue.remediation(new Remediation().trustedContent(trustedContent))); + } + + depReport.recommendation(recommendedRef.get()); + } + } + } + } + + Optional queryRegistryAndCompare(String purlRef, String sbomSha256) { + try { + PackageRef ref = PackageRef.builder().purl(purlRef).build(); + String name = ref.name(); + String version = ref.version(); + if (name == null || version == null) { + return Optional.empty(); + } + + String normalizedName = name.toLowerCase().replace("-", "_").replace(".", "_"); + + String url = registryHost.replaceAll("/+$", "") + "/" + normalizedName + "/"; + + var httpRequest = + java.net.http.HttpRequest.newBuilder() + .uri(java.net.URI.create(url)) + .header("Accept", PEP691_ACCEPT) + .timeout(java.time.Duration.parse("PT" + timeout.toUpperCase())) + .GET() + .build(); + + var httpClient = + java.net.http.HttpClient.newBuilder() + .connectTimeout(java.time.Duration.parse("PT" + timeout.toUpperCase())) + .build(); + + var httpResponse = + httpClient.send(httpRequest, java.net.http.HttpResponse.BodyHandlers.ofString()); + + if (httpResponse.statusCode() != 200) { + LOGGER.debugf("PEP 691 registry returned %d for %s", httpResponse.statusCode(), name); + return Optional.empty(); + } + + Pep691Response response = objectMapper.readValue(httpResponse.body(), Pep691Response.class); + if (response == null || response.files() == null || response.files().isEmpty()) { + return Optional.empty(); + } + + String filePrefix = normalizedName + "-" + version; + for (var file : response.files()) { + if (file.filename() == null || file.hashes() == null) { + continue; + } + if (!matchesVersion(file.filename(), filePrefix)) { + continue; + } + String registrySha256 = file.hashes().get("sha256"); + if (registrySha256 != null && !registrySha256.equalsIgnoreCase(sbomSha256)) { + return Optional.of( + PackageRef.builder() + .purl( + PKG_PYPI_PREFIX + + name + + "@" + + version + + "?repository_url=" + + registryHost.replaceAll("/+$", "")) + .build()); + } + } + + return Optional.empty(); + } catch (Exception e) { + LOGGER.debugf("PEP 691 registry lookup failed for %s: %s", purlRef, e.getMessage()); + return Optional.empty(); + } + } + + private boolean matchesVersion(String filename, String prefix) { + String normalizedFilename = filename.toLowerCase().replace("-", "_").replace(".", "_"); + String normalizedPrefix = prefix.toLowerCase().replace("-", "_").replace(".", "_"); + if (!normalizedFilename.startsWith(normalizedPrefix)) { + return false; + } + if (normalizedFilename.length() == normalizedPrefix.length()) { + return true; + } + char next = filename.charAt(prefix.length()); + return next == '-' || next == '.' || next == '_'; + } +} diff --git a/src/main/java/io/github/guacsec/trustifyda/model/registry/Pep691Response.java b/src/main/java/io/github/guacsec/trustifyda/model/registry/Pep691Response.java new file mode 100644 index 00000000..b8fa0ba0 --- /dev/null +++ b/src/main/java/io/github/guacsec/trustifyda/model/registry/Pep691Response.java @@ -0,0 +1,34 @@ +/* + * Copyright 2023-2025 Trustify Dependency Analytics Authors + * + * 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. + */ + +package io.github.guacsec.trustifyda.model.registry; + +import java.util.List; +import java.util.Map; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; + +import io.quarkus.runtime.annotations.RegisterForReflection; + +@RegisterForReflection +@JsonIgnoreProperties(ignoreUnknown = true) +public record Pep691Response(String name, List files) { + + @RegisterForReflection + @JsonIgnoreProperties(ignoreUnknown = true) + public record FileInfo(String filename, String url, Map hashes) {} +} diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties index 18310ccf..0a9c7f35 100644 --- a/src/main/resources/application.properties +++ b/src/main/resources/application.properties @@ -18,6 +18,9 @@ report.remediation.template=https://deps.dev/__PACKAGE_TYPE__/__PACKAGE_NAME__/_ api.licenses.depsdev.host=https://api.deps.dev +api.pypi.registry.host=${PYPI_REGISTRY_HOST:} +api.pypi.registry.timeout=${PYPI_REGISTRY_TIMEOUT:10s} + ## Monitoring - Sentry # monitoring.enabled=true # monitoring.sentry.dsn=https://@app.glitchtip.com/ diff --git a/src/test/java/io/github/guacsec/trustifyda/extensions/WiremockExtension.java b/src/test/java/io/github/guacsec/trustifyda/extensions/WiremockExtension.java index 4141eb31..e47cdc7d 100644 --- a/src/test/java/io/github/guacsec/trustifyda/extensions/WiremockExtension.java +++ b/src/test/java/io/github/guacsec/trustifyda/extensions/WiremockExtension.java @@ -37,7 +37,8 @@ public Map start() { return Map.of( "provider.trustify.host", server.baseUrl(), - "api.licenses.depsdev.host", server.baseUrl()); + "api.licenses.depsdev.host", server.baseUrl(), + "api.pypi.registry.host", server.baseUrl()); } @Override diff --git a/src/test/java/io/github/guacsec/trustifyda/integration/AbstractAnalysisTest.java b/src/test/java/io/github/guacsec/trustifyda/integration/AbstractAnalysisTest.java index f04ceeb1..f0d8ba5f 100644 --- a/src/test/java/io/github/guacsec/trustifyda/integration/AbstractAnalysisTest.java +++ b/src/test/java/io/github/guacsec/trustifyda/integration/AbstractAnalysisTest.java @@ -444,6 +444,12 @@ protected String replaceMockedDepsDevSourceUrl(String body) { "\"sourceUrl\": \"http://localhost:(\\d+)\"", "\"sourceUrl\": \"https://api.deps.dev\""); } + protected String replaceMockedRegistryUrl(String body) { + return body.replaceAll( + "repository_url=http%3A%2F%2Flocalhost%3A(\\d+)", + "repository_url=https%3A%2F%2Fpackages.redhat.com%2Ftrusted-libraries%2Fpython"); + } + protected void verifyNoInteractions() { verifyNoInteractionsWithTrustify(); } diff --git a/src/test/java/io/github/guacsec/trustifyda/integration/Pep691AnalysisTest.java b/src/test/java/io/github/guacsec/trustifyda/integration/Pep691AnalysisTest.java new file mode 100644 index 00000000..c625ed42 --- /dev/null +++ b/src/test/java/io/github/guacsec/trustifyda/integration/Pep691AnalysisTest.java @@ -0,0 +1,123 @@ +/* + * Copyright 2023-2025 Trustify Dependency Analytics Authors + * + * 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. + */ + +package io.github.guacsec.trustifyda.integration; + +import static com.github.tomakehurst.wiremock.client.WireMock.aResponse; +import static com.github.tomakehurst.wiremock.client.WireMock.containing; +import static com.github.tomakehurst.wiremock.client.WireMock.equalTo; +import static com.github.tomakehurst.wiremock.client.WireMock.equalToJson; +import static com.github.tomakehurst.wiremock.client.WireMock.get; +import static com.github.tomakehurst.wiremock.client.WireMock.post; +import static com.github.tomakehurst.wiremock.client.WireMock.urlPathEqualTo; +import static io.github.guacsec.trustifyda.extensions.WiremockExtension.TRUSTIFY_TOKEN; +import static io.restassured.RestAssured.given; +import static org.apache.camel.Exchange.CONTENT_TYPE; + +import java.io.File; + +import org.apache.camel.Exchange; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import io.github.guacsec.trustifyda.extensions.OidcWiremockExtension; +import io.quarkus.test.common.QuarkusTestResource; +import io.quarkus.test.junit.QuarkusTest; + +import jakarta.ws.rs.core.MediaType; + +@QuarkusTest +@QuarkusTestResource(OidcWiremockExtension.class) +public class Pep691AnalysisTest extends AbstractAnalysisTest { + + @Override + @AfterEach + void resetMock() { + if (server != null) { + server.resetAll(); + OidcWiremockExtension.restubOidcEndpoints(server); + } + } + + @BeforeEach + void setupOidcStubs() { + if (server != null) { + OidcWiremockExtension.restubOidcEndpoints(server); + } + } + + @Test + public void testPypiRecommendations() throws Exception { + stubAllProviders(); + stubDepsDevTimeoutRequest(); + stubPypiTrustifyRequests(); + stubPep691RegistryRequests(); + + var body = + given() + .header(CONTENT_TYPE, Constants.CYCLONEDX_MEDIATYPE_JSON) + .header("Accept", MediaType.APPLICATION_JSON) + .body(loadPypiSBOMFile()) + .when() + .post("/api/v5/analysis") + .then() + .assertThat() + .statusCode(200) + .contentType(MediaType.APPLICATION_JSON) + .extract() + .body() + .asPrettyString(); + + body = replaceMockedDepsDevSourceUrl(body); + body = replaceMockedRegistryUrl(body); + assertJson("reports/pypi_report.json", body); + } + + private File loadPypiSBOMFile() { + return new File( + getClass().getClassLoader().getResource("cyclonedx/pypi-sbom-with-hashes.json").getPath()); + } + + private void stubPypiTrustifyRequests() { + server.stubFor( + post(Constants.TRUSTIFY_ANALYZE_PATH) + .withHeader( + Constants.AUTHORIZATION_HEADER, + equalTo("Bearer " + TRUSTIFY_TOKEN).or(equalTo("Bearer " + OK_TOKEN))) + .withHeader(Exchange.CONTENT_TYPE, containing(MediaType.APPLICATION_JSON)) + .withRequestBody( + equalToJson(loadFileAsString("__files/trustify/pypi_request.json"), true, false)) + .willReturn( + aResponse() + .withStatus(200) + .withHeader(Exchange.CONTENT_TYPE, MediaType.APPLICATION_JSON) + .withBodyFile("trustify/pypi_report.json"))); + OidcWiremockExtension.restubOidcEndpoints(server); + } + + private void stubPep691RegistryRequests() { + server.stubFor( + get(urlPathEqualTo("/requests/")) + .withHeader("Accept", equalTo("application/vnd.pypi.simple.v1+json")) + .willReturn( + aResponse() + .withStatus(200) + .withHeader(Exchange.CONTENT_TYPE, "application/vnd.pypi.simple.v1+json") + .withBodyFile("pypi-registry/requests_response.json"))); + } +} diff --git a/src/test/java/io/github/guacsec/trustifyda/integration/registry/Pep691IntegrationTest.java b/src/test/java/io/github/guacsec/trustifyda/integration/registry/Pep691IntegrationTest.java new file mode 100644 index 00000000..10569c02 --- /dev/null +++ b/src/test/java/io/github/guacsec/trustifyda/integration/registry/Pep691IntegrationTest.java @@ -0,0 +1,197 @@ +/* + * Copyright 2023-2025 Trustify Dependency Analytics Authors + * + * 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. + */ + +package io.github.guacsec.trustifyda.integration.registry; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +import java.util.Collections; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +import org.apache.camel.Exchange; +import org.apache.camel.Message; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import io.github.guacsec.trustifyda.api.PackageRef; +import io.github.guacsec.trustifyda.api.v5.AnalysisReport; +import io.github.guacsec.trustifyda.api.v5.DependencyReport; +import io.github.guacsec.trustifyda.api.v5.ProviderReport; +import io.github.guacsec.trustifyda.api.v5.Source; +import io.github.guacsec.trustifyda.integration.Constants; +import io.github.guacsec.trustifyda.model.DependencyTree; + +public class Pep691IntegrationTest { + + private Pep691Integration integration; + private Exchange exchange; + private Message message; + + @BeforeEach + void setUp() { + integration = new Pep691Integration(); + exchange = mock(Exchange.class); + message = mock(Message.class); + when(exchange.getIn()).thenReturn(message); + } + + @Test + void skipWhenRegistryHostEmpty() { + integration.registryHost = ""; + var report = new AnalysisReport(); + when(message.getBody()).thenReturn(report); + + integration.enrichRecommendations(exchange); + } + + @Test + void skipWhenRegistryHostNull() { + integration.registryHost = null; + var report = new AnalysisReport(); + when(message.getBody()).thenReturn(report); + + integration.enrichRecommendations(exchange); + } + + @Test + void skipWhenBodyNotAnalysisReport() { + integration.registryHost = "https://registry.example.com"; + when(message.getBody()).thenReturn("not a report"); + + integration.enrichRecommendations(exchange); + } + + @Test + void skipWhenNoComponentHashes() { + integration.registryHost = "https://registry.example.com"; + var report = buildReportWithPypiDep("pkg:pypi/requests@2.31.0"); + when(message.getBody()).thenReturn(report); + when(exchange.getProperty(Constants.DEPENDENCY_TREE_PROPERTY, DependencyTree.class)) + .thenReturn( + DependencyTree.builder() + .dependencies(Collections.emptyMap()) + .componentHashes(Collections.emptyMap()) + .build()); + + integration.enrichRecommendations(exchange); + + var dep = getFirstDep(report); + assertNull(dep.getRecommendation()); + } + + @Test + void skipNonPypiDependencies() { + integration.registryHost = "https://registry.example.com"; + var report = buildReportWithDep("pkg:npm/lodash@4.17.21"); + when(message.getBody()).thenReturn(report); + + Map> hashes = new HashMap<>(); + hashes.put("pkg:npm/lodash@4.17.21", Map.of("SHA-256", "abc123")); + when(exchange.getProperty(Constants.DEPENDENCY_TREE_PROPERTY, DependencyTree.class)) + .thenReturn( + DependencyTree.builder() + .dependencies(Collections.emptyMap()) + .componentHashes(hashes) + .build()); + + integration.enrichRecommendations(exchange); + + var dep = getFirstDep(report); + assertNull(dep.getRecommendation()); + } + + @Test + void skipWhenNoSha256Hash() { + integration.registryHost = "https://registry.example.com"; + var report = buildReportWithPypiDep("pkg:pypi/requests@2.31.0"); + when(message.getBody()).thenReturn(report); + + Map> hashes = new HashMap<>(); + hashes.put("pkg:pypi/requests@2.31.0", Map.of("MD5", "abc123")); + when(exchange.getProperty(Constants.DEPENDENCY_TREE_PROPERTY, DependencyTree.class)) + .thenReturn( + DependencyTree.builder() + .dependencies(Collections.emptyMap()) + .componentHashes(hashes) + .build()); + + integration.enrichRecommendations(exchange); + + var dep = getFirstDep(report); + assertNull(dep.getRecommendation()); + } + + @Test + void skipWhenRecommendationAlreadySet() { + integration.registryHost = "https://registry.example.com"; + var report = buildReportWithPypiDep("pkg:pypi/requests@2.31.0"); + var existingRec = PackageRef.builder().purl("pkg:pypi/requests@2.32.0").build(); + getFirstDep(report).recommendation(existingRec); + when(message.getBody()).thenReturn(report); + + Map> hashes = new HashMap<>(); + hashes.put("pkg:pypi/requests@2.31.0", Map.of("SHA-256", "abc123")); + when(exchange.getProperty(Constants.DEPENDENCY_TREE_PROPERTY, DependencyTree.class)) + .thenReturn( + DependencyTree.builder() + .dependencies(Collections.emptyMap()) + .componentHashes(hashes) + .build()); + + integration.enrichRecommendations(exchange); + + assertEquals(existingRec, getFirstDep(report).getRecommendation()); + } + + private AnalysisReport buildReportWithPypiDep(String purl) { + return buildReportWithDep(purl); + } + + private AnalysisReport buildReportWithDep(String purl) { + var dep = new DependencyReport(); + dep.ref(PackageRef.builder().purl(purl).build()); + + var source = new Source(); + source.dependencies(List.of(dep)); + + var providerReport = new ProviderReport(); + providerReport.sources(Map.of("source1", source)); + + var report = new AnalysisReport(); + report.providers(Map.of("provider1", providerReport)); + return report; + } + + private DependencyReport getFirstDep(AnalysisReport report) { + return report + .getProviders() + .values() + .iterator() + .next() + .getSources() + .values() + .iterator() + .next() + .getDependencies() + .get(0); + } +} diff --git a/src/test/java/io/github/guacsec/trustifyda/model/registry/Pep691ResponseTest.java b/src/test/java/io/github/guacsec/trustifyda/model/registry/Pep691ResponseTest.java new file mode 100644 index 00000000..550a0feb --- /dev/null +++ b/src/test/java/io/github/guacsec/trustifyda/model/registry/Pep691ResponseTest.java @@ -0,0 +1,105 @@ +/* + * Copyright 2023-2025 Trustify Dependency Analytics Authors + * + * 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. + */ + +package io.github.guacsec.trustifyda.model.registry; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; + +import org.junit.jupiter.api.Test; + +import com.fasterxml.jackson.databind.ObjectMapper; + +public class Pep691ResponseTest { + + private final ObjectMapper mapper = new ObjectMapper(); + + @Test + void deserializeFullResponse() throws Exception { + String json = + """ + { + "name": "requests", + "files": [ + { + "filename": "requests-2.31.0-py3-none-any.whl", + "url": "https://example.com/requests-2.31.0-py3-none-any.whl", + "hashes": { + "sha256": "abc123def456" + } + } + ] + } + """; + + Pep691Response response = mapper.readValue(json, Pep691Response.class); + + assertEquals("requests", response.name()); + assertNotNull(response.files()); + assertEquals(1, response.files().size()); + assertEquals("requests-2.31.0-py3-none-any.whl", response.files().get(0).filename()); + assertEquals("abc123def456", response.files().get(0).hashes().get("sha256")); + } + + @Test + void deserializeIgnoresUnknownFields() throws Exception { + String json = + """ + { + "meta": {"api-version": "1.1"}, + "name": "flask", + "files": [ + { + "filename": "flask-3.0.0.tar.gz", + "url": "https://example.com/flask-3.0.0.tar.gz", + "hashes": {"sha256": "deadbeef"}, + "requires-python": ">=3.8", + "dist-info-metadata": true + } + ] + } + """; + + Pep691Response response = mapper.readValue(json, Pep691Response.class); + + assertEquals("flask", response.name()); + assertEquals(1, response.files().size()); + assertEquals("deadbeef", response.files().get(0).hashes().get("sha256")); + } + + @Test + void deserializeMultipleFiles() throws Exception { + String json = + """ + { + "name": "pip", + "files": [ + {"filename": "pip-23.0.tar.gz", "url": "https://example.com/pip-23.0.tar.gz", "hashes": {"sha256": "aaa"}}, + {"filename": "pip-23.1.tar.gz", "url": "https://example.com/pip-23.1.tar.gz", "hashes": {"sha256": "bbb"}}, + {"filename": "pip-23.1-py3-none-any.whl", "url": "https://example.com/pip-23.1-py3-none-any.whl", "hashes": {"sha256": "ccc"}} + ] + } + """; + + Pep691Response response = mapper.readValue(json, Pep691Response.class); + + assertEquals(3, response.files().size()); + assertEquals("aaa", response.files().get(0).hashes().get("sha256")); + assertEquals("bbb", response.files().get(1).hashes().get("sha256")); + assertEquals("ccc", response.files().get(2).hashes().get("sha256")); + } +} diff --git a/src/test/resources/__files/depsdev/pypi_request.json b/src/test/resources/__files/depsdev/pypi_request.json new file mode 100644 index 00000000..ddba5f4e --- /dev/null +++ b/src/test/resources/__files/depsdev/pypi_request.json @@ -0,0 +1,7 @@ +{ + "requests" : [ { + "purl" : "pkg:pypi/flask@3.0.0" + }, { + "purl" : "pkg:pypi/requests@2.31.0" + } ] +} diff --git a/src/test/resources/__files/depsdev/pypi_response.json b/src/test/resources/__files/depsdev/pypi_response.json new file mode 100644 index 00000000..781a0eb1 --- /dev/null +++ b/src/test/resources/__files/depsdev/pypi_response.json @@ -0,0 +1,123 @@ +{ + "nextPageToken": "", + "responses": [ + { + "request": { + "purl": "pkg:pypi/flask@3.0.0" + }, + "result": { + "version": { + "advisoryKeys": [], + "attestations": [], + "isDefault": false, + "isDeprecated": false, + "licenseDetails": [ + { + "license": "BSD-3-Clause", + "spdx": "BSD-3-Clause" + } + ], + "licenses": [ + "BSD-3-Clause" + ], + "links": [ + { + "label": "SOURCE_REPO", + "url": "https://github.com/pallets/flask" + }, + { + "label": "HOMEPAGE", + "url": "https://flask.palletsprojects.com/" + } + ], + "publishedAt": "2023-09-30T00:00:00Z", + "purl": "pkg:pypi/flask@3.0.0", + "registries": [ + "https://pypi.org/simple/" + ], + "relatedProjects": [ + { + "projectKey": { + "id": "github.com/pallets/flask" + }, + "relationProvenance": "UNVERIFIED_METADATA", + "relationType": "SOURCE_REPO" + } + ], + "slsaProvenances": [], + "upstreamIdentifiers": [ + { + "packageName": "flask", + "source": "PYPI", + "versionString": "3.0.0" + } + ], + "versionKey": { + "name": "flask", + "system": "PYPI", + "version": "3.0.0" + } + } + } + }, + { + "request": { + "purl": "pkg:pypi/requests@2.31.0" + }, + "result": { + "version": { + "advisoryKeys": [], + "attestations": [], + "isDefault": false, + "isDeprecated": false, + "licenseDetails": [ + { + "license": "Apache-2.0", + "spdx": "Apache-2.0" + } + ], + "licenses": [ + "Apache-2.0" + ], + "links": [ + { + "label": "SOURCE_REPO", + "url": "https://github.com/psf/requests" + }, + { + "label": "HOMEPAGE", + "url": "https://requests.readthedocs.io" + } + ], + "publishedAt": "2023-05-22T00:00:00Z", + "purl": "pkg:pypi/requests@2.31.0", + "registries": [ + "https://pypi.org/simple/" + ], + "relatedProjects": [ + { + "projectKey": { + "id": "github.com/psf/requests" + }, + "relationProvenance": "UNVERIFIED_METADATA", + "relationType": "SOURCE_REPO" + } + ], + "slsaProvenances": [], + "upstreamIdentifiers": [ + { + "packageName": "requests", + "source": "PYPI", + "versionString": "2.31.0" + } + ], + "versionKey": { + "name": "requests", + "system": "PYPI", + "version": "2.31.0" + } + } + } + } + ] +} diff --git a/src/test/resources/__files/pypi-registry/requests_response.json b/src/test/resources/__files/pypi-registry/requests_response.json new file mode 100644 index 00000000..05df101c --- /dev/null +++ b/src/test/resources/__files/pypi-registry/requests_response.json @@ -0,0 +1,20 @@ +{ + "meta": {"api-version": "1.1"}, + "name": "requests", + "files": [ + { + "filename": "requests-2.31.0-py3-none-any.whl", + "url": "https://packages.redhat.com/api/pulp-content/requests-2.31.0-py3-none-any.whl", + "hashes": { + "sha256": "5c1ba4cdf158728aead706c29999090cafc6c08a7261d0884f203f0c6628db08" + } + }, + { + "filename": "requests-2.31.0.tar.gz", + "url": "https://packages.redhat.com/api/pulp-content/requests-2.31.0.tar.gz", + "hashes": { + "sha256": "30237cd9fffe9312aa27e9750d463f7fae71dd3671bf4db5e3a5d65f5e2e854d" + } + } + ] +} diff --git a/src/test/resources/__files/reports/pypi_report.json b/src/test/resources/__files/reports/pypi_report.json new file mode 100644 index 00000000..4cdaadaa --- /dev/null +++ b/src/test/resources/__files/reports/pypi_report.json @@ -0,0 +1,107 @@ +{ + "scanned": { + "total": 2, + "direct": 2, + "transitive": 0 + }, + "providers": { + "trustify": { + "status": { + "ok": true, + "name": "trustify", + "code": 200, + "message": "OK", + "warnings": { + + } + }, + "sources": { + "osv-github": { + "summary": { + "direct": 1, + "transitive": 0, + "total": 1, + "dependencies": 1, + "critical": 0, + "high": 0, + "medium": 1, + "low": 0, + "remediations": 0, + "recommendations": 0, + "unscanned": 0 + }, + "dependencies": [ + { + "ref": "pkg:pypi/requests@2.31.0", + "issues": [ + { + "id": "CVE-2024-35195", + "title": "Requests Session object does not verify requests after making first request with verify=False", + "source": "osv-github", + "cvssScore": 5.6, + "severity": "MEDIUM", + "cves": [ + "CVE-2024-35195" + ], + "unique": false, + "remediation": { + "trustedContent": { + "ref": "pkg:pypi/requests@2.31.0?repository_url=https%3A%2F%2Fpackages.redhat.com%2Ftrusted-libraries%2Fpython" + } + } + } + ], + "transitive": [ + + ], + "recommendation": "pkg:pypi/requests@2.31.0?repository_url=https%3A%2F%2Fpackages.redhat.com%2Ftrusted-libraries%2Fpython", + "highestVulnerability": { + "id": "CVE-2024-35195", + "title": "Requests Session object does not verify requests after making first request with verify=False", + "source": "osv-github", + "cvssScore": 5.6, + "severity": "MEDIUM", + "cves": [ + "CVE-2024-35195" + ], + "unique": false, + "remediation": { + "trustedContent": { + "ref": "pkg:pypi/requests@2.31.0?repository_url=https%3A%2F%2Fpackages.redhat.com%2Ftrusted-libraries%2Fpython" + } + } + } + } + ] + } + } + } + }, + "licenses": [ + { + "status": { + "ok": false, + "name": "deps.dev", + "code": 504, + "message": "Request timed out", + "warnings": { + + } + }, + "summary": { + "total": 0, + "concluded": 0, + "permissive": 0, + "weakCopyleft": 0, + "strongCopyleft": 0, + "unknown": 0, + "deprecated": 0, + "osiApproved": 0, + "fsfLibre": 0 + }, + "packages": { + + } + } + ] +} \ No newline at end of file diff --git a/src/test/resources/__files/trustify/pypi_report.json b/src/test/resources/__files/trustify/pypi_report.json new file mode 100644 index 00000000..2f434210 --- /dev/null +++ b/src/test/resources/__files/trustify/pypi_report.json @@ -0,0 +1,50 @@ +{ + "pkg:pypi/requests@2.31.0": { + "details": [ + { + "normative": true, + "identifier": "CVE-2024-35195", + "title": "Requests Session object does not verify requests after making first request with verify=False", + "description": "When making requests through a Requests Session, if the first request is made with verify=False to disable cert verification, all subsequent requests to the same host will continue to ignore cert verification regardless of changes to the value of verify.", + "reserved": "2024-05-20T00:00:00Z", + "published": "2024-05-20T00:00:00Z", + "modified": "2024-06-10T00:00:00Z", + "withdrawn": null, + "discovered": null, + "released": null, + "cwes": [ + "CWE-295" + ], + "status": { + "affected": [ + { + "uuid": "urn:uuid:a1b2c3d4-e5f6-7890-abcd-ef1234567890", + "identifier": "GHSA-9wx4-h78v-vm56", + "document_id": "GHSA-9wx4-h78v-vm56", + "issuer": null, + "published": "2024-05-20T20:15:00Z", + "modified": "2024-06-10T18:39:00Z", + "withdrawn": null, + "title": "Requests Session object does not verify requests after making first request with verify=False", + "labels": { + "importer": "osv-github", + "source": "https://github.com/github/advisory-database", + "file": "github-reviewed/2024/05/GHSA-9wx4-h78v-vm56/GHSA-9wx4-h78v-vm56.json", + "type": "osv" + }, + "scores": [ + { + "type": "3.1", + "value": 5.6, + "severity": "medium" + } + ] + } + ] + } + } + ], + "warnings": [] + }, + "warnings": [] +} diff --git a/src/test/resources/__files/trustify/pypi_request.json b/src/test/resources/__files/trustify/pypi_request.json new file mode 100644 index 00000000..adb92b66 --- /dev/null +++ b/src/test/resources/__files/trustify/pypi_request.json @@ -0,0 +1,6 @@ +{ + "purls": [ + "pkg:pypi/flask@3.0.0", + "pkg:pypi/requests@2.31.0" + ] +} diff --git a/src/test/resources/cyclonedx/pypi-sbom-with-hashes.json b/src/test/resources/cyclonedx/pypi-sbom-with-hashes.json new file mode 100644 index 00000000..165d6aa8 --- /dev/null +++ b/src/test/resources/cyclonedx/pypi-sbom-with-hashes.json @@ -0,0 +1,52 @@ +{ + "bomFormat": "CycloneDX", + "specVersion": "1.6", + "version": 1, + "metadata": { + "component": { + "bom-ref": "pkg:pypi/my-python-app@1.0.0", + "type": "application", + "name": "my-python-app", + "version": "1.0.0", + "purl": "pkg:pypi/my-python-app@1.0.0" + } + }, + "components": [ + { + "bom-ref": "pkg:pypi/my-python-app@1.0.0", + "type": "application", + "name": "my-python-app", + "version": "1.0.0", + "purl": "pkg:pypi/my-python-app@1.0.0" + }, + { + "bom-ref": "pkg:pypi/requests@2.31.0", + "type": "library", + "name": "requests", + "version": "2.31.0", + "purl": "pkg:pypi/requests@2.31.0", + "hashes": [ + { + "alg": "SHA-256", + "content": "a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2" + } + ] + }, + { + "bom-ref": "pkg:pypi/flask@3.0.0", + "type": "library", + "name": "flask", + "version": "3.0.0", + "purl": "pkg:pypi/flask@3.0.0" + } + ], + "dependencies": [ + { + "ref": "pkg:pypi/my-python-app@1.0.0", + "dependsOn": [ + "pkg:pypi/requests@2.31.0", + "pkg:pypi/flask@3.0.0" + ] + } + ] +} From 975d3aae8e1089a3f02c416c353047dcd103ca7d Mon Sep 17 00:00:00 2001 From: Ruben Romero Montes Date: Fri, 8 May 2026 15:59:45 +0200 Subject: [PATCH 2/6] refactor: use Camel HTTP integration for PEP 691 registry lookups Replace java.net.http.HttpClient with Camel HTTP component following the existing patterns used by TrustifyIntegration and LicensesIntegration. Uses ProducerTemplate to invoke a dedicated pep691Lookup sub-route with circuit breaker, proper header management, and .toD() for dynamic URLs. Also fixes two bugs: - Recommend packages even when SBOM has no SHA-256 hash (null hash = always recommend since we can't verify the artifact is already trusted) - Process all pypi packages from DependencyTree, not just those already in the vulnerability report (catches packages with no vulnerabilities) Co-Authored-By: Claude Opus 4.6 --- .../registry/Pep691Integration.java | 171 ++++++++++++++---- .../integration/Pep691AnalysisTest.java | 9 + .../registry/Pep691IntegrationTest.java | 18 +- .../__files/pypi-registry/flask_response.json | 20 ++ .../__files/reports/pypi_report.json | 14 +- src/test/resources/cyclonedx/pypi-sbom.json | 6 +- 6 files changed, 184 insertions(+), 54 deletions(-) create mode 100644 src/test/resources/__files/pypi-registry/flask_response.json diff --git a/src/main/java/io/github/guacsec/trustifyda/integration/registry/Pep691Integration.java b/src/main/java/io/github/guacsec/trustifyda/integration/registry/Pep691Integration.java index 3c663090..dbb96efd 100644 --- a/src/main/java/io/github/guacsec/trustifyda/integration/registry/Pep691Integration.java +++ b/src/main/java/io/github/guacsec/trustifyda/integration/registry/Pep691Integration.java @@ -17,10 +17,14 @@ package io.github.guacsec.trustifyda.integration.registry; +import java.util.HashSet; import java.util.Map; import java.util.Optional; +import java.util.Set; import org.apache.camel.Exchange; +import org.apache.camel.Message; +import org.apache.camel.ProducerTemplate; import org.apache.camel.builder.endpoint.EndpointRouteBuilder; import org.eclipse.microprofile.config.inject.ConfigProperty; import org.jboss.logging.Logger; @@ -29,6 +33,7 @@ import io.github.guacsec.trustifyda.api.PackageRef; import io.github.guacsec.trustifyda.api.v5.AnalysisReport; +import io.github.guacsec.trustifyda.api.v5.DependencyReport; import io.github.guacsec.trustifyda.api.v5.Remediation; import io.github.guacsec.trustifyda.api.v5.RemediationTrustedContent; import io.github.guacsec.trustifyda.integration.Constants; @@ -37,6 +42,7 @@ import jakarta.enterprise.context.ApplicationScoped; import jakarta.inject.Inject; +import jakarta.ws.rs.HttpMethod; @ApplicationScoped public class Pep691Integration extends EndpointRouteBuilder { @@ -46,6 +52,8 @@ public class Pep691Integration extends EndpointRouteBuilder { private static final String PEP691_ACCEPT = "application/vnd.pypi.simple.v1+json"; private static final String PKG_PYPI_PREFIX = "pkg:pypi/"; private static final String HASH_ALG_SHA256 = "SHA-256"; + private static final String PEP691_URL_PROPERTY = "pep691RegistryUrl"; + private static final String PEP691_PACKAGE_PROPERTY = "pep691PackageName"; @ConfigProperty(name = "api.pypi.registry.host", defaultValue = "") String registryHost; @@ -55,15 +63,52 @@ public class Pep691Integration extends EndpointRouteBuilder { @Inject ObjectMapper objectMapper; + @Inject ProducerTemplate producerTemplate; + @Override public void configure() { // fmt:off from(direct("enrichPypiRecommendations")) .routeId("enrichPypiRecommendations") .process(this::enrichRecommendations); + + from(direct("pep691Lookup")) + .routeId("pep691Lookup") + .circuitBreaker() + .faultToleranceConfiguration() + .timeoutEnabled(true) + .timeoutDuration(timeout) + .end() + .process(this::processPep691Request) + .toD("${exchangeProperty.pep691RegistryUrl}?throwExceptionOnFailure=false") + .onFallback() + .process(this::handleLookupFallback) + .end(); // fmt:on } + private void processPep691Request(Exchange exchange) { + Message message = exchange.getMessage(); + message.removeHeader(Exchange.HTTP_RAW_QUERY); + message.removeHeader(Exchange.HTTP_QUERY); + message.removeHeader(Exchange.HTTP_URI); + message.removeHeader(Exchange.HTTP_PATH); + message.removeHeader(Exchange.HTTP_HOST); + message.removeHeader(Constants.ACCEPT_ENCODING_HEADER); + message.removeHeader(Exchange.CONTENT_TYPE); + + message.setHeader(Exchange.HTTP_METHOD, HttpMethod.GET); + message.setHeader("Accept", PEP691_ACCEPT); + + String packageName = exchange.getProperty(PEP691_PACKAGE_PROPERTY, String.class); + message.setHeader(Exchange.HTTP_PATH, "/" + packageName + "/"); + } + + private void handleLookupFallback(Exchange exchange) { + exchange.getMessage().setHeader(Exchange.HTTP_RESPONSE_CODE, 504); + exchange.getMessage().setBody(null); + } + void enrichRecommendations(Exchange exchange) { if (registryHost == null || registryHost.isBlank()) { return; @@ -76,7 +121,7 @@ void enrichRecommendations(Exchange exchange) { DependencyTree tree = exchange.getProperty(Constants.DEPENDENCY_TREE_PROPERTY, DependencyTree.class); - if (tree == null || tree.componentHashes() == null || tree.componentHashes().isEmpty()) { + if (tree == null) { return; } @@ -86,6 +131,8 @@ void enrichRecommendations(Exchange exchange) { return; } + Set processedPurls = new HashSet<>(); + for (var providerEntry : providers.entrySet()) { var providerReport = providerEntry.getValue(); if (providerReport == null @@ -110,16 +157,14 @@ void enrichRecommendations(Exchange exchange) { continue; } + processedPurls.add(purlRef); + if (depReport.getRecommendation() != null) { continue; } Map purlHashes = hashes.get(purlRef); - if (purlHashes == null || !purlHashes.containsKey(HASH_ALG_SHA256)) { - continue; - } - - String sbomSha256 = purlHashes.get(HASH_ALG_SHA256); + String sbomSha256 = (purlHashes != null) ? purlHashes.get(HASH_ALG_SHA256) : null; Optional recommendedRef = queryRegistryAndCompare(purlRef, sbomSha256); if (recommendedRef.isEmpty()) { continue; @@ -138,6 +183,63 @@ void enrichRecommendations(Exchange exchange) { } } } + + for (PackageRef pkgRef : tree.getAll()) { + String purlRef = pkgRef.ref(); + if (purlRef == null || !purlRef.startsWith(PKG_PYPI_PREFIX)) { + continue; + } + if (processedPurls.contains(purlRef)) { + continue; + } + + Map purlHashes = hashes.get(purlRef); + String sbomSha256 = (purlHashes != null) ? purlHashes.get(HASH_ALG_SHA256) : null; + Optional recommendedRef = queryRegistryAndCompare(purlRef, sbomSha256); + if (recommendedRef.isEmpty()) { + continue; + } + + var depReport = new DependencyReport().ref(pkgRef).recommendation(recommendedRef.get()); + + for (var providerEntry : providers.entrySet()) { + var providerReport = providerEntry.getValue(); + if (providerReport == null + || providerReport.getSources() == null + || providerReport.getSources().isEmpty()) { + continue; + } + for (var sourceEntry : providerReport.getSources().entrySet()) { + var sourceReport = sourceEntry.getValue(); + if (sourceReport != null) { + sourceReport.addDependenciesItem(depReport); + break; + } + } + break; + } + } + + for (var providerEntry : providers.entrySet()) { + var providerReport = providerEntry.getValue(); + if (providerReport == null || providerReport.getSources() == null) { + continue; + } + for (var sourceEntry : providerReport.getSources().entrySet()) { + var sourceReport = sourceEntry.getValue(); + if (sourceReport == null + || sourceReport.getDependencies() == null + || sourceReport.getSummary() == null) { + continue; + } + int recCount = + (int) + sourceReport.getDependencies().stream() + .filter(d -> d.getRecommendation() != null) + .count(); + sourceReport.getSummary().setRecommendations(recCount); + } + } } Optional queryRegistryAndCompare(String purlRef, String sbomSha256) { @@ -150,37 +252,33 @@ Optional queryRegistryAndCompare(String purlRef, String sbomSha256) } String normalizedName = name.toLowerCase().replace("-", "_").replace(".", "_"); - - String url = registryHost.replaceAll("/+$", "") + "/" + normalizedName + "/"; - - var httpRequest = - java.net.http.HttpRequest.newBuilder() - .uri(java.net.URI.create(url)) - .header("Accept", PEP691_ACCEPT) - .timeout(java.time.Duration.parse("PT" + timeout.toUpperCase())) - .GET() - .build(); - - var httpClient = - java.net.http.HttpClient.newBuilder() - .connectTimeout(java.time.Duration.parse("PT" + timeout.toUpperCase())) - .build(); - - var httpResponse = - httpClient.send(httpRequest, java.net.http.HttpResponse.BodyHandlers.ofString()); - - if (httpResponse.statusCode() != 200) { - LOGGER.debugf("PEP 691 registry returned %d for %s", httpResponse.statusCode(), name); + String baseUrl = registryHost.replaceAll("/+$", ""); + + Exchange response = + producerTemplate.send( + "direct:pep691Lookup", + ex -> { + ex.setProperty(PEP691_URL_PROPERTY, baseUrl); + ex.setProperty(PEP691_PACKAGE_PROPERTY, normalizedName); + }); + + Integer statusCode = + response.getMessage().getHeader(Exchange.HTTP_RESPONSE_CODE, Integer.class); + if (statusCode == null || statusCode != 200) { + LOGGER.debugf("PEP 691 registry returned %s for %s", statusCode, name); return Optional.empty(); } - Pep691Response response = objectMapper.readValue(httpResponse.body(), Pep691Response.class); - if (response == null || response.files() == null || response.files().isEmpty()) { + String responseBody = response.getMessage().getBody(String.class); + Pep691Response pep691Response = objectMapper.readValue(responseBody, Pep691Response.class); + if (pep691Response == null + || pep691Response.files() == null + || pep691Response.files().isEmpty()) { return Optional.empty(); } String filePrefix = normalizedName + "-" + version; - for (var file : response.files()) { + for (var file : pep691Response.files()) { if (file.filename() == null || file.hashes() == null) { continue; } @@ -188,16 +286,13 @@ Optional queryRegistryAndCompare(String purlRef, String sbomSha256) continue; } String registrySha256 = file.hashes().get("sha256"); - if (registrySha256 != null && !registrySha256.equalsIgnoreCase(sbomSha256)) { + if (registrySha256 != null) { + if (sbomSha256 != null && registrySha256.equalsIgnoreCase(sbomSha256)) { + return Optional.empty(); + } return Optional.of( PackageRef.builder() - .purl( - PKG_PYPI_PREFIX - + name - + "@" - + version - + "?repository_url=" - + registryHost.replaceAll("/+$", "")) + .purl(PKG_PYPI_PREFIX + name + "@" + version + "?repository_url=" + baseUrl) .build()); } } diff --git a/src/test/java/io/github/guacsec/trustifyda/integration/Pep691AnalysisTest.java b/src/test/java/io/github/guacsec/trustifyda/integration/Pep691AnalysisTest.java index c625ed42..aac4a735 100644 --- a/src/test/java/io/github/guacsec/trustifyda/integration/Pep691AnalysisTest.java +++ b/src/test/java/io/github/guacsec/trustifyda/integration/Pep691AnalysisTest.java @@ -119,5 +119,14 @@ private void stubPep691RegistryRequests() { .withStatus(200) .withHeader(Exchange.CONTENT_TYPE, "application/vnd.pypi.simple.v1+json") .withBodyFile("pypi-registry/requests_response.json"))); + + server.stubFor( + get(urlPathEqualTo("/flask/")) + .withHeader("Accept", equalTo("application/vnd.pypi.simple.v1+json")) + .willReturn( + aResponse() + .withStatus(200) + .withHeader(Exchange.CONTENT_TYPE, "application/vnd.pypi.simple.v1+json") + .withBodyFile("pypi-registry/flask_response.json"))); } } diff --git a/src/test/java/io/github/guacsec/trustifyda/integration/registry/Pep691IntegrationTest.java b/src/test/java/io/github/guacsec/trustifyda/integration/registry/Pep691IntegrationTest.java index 10569c02..25ddf640 100644 --- a/src/test/java/io/github/guacsec/trustifyda/integration/registry/Pep691IntegrationTest.java +++ b/src/test/java/io/github/guacsec/trustifyda/integration/registry/Pep691IntegrationTest.java @@ -22,6 +22,7 @@ import static org.mockito.Mockito.mock; import static org.mockito.Mockito.when; +import java.util.ArrayList; import java.util.Collections; import java.util.HashMap; import java.util.List; @@ -39,6 +40,7 @@ import io.github.guacsec.trustifyda.api.v5.Source; import io.github.guacsec.trustifyda.integration.Constants; import io.github.guacsec.trustifyda.model.DependencyTree; +import io.github.guacsec.trustifyda.model.DirectDependency; public class Pep691IntegrationTest { @@ -81,7 +83,7 @@ void skipWhenBodyNotAnalysisReport() { } @Test - void skipWhenNoComponentHashes() { + void proceedWhenNoComponentHashes() { integration.registryHost = "https://registry.example.com"; var report = buildReportWithPypiDep("pkg:pypi/requests@2.31.0"); when(message.getBody()).thenReturn(report); @@ -104,14 +106,14 @@ void skipNonPypiDependencies() { var report = buildReportWithDep("pkg:npm/lodash@4.17.21"); when(message.getBody()).thenReturn(report); + PackageRef npmRef = PackageRef.builder().purl("pkg:npm/lodash@4.17.21").build(); + Map deps = new HashMap<>(); + deps.put(npmRef, DirectDependency.builder().ref(npmRef).build()); + Map> hashes = new HashMap<>(); hashes.put("pkg:npm/lodash@4.17.21", Map.of("SHA-256", "abc123")); when(exchange.getProperty(Constants.DEPENDENCY_TREE_PROPERTY, DependencyTree.class)) - .thenReturn( - DependencyTree.builder() - .dependencies(Collections.emptyMap()) - .componentHashes(hashes) - .build()); + .thenReturn(DependencyTree.builder().dependencies(deps).componentHashes(hashes).build()); integration.enrichRecommendations(exchange); @@ -120,7 +122,7 @@ void skipNonPypiDependencies() { } @Test - void skipWhenNoSha256Hash() { + void proceedWhenNoSha256Hash() { integration.registryHost = "https://registry.example.com"; var report = buildReportWithPypiDep("pkg:pypi/requests@2.31.0"); when(message.getBody()).thenReturn(report); @@ -171,7 +173,7 @@ private AnalysisReport buildReportWithDep(String purl) { dep.ref(PackageRef.builder().purl(purl).build()); var source = new Source(); - source.dependencies(List.of(dep)); + source.dependencies(new ArrayList<>(List.of(dep))); var providerReport = new ProviderReport(); providerReport.sources(Map.of("source1", source)); diff --git a/src/test/resources/__files/pypi-registry/flask_response.json b/src/test/resources/__files/pypi-registry/flask_response.json new file mode 100644 index 00000000..3e48de90 --- /dev/null +++ b/src/test/resources/__files/pypi-registry/flask_response.json @@ -0,0 +1,20 @@ +{ + "meta": {"api-version": "1.1"}, + "name": "flask", + "files": [ + { + "filename": "flask-3.0.0-py3-none-any.whl", + "url": "https://packages.redhat.com/api/pulp-content/flask-3.0.0-py3-none-any.whl", + "hashes": { + "sha256": "21128f47e4e3b9d29385149c0a8a0f4d14be5e42a3371f4e8a9b6c4e4edeab39" + } + }, + { + "filename": "flask-3.0.0.tar.gz", + "url": "https://packages.redhat.com/api/pulp-content/flask-3.0.0.tar.gz", + "hashes": { + "sha256": "7278c54e1bbff2a0b53e5c9fc729c0e35b7de9aabc6a9f7c9e1bbb3a6e3f0d9b" + } + } + ] +} diff --git a/src/test/resources/__files/reports/pypi_report.json b/src/test/resources/__files/reports/pypi_report.json index 4cdaadaa..9d86843c 100644 --- a/src/test/resources/__files/reports/pypi_report.json +++ b/src/test/resources/__files/reports/pypi_report.json @@ -12,7 +12,7 @@ "code": 200, "message": "OK", "warnings": { - + } }, "sources": { @@ -27,7 +27,7 @@ "medium": 1, "low": 0, "remediations": 0, - "recommendations": 0, + "recommendations": 2, "unscanned": 0 }, "dependencies": [ @@ -52,7 +52,7 @@ } ], "transitive": [ - + ], "recommendation": "pkg:pypi/requests@2.31.0?repository_url=https%3A%2F%2Fpackages.redhat.com%2Ftrusted-libraries%2Fpython", "highestVulnerability": { @@ -71,6 +71,10 @@ } } } + }, + { + "ref": "pkg:pypi/flask@3.0.0", + "recommendation": "pkg:pypi/flask@3.0.0?repository_url=https%3A%2F%2Fpackages.redhat.com%2Ftrusted-libraries%2Fpython" } ] } @@ -85,7 +89,7 @@ "code": 504, "message": "Request timed out", "warnings": { - + } }, "summary": { @@ -100,7 +104,7 @@ "fsfLibre": 0 }, "packages": { - + } } ] diff --git a/src/test/resources/cyclonedx/pypi-sbom.json b/src/test/resources/cyclonedx/pypi-sbom.json index 7afa74ba..e9e6e3c4 100644 --- a/src/test/resources/cyclonedx/pypi-sbom.json +++ b/src/test/resources/cyclonedx/pypi-sbom.json @@ -11,8 +11,8 @@ "type": "library", "bom-ref": "d574b448-4022-4dd8-9612-e6296d717e7f", "name": "amqp", - "version": "2.6.1", - "purl": "pkg:pypi/amqp@2.6.1" + "version": "5.3.1", + "purl": "pkg:pypi/amqp@5.3.1" }, { "type": "library", "bom-ref": "82565562-7694-473b-a0ff-631faf77d488", @@ -35,4 +35,4 @@ "ref": "ac3e331e-6848-11ee-8c99-0242ac120002", "dependsOn": [] }] -} \ No newline at end of file +} From 62df4fca0de6df63fa48f7fbf424111759c8ad73 Mon Sep 17 00:00:00 2001 From: Ruben Romero Montes Date: Fri, 8 May 2026 17:18:00 +0200 Subject: [PATCH 3/6] refactor: extract RegistryEnrichmentService for reusable registry enrichment MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Extract the three-pass enrichment logic from Pep691Integration into a stateless RegistryEnrichmentService that accepts a BiFunction for the registry query strategy. This enables adding Maven Central, Go proxy, npm, etc. without duplicating enrichment code — each new ecosystem reuses the service with its own query function. Co-Authored-By: Claude Opus 4.6 --- .../registry/Pep691Integration.java | 125 +-------- .../registry/RegistryEnrichmentService.java | 183 +++++++++++++ .../registry/Pep691IntegrationTest.java | 67 ----- .../RegistryEnrichmentServiceTest.java | 257 ++++++++++++++++++ 4 files changed, 443 insertions(+), 189 deletions(-) create mode 100644 src/main/java/io/github/guacsec/trustifyda/integration/registry/RegistryEnrichmentService.java create mode 100644 src/test/java/io/github/guacsec/trustifyda/integration/registry/RegistryEnrichmentServiceTest.java diff --git a/src/main/java/io/github/guacsec/trustifyda/integration/registry/Pep691Integration.java b/src/main/java/io/github/guacsec/trustifyda/integration/registry/Pep691Integration.java index dbb96efd..832b8713 100644 --- a/src/main/java/io/github/guacsec/trustifyda/integration/registry/Pep691Integration.java +++ b/src/main/java/io/github/guacsec/trustifyda/integration/registry/Pep691Integration.java @@ -17,10 +17,7 @@ package io.github.guacsec.trustifyda.integration.registry; -import java.util.HashSet; -import java.util.Map; import java.util.Optional; -import java.util.Set; import org.apache.camel.Exchange; import org.apache.camel.Message; @@ -33,9 +30,6 @@ import io.github.guacsec.trustifyda.api.PackageRef; import io.github.guacsec.trustifyda.api.v5.AnalysisReport; -import io.github.guacsec.trustifyda.api.v5.DependencyReport; -import io.github.guacsec.trustifyda.api.v5.Remediation; -import io.github.guacsec.trustifyda.api.v5.RemediationTrustedContent; import io.github.guacsec.trustifyda.integration.Constants; import io.github.guacsec.trustifyda.model.DependencyTree; import io.github.guacsec.trustifyda.model.registry.Pep691Response; @@ -51,7 +45,6 @@ public class Pep691Integration extends EndpointRouteBuilder { private static final String PEP691_ACCEPT = "application/vnd.pypi.simple.v1+json"; private static final String PKG_PYPI_PREFIX = "pkg:pypi/"; - private static final String HASH_ALG_SHA256 = "SHA-256"; private static final String PEP691_URL_PROPERTY = "pep691RegistryUrl"; private static final String PEP691_PACKAGE_PROPERTY = "pep691PackageName"; @@ -61,6 +54,8 @@ public class Pep691Integration extends EndpointRouteBuilder { @ConfigProperty(name = "api.pypi.registry.timeout", defaultValue = "10s") String timeout; + private final RegistryEnrichmentService enrichmentService = new RegistryEnrichmentService(); + @Inject ObjectMapper objectMapper; @Inject ProducerTemplate producerTemplate; @@ -125,121 +120,7 @@ void enrichRecommendations(Exchange exchange) { return; } - Map> hashes = tree.componentHashes(); - var providers = report.getProviders(); - if (providers == null || providers.isEmpty()) { - return; - } - - Set processedPurls = new HashSet<>(); - - for (var providerEntry : providers.entrySet()) { - var providerReport = providerEntry.getValue(); - if (providerReport == null - || providerReport.getSources() == null - || providerReport.getSources().isEmpty()) { - continue; - } - - for (var sourceEntry : providerReport.getSources().entrySet()) { - var sourceReport = sourceEntry.getValue(); - if (sourceReport == null || sourceReport.getDependencies() == null) { - continue; - } - - for (var depReport : sourceReport.getDependencies()) { - if (depReport == null || depReport.getRef() == null) { - continue; - } - - String purlRef = depReport.getRef().ref(); - if (purlRef == null || !purlRef.startsWith(PKG_PYPI_PREFIX)) { - continue; - } - - processedPurls.add(purlRef); - - if (depReport.getRecommendation() != null) { - continue; - } - - Map purlHashes = hashes.get(purlRef); - String sbomSha256 = (purlHashes != null) ? purlHashes.get(HASH_ALG_SHA256) : null; - Optional recommendedRef = queryRegistryAndCompare(purlRef, sbomSha256); - if (recommendedRef.isEmpty()) { - continue; - } - - var trustedContent = new RemediationTrustedContent().ref(recommendedRef.get()); - - if (depReport.getIssues() != null) { - depReport - .getIssues() - .forEach( - issue -> issue.remediation(new Remediation().trustedContent(trustedContent))); - } - - depReport.recommendation(recommendedRef.get()); - } - } - } - - for (PackageRef pkgRef : tree.getAll()) { - String purlRef = pkgRef.ref(); - if (purlRef == null || !purlRef.startsWith(PKG_PYPI_PREFIX)) { - continue; - } - if (processedPurls.contains(purlRef)) { - continue; - } - - Map purlHashes = hashes.get(purlRef); - String sbomSha256 = (purlHashes != null) ? purlHashes.get(HASH_ALG_SHA256) : null; - Optional recommendedRef = queryRegistryAndCompare(purlRef, sbomSha256); - if (recommendedRef.isEmpty()) { - continue; - } - - var depReport = new DependencyReport().ref(pkgRef).recommendation(recommendedRef.get()); - - for (var providerEntry : providers.entrySet()) { - var providerReport = providerEntry.getValue(); - if (providerReport == null - || providerReport.getSources() == null - || providerReport.getSources().isEmpty()) { - continue; - } - for (var sourceEntry : providerReport.getSources().entrySet()) { - var sourceReport = sourceEntry.getValue(); - if (sourceReport != null) { - sourceReport.addDependenciesItem(depReport); - break; - } - } - break; - } - } - - for (var providerEntry : providers.entrySet()) { - var providerReport = providerEntry.getValue(); - if (providerReport == null || providerReport.getSources() == null) { - continue; - } - for (var sourceEntry : providerReport.getSources().entrySet()) { - var sourceReport = sourceEntry.getValue(); - if (sourceReport == null - || sourceReport.getDependencies() == null - || sourceReport.getSummary() == null) { - continue; - } - int recCount = - (int) - sourceReport.getDependencies().stream() - .filter(d -> d.getRecommendation() != null) - .count(); - sourceReport.getSummary().setRecommendations(recCount); - } - } + enrichmentService.enrichReport(report, tree, PKG_PYPI_PREFIX, this::queryRegistryAndCompare); } Optional queryRegistryAndCompare(String purlRef, String sbomSha256) { diff --git a/src/main/java/io/github/guacsec/trustifyda/integration/registry/RegistryEnrichmentService.java b/src/main/java/io/github/guacsec/trustifyda/integration/registry/RegistryEnrichmentService.java new file mode 100644 index 00000000..2f752b45 --- /dev/null +++ b/src/main/java/io/github/guacsec/trustifyda/integration/registry/RegistryEnrichmentService.java @@ -0,0 +1,183 @@ +/* + * Copyright 2023-2025 Trustify Dependency Analytics Authors + * + * 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. + */ + +package io.github.guacsec.trustifyda.integration.registry; + +import java.util.HashSet; +import java.util.Map; +import java.util.Optional; +import java.util.Set; +import java.util.function.BiFunction; + +import io.github.guacsec.trustifyda.api.PackageRef; +import io.github.guacsec.trustifyda.api.v5.AnalysisReport; +import io.github.guacsec.trustifyda.api.v5.DependencyReport; +import io.github.guacsec.trustifyda.api.v5.ProviderReport; +import io.github.guacsec.trustifyda.api.v5.Remediation; +import io.github.guacsec.trustifyda.api.v5.RemediationTrustedContent; +import io.github.guacsec.trustifyda.model.DependencyTree; + +class RegistryEnrichmentService { + + private static final String HASH_ALG_SHA256 = "SHA-256"; + + void enrichReport( + AnalysisReport report, + DependencyTree tree, + String packagePrefix, + BiFunction> registryQuery) { + var providers = report.getProviders(); + if (providers == null || providers.isEmpty()) { + return; + } + + Map> hashes = tree.componentHashes(); + Set processedPurls = + enrichExistingDependencies(providers, hashes, packagePrefix, registryQuery); + enrichUnreportedDependencies( + providers, tree, hashes, packagePrefix, registryQuery, processedPurls); + recountRecommendations(providers); + } + + private Set enrichExistingDependencies( + Map providers, + Map> hashes, + String packagePrefix, + BiFunction> registryQuery) { + Set processedPurls = new HashSet<>(); + + for (var providerEntry : providers.entrySet()) { + var providerReport = providerEntry.getValue(); + if (providerReport == null + || providerReport.getSources() == null + || providerReport.getSources().isEmpty()) { + continue; + } + + for (var sourceEntry : providerReport.getSources().entrySet()) { + var sourceReport = sourceEntry.getValue(); + if (sourceReport == null || sourceReport.getDependencies() == null) { + continue; + } + + for (var depReport : sourceReport.getDependencies()) { + if (depReport == null || depReport.getRef() == null) { + continue; + } + + String purlRef = depReport.getRef().ref(); + if (purlRef == null || !purlRef.startsWith(packagePrefix)) { + continue; + } + + processedPurls.add(purlRef); + + if (depReport.getRecommendation() != null) { + continue; + } + + Map purlHashes = hashes.get(purlRef); + String sbomSha256 = (purlHashes != null) ? purlHashes.get(HASH_ALG_SHA256) : null; + Optional recommendedRef = registryQuery.apply(purlRef, sbomSha256); + if (recommendedRef.isEmpty()) { + continue; + } + + var trustedContent = new RemediationTrustedContent().ref(recommendedRef.get()); + + if (depReport.getIssues() != null) { + depReport + .getIssues() + .forEach( + issue -> issue.remediation(new Remediation().trustedContent(trustedContent))); + } + + depReport.recommendation(recommendedRef.get()); + } + } + } + + return processedPurls; + } + + private void enrichUnreportedDependencies( + Map providers, + DependencyTree tree, + Map> hashes, + String packagePrefix, + BiFunction> registryQuery, + Set processedPurls) { + for (PackageRef pkgRef : tree.getAll()) { + String purlRef = pkgRef.ref(); + if (purlRef == null || !purlRef.startsWith(packagePrefix)) { + continue; + } + if (processedPurls.contains(purlRef)) { + continue; + } + + Map purlHashes = hashes.get(purlRef); + String sbomSha256 = (purlHashes != null) ? purlHashes.get(HASH_ALG_SHA256) : null; + Optional recommendedRef = registryQuery.apply(purlRef, sbomSha256); + if (recommendedRef.isEmpty()) { + continue; + } + + var depReport = new DependencyReport().ref(pkgRef).recommendation(recommendedRef.get()); + + for (var providerEntry : providers.entrySet()) { + var providerReport = providerEntry.getValue(); + if (providerReport == null + || providerReport.getSources() == null + || providerReport.getSources().isEmpty()) { + continue; + } + for (var sourceEntry : providerReport.getSources().entrySet()) { + var sourceReport = sourceEntry.getValue(); + if (sourceReport != null) { + sourceReport.addDependenciesItem(depReport); + break; + } + } + break; + } + } + } + + private void recountRecommendations(Map providers) { + for (var providerEntry : providers.entrySet()) { + var providerReport = providerEntry.getValue(); + if (providerReport == null || providerReport.getSources() == null) { + continue; + } + for (var sourceEntry : providerReport.getSources().entrySet()) { + var sourceReport = sourceEntry.getValue(); + if (sourceReport == null + || sourceReport.getDependencies() == null + || sourceReport.getSummary() == null) { + continue; + } + int recCount = + (int) + sourceReport.getDependencies().stream() + .filter(d -> d.getRecommendation() != null) + .count(); + sourceReport.getSummary().setRecommendations(recCount); + } + } + } +} diff --git a/src/test/java/io/github/guacsec/trustifyda/integration/registry/Pep691IntegrationTest.java b/src/test/java/io/github/guacsec/trustifyda/integration/registry/Pep691IntegrationTest.java index 25ddf640..5b926a65 100644 --- a/src/test/java/io/github/guacsec/trustifyda/integration/registry/Pep691IntegrationTest.java +++ b/src/test/java/io/github/guacsec/trustifyda/integration/registry/Pep691IntegrationTest.java @@ -17,14 +17,12 @@ package io.github.guacsec.trustifyda.integration.registry; -import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertNull; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.when; import java.util.ArrayList; import java.util.Collections; -import java.util.HashMap; import java.util.List; import java.util.Map; @@ -40,7 +38,6 @@ import io.github.guacsec.trustifyda.api.v5.Source; import io.github.guacsec.trustifyda.integration.Constants; import io.github.guacsec.trustifyda.model.DependencyTree; -import io.github.guacsec.trustifyda.model.DirectDependency; public class Pep691IntegrationTest { @@ -100,70 +97,6 @@ void proceedWhenNoComponentHashes() { assertNull(dep.getRecommendation()); } - @Test - void skipNonPypiDependencies() { - integration.registryHost = "https://registry.example.com"; - var report = buildReportWithDep("pkg:npm/lodash@4.17.21"); - when(message.getBody()).thenReturn(report); - - PackageRef npmRef = PackageRef.builder().purl("pkg:npm/lodash@4.17.21").build(); - Map deps = new HashMap<>(); - deps.put(npmRef, DirectDependency.builder().ref(npmRef).build()); - - Map> hashes = new HashMap<>(); - hashes.put("pkg:npm/lodash@4.17.21", Map.of("SHA-256", "abc123")); - when(exchange.getProperty(Constants.DEPENDENCY_TREE_PROPERTY, DependencyTree.class)) - .thenReturn(DependencyTree.builder().dependencies(deps).componentHashes(hashes).build()); - - integration.enrichRecommendations(exchange); - - var dep = getFirstDep(report); - assertNull(dep.getRecommendation()); - } - - @Test - void proceedWhenNoSha256Hash() { - integration.registryHost = "https://registry.example.com"; - var report = buildReportWithPypiDep("pkg:pypi/requests@2.31.0"); - when(message.getBody()).thenReturn(report); - - Map> hashes = new HashMap<>(); - hashes.put("pkg:pypi/requests@2.31.0", Map.of("MD5", "abc123")); - when(exchange.getProperty(Constants.DEPENDENCY_TREE_PROPERTY, DependencyTree.class)) - .thenReturn( - DependencyTree.builder() - .dependencies(Collections.emptyMap()) - .componentHashes(hashes) - .build()); - - integration.enrichRecommendations(exchange); - - var dep = getFirstDep(report); - assertNull(dep.getRecommendation()); - } - - @Test - void skipWhenRecommendationAlreadySet() { - integration.registryHost = "https://registry.example.com"; - var report = buildReportWithPypiDep("pkg:pypi/requests@2.31.0"); - var existingRec = PackageRef.builder().purl("pkg:pypi/requests@2.32.0").build(); - getFirstDep(report).recommendation(existingRec); - when(message.getBody()).thenReturn(report); - - Map> hashes = new HashMap<>(); - hashes.put("pkg:pypi/requests@2.31.0", Map.of("SHA-256", "abc123")); - when(exchange.getProperty(Constants.DEPENDENCY_TREE_PROPERTY, DependencyTree.class)) - .thenReturn( - DependencyTree.builder() - .dependencies(Collections.emptyMap()) - .componentHashes(hashes) - .build()); - - integration.enrichRecommendations(exchange); - - assertEquals(existingRec, getFirstDep(report).getRecommendation()); - } - private AnalysisReport buildReportWithPypiDep(String purl) { return buildReportWithDep(purl); } diff --git a/src/test/java/io/github/guacsec/trustifyda/integration/registry/RegistryEnrichmentServiceTest.java b/src/test/java/io/github/guacsec/trustifyda/integration/registry/RegistryEnrichmentServiceTest.java new file mode 100644 index 00000000..87530170 --- /dev/null +++ b/src/test/java/io/github/guacsec/trustifyda/integration/registry/RegistryEnrichmentServiceTest.java @@ -0,0 +1,257 @@ +/* + * Copyright 2023-2025 Trustify Dependency Analytics Authors + * + * 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. + */ + +package io.github.guacsec.trustifyda.integration.registry; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.function.BiFunction; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import io.github.guacsec.trustifyda.api.PackageRef; +import io.github.guacsec.trustifyda.api.v5.AnalysisReport; +import io.github.guacsec.trustifyda.api.v5.DependencyReport; +import io.github.guacsec.trustifyda.api.v5.Issue; +import io.github.guacsec.trustifyda.api.v5.ProviderReport; +import io.github.guacsec.trustifyda.api.v5.Source; +import io.github.guacsec.trustifyda.api.v5.SourceSummary; +import io.github.guacsec.trustifyda.model.DependencyTree; +import io.github.guacsec.trustifyda.model.DirectDependency; + +public class RegistryEnrichmentServiceTest { + + private static final String PKG_PYPI_PREFIX = "pkg:pypi/"; + + private RegistryEnrichmentService service; + private BiFunction> alwaysRecommend; + private BiFunction> neverRecommend; + + @BeforeEach + void setUp() { + service = new RegistryEnrichmentService(); + + alwaysRecommend = + (purl, sha) -> + Optional.of( + PackageRef.builder() + .purl(purl + "?repository_url=https://registry.example.com") + .build()); + + neverRecommend = (purl, sha) -> Optional.empty(); + } + + @Test + void enrichExistingDepsWithMatchingPrefix() { + var report = buildReportWithPypiDep("pkg:pypi/requests@2.31.0"); + var tree = buildTree("pkg:pypi/requests@2.31.0", Map.of("SHA-256", "abc123")); + + service.enrichReport(report, tree, PKG_PYPI_PREFIX, alwaysRecommend); + + var dep = getFirstDep(report); + assertNotNull(dep.getRecommendation()); + assertEquals( + "pkg:pypi/requests@2.31.0?repository_url=https%3A%2F%2Fregistry.example.com", + dep.getRecommendation().ref()); + } + + @Test + void skipDepsWithNonMatchingPrefix() { + var report = buildReportWithDep("pkg:npm/lodash@4.17.21"); + var tree = buildTree("pkg:npm/lodash@4.17.21", Map.of("SHA-256", "abc123")); + + service.enrichReport(report, tree, PKG_PYPI_PREFIX, alwaysRecommend); + + var dep = getFirstDep(report); + assertNull(dep.getRecommendation()); + } + + @Test + void skipDepsWithExistingRecommendation() { + var report = buildReportWithPypiDep("pkg:pypi/requests@2.31.0"); + var existingRec = PackageRef.builder().purl("pkg:pypi/requests@2.32.0").build(); + getFirstDep(report).recommendation(existingRec); + + var tree = buildTree("pkg:pypi/requests@2.31.0", Map.of("SHA-256", "abc123")); + + service.enrichReport(report, tree, PKG_PYPI_PREFIX, alwaysRecommend); + + assertEquals(existingRec, getFirstDep(report).getRecommendation()); + } + + @Test + void enrichUnreportedTreeDeps() { + var report = buildReportWithPypiDep("pkg:pypi/requests@2.31.0"); + + PackageRef requestsRef = PackageRef.builder().purl("pkg:pypi/requests@2.31.0").build(); + PackageRef flaskRef = PackageRef.builder().purl("pkg:pypi/flask@3.0.0").build(); + Map deps = new HashMap<>(); + deps.put(requestsRef, DirectDependency.builder().ref(requestsRef).build()); + deps.put(flaskRef, DirectDependency.builder().ref(flaskRef).build()); + + var tree = + DependencyTree.builder().dependencies(deps).componentHashes(Collections.emptyMap()).build(); + + service.enrichReport(report, tree, PKG_PYPI_PREFIX, alwaysRecommend); + + var allDeps = + report + .getProviders() + .values() + .iterator() + .next() + .getSources() + .values() + .iterator() + .next() + .getDependencies(); + assertEquals(2, allDeps.size()); + + var flaskDep = allDeps.stream().filter(d -> d.getRef().ref().contains("flask")).findFirst(); + assertNotNull(flaskDep.orElse(null)); + assertNotNull(flaskDep.get().getRecommendation()); + } + + @Test + void recountRecommendations() { + var report = buildReportWithPypiDep("pkg:pypi/requests@2.31.0"); + var source = + report.getProviders().values().iterator().next().getSources().values().iterator().next(); + source.summary(new SourceSummary().recommendations(0)); + + var tree = buildTree("pkg:pypi/requests@2.31.0", Map.of("SHA-256", "abc123")); + + service.enrichReport(report, tree, PKG_PYPI_PREFIX, alwaysRecommend); + + assertEquals(1, source.getSummary().getRecommendations()); + } + + @Test + void setIssueRemediationAlongsideRecommendation() { + var report = buildReportWithPypiDep("pkg:pypi/requests@2.31.0"); + var dep = getFirstDep(report); + dep.issues(new ArrayList<>(List.of(new Issue().id("CVE-2024-35195")))); + + var tree = buildTree("pkg:pypi/requests@2.31.0", Map.of("SHA-256", "abc123")); + + service.enrichReport(report, tree, PKG_PYPI_PREFIX, alwaysRecommend); + + assertNotNull(dep.getRecommendation()); + var issue = dep.getIssues().get(0); + assertNotNull(issue.getRemediation()); + assertNotNull(issue.getRemediation().getTrustedContent()); + assertEquals(dep.getRecommendation(), issue.getRemediation().getTrustedContent().getRef()); + } + + @Test + void noEnrichmentWhenRegistryReturnsEmpty() { + var report = buildReportWithPypiDep("pkg:pypi/requests@2.31.0"); + var tree = buildTree("pkg:pypi/requests@2.31.0", Map.of("SHA-256", "abc123")); + + service.enrichReport(report, tree, PKG_PYPI_PREFIX, neverRecommend); + + assertNull(getFirstDep(report).getRecommendation()); + } + + @Test + void handleEmptyProviders() { + var report = new AnalysisReport(); + var tree = buildTree("pkg:pypi/requests@2.31.0", Map.of("SHA-256", "abc123")); + + service.enrichReport(report, tree, PKG_PYPI_PREFIX, alwaysRecommend); + + assertNotNull(report.getProviders()); + assertEquals(0, report.getProviders().size()); + } + + @Test + void passHashToRegistryQuery() { + var report = buildReportWithPypiDep("pkg:pypi/requests@2.31.0"); + + Map> hashes = new HashMap<>(); + hashes.put("pkg:pypi/requests@2.31.0", Map.of("SHA-256", "expected-hash")); + + PackageRef requestsRef = PackageRef.builder().purl("pkg:pypi/requests@2.31.0").build(); + Map deps = new HashMap<>(); + deps.put(requestsRef, DirectDependency.builder().ref(requestsRef).build()); + var tree = DependencyTree.builder().dependencies(deps).componentHashes(hashes).build(); + + String[] capturedHash = new String[1]; + BiFunction> capturingQuery = + (purl, sha) -> { + capturedHash[0] = sha; + return Optional.empty(); + }; + + service.enrichReport(report, tree, PKG_PYPI_PREFIX, capturingQuery); + + assertEquals("expected-hash", capturedHash[0]); + } + + private AnalysisReport buildReportWithPypiDep(String purl) { + return buildReportWithDep(purl); + } + + private AnalysisReport buildReportWithDep(String purl) { + var dep = new DependencyReport(); + dep.ref(PackageRef.builder().purl(purl).build()); + + var source = new Source(); + source.dependencies(new ArrayList<>(List.of(dep))); + + var providerReport = new ProviderReport(); + providerReport.sources(Map.of("source1", source)); + + var report = new AnalysisReport(); + report.providers(Map.of("provider1", providerReport)); + return report; + } + + private DependencyTree buildTree(String purl, Map hashMap) { + PackageRef ref = PackageRef.builder().purl(purl).build(); + Map deps = new HashMap<>(); + deps.put(ref, DirectDependency.builder().ref(ref).build()); + + Map> hashes = new HashMap<>(); + hashes.put(purl, new HashMap<>(hashMap)); + + return DependencyTree.builder().dependencies(deps).componentHashes(hashes).build(); + } + + private DependencyReport getFirstDep(AnalysisReport report) { + return report + .getProviders() + .values() + .iterator() + .next() + .getSources() + .values() + .iterator() + .next() + .getDependencies() + .get(0); + } +} From de0778c5cfb71407ac391a46c8a964511e987c27 Mon Sep 17 00:00:00 2001 From: Ruben Romero Montes Date: Fri, 8 May 2026 20:55:28 +0200 Subject: [PATCH 4/6] refactor: introduce TrustedLibrariesIntegration for scalable registry enrichment MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace hardcoded enrichPypiRecommendations route with a CDI-based discovery pattern. TrustedLibrariesIntegration discovers all RegistryIntegration beans, runs enabled ones sequentially with exception isolation. Adding a new ecosystem (Maven, Go, npm) now requires only one new class — zero changes to existing code. Co-Authored-By: Claude Opus 4.6 --- .../backend/ExhortIntegration.java | 2 +- .../registry/Pep691Integration.java | 35 ++-- .../registry/RegistryIntegration.java | 28 ++++ .../registry/TrustedLibrariesIntegration.java | 72 +++++++++ .../registry/Pep691IntegrationTest.java | 52 ++---- .../TrustedLibrariesIntegrationTest.java | 149 ++++++++++++++++++ 6 files changed, 276 insertions(+), 62 deletions(-) create mode 100644 src/main/java/io/github/guacsec/trustifyda/integration/registry/RegistryIntegration.java create mode 100644 src/main/java/io/github/guacsec/trustifyda/integration/registry/TrustedLibrariesIntegration.java create mode 100644 src/test/java/io/github/guacsec/trustifyda/integration/registry/TrustedLibrariesIntegrationTest.java diff --git a/src/main/java/io/github/guacsec/trustifyda/integration/backend/ExhortIntegration.java b/src/main/java/io/github/guacsec/trustifyda/integration/backend/ExhortIntegration.java index 942d82e4..55b54059 100644 --- a/src/main/java/io/github/guacsec/trustifyda/integration/backend/ExhortIntegration.java +++ b/src/main/java/io/github/guacsec/trustifyda/integration/backend/ExhortIntegration.java @@ -218,7 +218,7 @@ public void configure() { .process(this::processAnalysisRequest) .process(monitoringProcessor::processOriginalRequest) .to(direct("analyzeSbom")) - .to(direct("enrichPypiRecommendations")) + .to(direct("enrichTrustedLibraries")) .to(direct("report")) .to(direct("postProcessAnalysisRequest")); diff --git a/src/main/java/io/github/guacsec/trustifyda/integration/registry/Pep691Integration.java b/src/main/java/io/github/guacsec/trustifyda/integration/registry/Pep691Integration.java index 832b8713..8e8b8f94 100644 --- a/src/main/java/io/github/guacsec/trustifyda/integration/registry/Pep691Integration.java +++ b/src/main/java/io/github/guacsec/trustifyda/integration/registry/Pep691Integration.java @@ -39,7 +39,7 @@ import jakarta.ws.rs.HttpMethod; @ApplicationScoped -public class Pep691Integration extends EndpointRouteBuilder { +public class Pep691Integration extends EndpointRouteBuilder implements RegistryIntegration { private static final Logger LOGGER = Logger.getLogger(Pep691Integration.class); @@ -60,13 +60,19 @@ public class Pep691Integration extends EndpointRouteBuilder { @Inject ProducerTemplate producerTemplate; + @Override + public boolean isEnabled() { + return registryHost != null && !registryHost.isBlank(); + } + + @Override + public void enrich(AnalysisReport report, DependencyTree tree) { + enrichmentService.enrichReport(report, tree, PKG_PYPI_PREFIX, this::queryRegistryAndCompare); + } + @Override public void configure() { // fmt:off - from(direct("enrichPypiRecommendations")) - .routeId("enrichPypiRecommendations") - .process(this::enrichRecommendations); - from(direct("pep691Lookup")) .routeId("pep691Lookup") .circuitBreaker() @@ -104,25 +110,6 @@ private void handleLookupFallback(Exchange exchange) { exchange.getMessage().setBody(null); } - void enrichRecommendations(Exchange exchange) { - if (registryHost == null || registryHost.isBlank()) { - return; - } - - var body = exchange.getIn().getBody(); - if (!(body instanceof AnalysisReport report)) { - return; - } - - DependencyTree tree = - exchange.getProperty(Constants.DEPENDENCY_TREE_PROPERTY, DependencyTree.class); - if (tree == null) { - return; - } - - enrichmentService.enrichReport(report, tree, PKG_PYPI_PREFIX, this::queryRegistryAndCompare); - } - Optional queryRegistryAndCompare(String purlRef, String sbomSha256) { try { PackageRef ref = PackageRef.builder().purl(purlRef).build(); diff --git a/src/main/java/io/github/guacsec/trustifyda/integration/registry/RegistryIntegration.java b/src/main/java/io/github/guacsec/trustifyda/integration/registry/RegistryIntegration.java new file mode 100644 index 00000000..5a182527 --- /dev/null +++ b/src/main/java/io/github/guacsec/trustifyda/integration/registry/RegistryIntegration.java @@ -0,0 +1,28 @@ +/* + * Copyright 2023-2025 Trustify Dependency Analytics Authors + * + * 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. + */ + +package io.github.guacsec.trustifyda.integration.registry; + +import io.github.guacsec.trustifyda.api.v5.AnalysisReport; +import io.github.guacsec.trustifyda.model.DependencyTree; + +interface RegistryIntegration { + + boolean isEnabled(); + + void enrich(AnalysisReport report, DependencyTree tree); +} diff --git a/src/main/java/io/github/guacsec/trustifyda/integration/registry/TrustedLibrariesIntegration.java b/src/main/java/io/github/guacsec/trustifyda/integration/registry/TrustedLibrariesIntegration.java new file mode 100644 index 00000000..fadd353c --- /dev/null +++ b/src/main/java/io/github/guacsec/trustifyda/integration/registry/TrustedLibrariesIntegration.java @@ -0,0 +1,72 @@ +/* + * Copyright 2023-2025 Trustify Dependency Analytics Authors + * + * 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. + */ + +package io.github.guacsec.trustifyda.integration.registry; + +import org.apache.camel.Exchange; +import org.apache.camel.builder.endpoint.EndpointRouteBuilder; +import org.jboss.logging.Logger; + +import io.github.guacsec.trustifyda.api.v5.AnalysisReport; +import io.github.guacsec.trustifyda.integration.Constants; +import io.github.guacsec.trustifyda.model.DependencyTree; + +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.enterprise.inject.Instance; +import jakarta.inject.Inject; + +@ApplicationScoped +public class TrustedLibrariesIntegration extends EndpointRouteBuilder { + + private static final Logger LOGGER = Logger.getLogger(TrustedLibrariesIntegration.class); + + @Inject Instance registryIntegrations; + + @Override + public void configure() { + // fmt:off + from(direct("enrichTrustedLibraries")) + .routeId("enrichTrustedLibraries") + .process(this::enrichAll); + // fmt:on + } + + void enrichAll(Exchange exchange) { + var body = exchange.getIn().getBody(); + if (!(body instanceof AnalysisReport report)) { + return; + } + + DependencyTree tree = + exchange.getProperty(Constants.DEPENDENCY_TREE_PROPERTY, DependencyTree.class); + if (tree == null) { + return; + } + + for (RegistryIntegration integration : registryIntegrations) { + if (integration.isEnabled()) { + try { + integration.enrich(report, tree); + } catch (Exception e) { + LOGGER.warnf( + "Registry enrichment failed for %s: %s", + integration.getClass().getSimpleName(), e.getMessage()); + } + } + } + } +} diff --git a/src/test/java/io/github/guacsec/trustifyda/integration/registry/Pep691IntegrationTest.java b/src/test/java/io/github/guacsec/trustifyda/integration/registry/Pep691IntegrationTest.java index 5b926a65..f063c743 100644 --- a/src/test/java/io/github/guacsec/trustifyda/integration/registry/Pep691IntegrationTest.java +++ b/src/test/java/io/github/guacsec/trustifyda/integration/registry/Pep691IntegrationTest.java @@ -17,17 +17,15 @@ package io.github.guacsec.trustifyda.integration.registry; +import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertNull; -import static org.mockito.Mockito.mock; -import static org.mockito.Mockito.when; +import static org.junit.jupiter.api.Assertions.assertTrue; import java.util.ArrayList; import java.util.Collections; import java.util.List; import java.util.Map; -import org.apache.camel.Exchange; -import org.apache.camel.Message; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; @@ -36,72 +34,52 @@ import io.github.guacsec.trustifyda.api.v5.DependencyReport; import io.github.guacsec.trustifyda.api.v5.ProviderReport; import io.github.guacsec.trustifyda.api.v5.Source; -import io.github.guacsec.trustifyda.integration.Constants; import io.github.guacsec.trustifyda.model.DependencyTree; public class Pep691IntegrationTest { private Pep691Integration integration; - private Exchange exchange; - private Message message; @BeforeEach void setUp() { integration = new Pep691Integration(); - exchange = mock(Exchange.class); - message = mock(Message.class); - when(exchange.getIn()).thenReturn(message); } @Test - void skipWhenRegistryHostEmpty() { + void disabledWhenRegistryHostEmpty() { integration.registryHost = ""; - var report = new AnalysisReport(); - when(message.getBody()).thenReturn(report); - - integration.enrichRecommendations(exchange); + assertFalse(integration.isEnabled()); } @Test - void skipWhenRegistryHostNull() { + void disabledWhenRegistryHostNull() { integration.registryHost = null; - var report = new AnalysisReport(); - when(message.getBody()).thenReturn(report); - - integration.enrichRecommendations(exchange); + assertFalse(integration.isEnabled()); } @Test - void skipWhenBodyNotAnalysisReport() { + void enabledWhenRegistryHostConfigured() { integration.registryHost = "https://registry.example.com"; - when(message.getBody()).thenReturn("not a report"); - - integration.enrichRecommendations(exchange); + assertTrue(integration.isEnabled()); } @Test - void proceedWhenNoComponentHashes() { + void enrichWithNoComponentHashes() { integration.registryHost = "https://registry.example.com"; var report = buildReportWithPypiDep("pkg:pypi/requests@2.31.0"); - when(message.getBody()).thenReturn(report); - when(exchange.getProperty(Constants.DEPENDENCY_TREE_PROPERTY, DependencyTree.class)) - .thenReturn( - DependencyTree.builder() - .dependencies(Collections.emptyMap()) - .componentHashes(Collections.emptyMap()) - .build()); + var tree = + DependencyTree.builder() + .dependencies(Collections.emptyMap()) + .componentHashes(Collections.emptyMap()) + .build(); - integration.enrichRecommendations(exchange); + integration.enrich(report, tree); var dep = getFirstDep(report); assertNull(dep.getRecommendation()); } private AnalysisReport buildReportWithPypiDep(String purl) { - return buildReportWithDep(purl); - } - - private AnalysisReport buildReportWithDep(String purl) { var dep = new DependencyReport(); dep.ref(PackageRef.builder().purl(purl).build()); diff --git a/src/test/java/io/github/guacsec/trustifyda/integration/registry/TrustedLibrariesIntegrationTest.java b/src/test/java/io/github/guacsec/trustifyda/integration/registry/TrustedLibrariesIntegrationTest.java new file mode 100644 index 00000000..8b950108 --- /dev/null +++ b/src/test/java/io/github/guacsec/trustifyda/integration/registry/TrustedLibrariesIntegrationTest.java @@ -0,0 +1,149 @@ +/* + * Copyright 2023-2025 Trustify Dependency Analytics Authors + * + * 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. + */ + +package io.github.guacsec.trustifyda.integration.registry; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.doThrow; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import java.util.Collections; +import java.util.List; + +import org.apache.camel.Exchange; +import org.apache.camel.Message; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import io.github.guacsec.trustifyda.api.v5.AnalysisReport; +import io.github.guacsec.trustifyda.integration.Constants; +import io.github.guacsec.trustifyda.model.DependencyTree; + +import jakarta.enterprise.inject.Instance; + +public class TrustedLibrariesIntegrationTest { + + private TrustedLibrariesIntegration integration; + private Exchange exchange; + private Message message; + + @SuppressWarnings("unchecked") + @BeforeEach + void setUp() { + integration = new TrustedLibrariesIntegration(); + integration.registryIntegrations = mock(Instance.class); + exchange = mock(Exchange.class); + message = mock(Message.class); + when(exchange.getIn()).thenReturn(message); + } + + @Test + void skipWhenBodyNotAnalysisReport() { + when(message.getBody()).thenReturn("not a report"); + + integration.enrichAll(exchange); + + verify(integration.registryIntegrations, never()).iterator(); + } + + @Test + void skipWhenDependencyTreeNull() { + var report = new AnalysisReport(); + when(message.getBody()).thenReturn(report); + when(exchange.getProperty(Constants.DEPENDENCY_TREE_PROPERTY, DependencyTree.class)) + .thenReturn(null); + + integration.enrichAll(exchange); + + verify(integration.registryIntegrations, never()).iterator(); + } + + @Test + void callEnabledRegistries() { + var report = new AnalysisReport(); + var tree = + DependencyTree.builder() + .dependencies(Collections.emptyMap()) + .componentHashes(Collections.emptyMap()) + .build(); + when(message.getBody()).thenReturn(report); + when(exchange.getProperty(Constants.DEPENDENCY_TREE_PROPERTY, DependencyTree.class)) + .thenReturn(tree); + + RegistryIntegration enabled = mock(RegistryIntegration.class); + when(enabled.isEnabled()).thenReturn(true); + + RegistryIntegration disabled = mock(RegistryIntegration.class); + when(disabled.isEnabled()).thenReturn(false); + + when(integration.registryIntegrations.iterator()) + .thenReturn(List.of(enabled, disabled).iterator()); + + integration.enrichAll(exchange); + + verify(enabled).enrich(report, tree); + verify(disabled, never()).enrich(any(), any()); + } + + @Test + void continueOnRegistryFailure() { + var report = new AnalysisReport(); + var tree = + DependencyTree.builder() + .dependencies(Collections.emptyMap()) + .componentHashes(Collections.emptyMap()) + .build(); + when(message.getBody()).thenReturn(report); + when(exchange.getProperty(Constants.DEPENDENCY_TREE_PROPERTY, DependencyTree.class)) + .thenReturn(tree); + + RegistryIntegration failing = mock(RegistryIntegration.class); + when(failing.isEnabled()).thenReturn(true); + doThrow(new RuntimeException("boom")).when(failing).enrich(any(), any()); + + RegistryIntegration healthy = mock(RegistryIntegration.class); + when(healthy.isEnabled()).thenReturn(true); + + when(integration.registryIntegrations.iterator()) + .thenReturn(List.of(failing, healthy).iterator()); + + integration.enrichAll(exchange); + + verify(failing).enrich(report, tree); + verify(healthy).enrich(report, tree); + } + + @Test + void noRegistriesConfigured() { + var report = new AnalysisReport(); + var tree = + DependencyTree.builder() + .dependencies(Collections.emptyMap()) + .componentHashes(Collections.emptyMap()) + .build(); + when(message.getBody()).thenReturn(report); + when(exchange.getProperty(Constants.DEPENDENCY_TREE_PROPERTY, DependencyTree.class)) + .thenReturn(tree); + + when(integration.registryIntegrations.iterator()).thenReturn(Collections.emptyIterator()); + + integration.enrichAll(exchange); + } +} From 656d8c4cec7aed49bb96e441bcf35f108610455e Mon Sep 17 00:00:00 2001 From: Ruben Romero Montes Date: Sat, 9 May 2026 17:49:41 +0200 Subject: [PATCH 5/6] fix: allow start backend without any additional provider Signed-off-by: Ruben Romero Montes --- .../integration/registry/Pep691Integration.java | 8 ++++---- .../integration/registry/Pep691IntegrationTest.java | 11 ++++++----- 2 files changed, 10 insertions(+), 9 deletions(-) diff --git a/src/main/java/io/github/guacsec/trustifyda/integration/registry/Pep691Integration.java b/src/main/java/io/github/guacsec/trustifyda/integration/registry/Pep691Integration.java index 8e8b8f94..4c81d994 100644 --- a/src/main/java/io/github/guacsec/trustifyda/integration/registry/Pep691Integration.java +++ b/src/main/java/io/github/guacsec/trustifyda/integration/registry/Pep691Integration.java @@ -48,8 +48,8 @@ public class Pep691Integration extends EndpointRouteBuilder implements RegistryI private static final String PEP691_URL_PROPERTY = "pep691RegistryUrl"; private static final String PEP691_PACKAGE_PROPERTY = "pep691PackageName"; - @ConfigProperty(name = "api.pypi.registry.host", defaultValue = "") - String registryHost; + @ConfigProperty(name = "api.pypi.registry.host") + Optional registryHost; @ConfigProperty(name = "api.pypi.registry.timeout", defaultValue = "10s") String timeout; @@ -62,7 +62,7 @@ public class Pep691Integration extends EndpointRouteBuilder implements RegistryI @Override public boolean isEnabled() { - return registryHost != null && !registryHost.isBlank(); + return registryHost.isPresent() && !registryHost.get().isBlank(); } @Override @@ -120,7 +120,7 @@ Optional queryRegistryAndCompare(String purlRef, String sbomSha256) } String normalizedName = name.toLowerCase().replace("-", "_").replace(".", "_"); - String baseUrl = registryHost.replaceAll("/+$", ""); + String baseUrl = registryHost.get().replaceAll("/+$", ""); Exchange response = producerTemplate.send( diff --git a/src/test/java/io/github/guacsec/trustifyda/integration/registry/Pep691IntegrationTest.java b/src/test/java/io/github/guacsec/trustifyda/integration/registry/Pep691IntegrationTest.java index f063c743..ea2cebe5 100644 --- a/src/test/java/io/github/guacsec/trustifyda/integration/registry/Pep691IntegrationTest.java +++ b/src/test/java/io/github/guacsec/trustifyda/integration/registry/Pep691IntegrationTest.java @@ -25,6 +25,7 @@ import java.util.Collections; import java.util.List; import java.util.Map; +import java.util.Optional; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; @@ -47,25 +48,25 @@ void setUp() { @Test void disabledWhenRegistryHostEmpty() { - integration.registryHost = ""; + integration.registryHost = Optional.of(""); assertFalse(integration.isEnabled()); } @Test - void disabledWhenRegistryHostNull() { - integration.registryHost = null; + void disabledWhenRegistryHostAbsent() { + integration.registryHost = Optional.empty(); assertFalse(integration.isEnabled()); } @Test void enabledWhenRegistryHostConfigured() { - integration.registryHost = "https://registry.example.com"; + integration.registryHost = Optional.of("https://registry.example.com"); assertTrue(integration.isEnabled()); } @Test void enrichWithNoComponentHashes() { - integration.registryHost = "https://registry.example.com"; + integration.registryHost = Optional.of("https://registry.example.com"); var report = buildReportWithPypiDep("pkg:pypi/requests@2.31.0"); var tree = DependencyTree.builder() From eba63ec09ad22501386864ed1b3b868f8578fe7a Mon Sep 17 00:00:00 2001 From: Ruben Romero Montes Date: Sat, 9 May 2026 20:39:50 +0200 Subject: [PATCH 6/6] fix: url-encode repository_url qualifier in PEP 691 PURL construction Wrap baseUrl with URLEncoder.encode() when building the PURL qualifier so that special characters (:/&=) are percent-encoded per the PURL spec. Add unit test verifying the encoding with a URL containing query params. Implements TC-4372 Co-Authored-By: Claude Opus 4.6 --- .../registry/Pep691Integration.java | 10 +++- .../registry/Pep691IntegrationTest.java | 54 ++++++++++++++++++- 2 files changed, 62 insertions(+), 2 deletions(-) diff --git a/src/main/java/io/github/guacsec/trustifyda/integration/registry/Pep691Integration.java b/src/main/java/io/github/guacsec/trustifyda/integration/registry/Pep691Integration.java index 4c81d994..d0ce5709 100644 --- a/src/main/java/io/github/guacsec/trustifyda/integration/registry/Pep691Integration.java +++ b/src/main/java/io/github/guacsec/trustifyda/integration/registry/Pep691Integration.java @@ -17,6 +17,8 @@ package io.github.guacsec.trustifyda.integration.registry; +import java.net.URLEncoder; +import java.nio.charset.StandardCharsets; import java.util.Optional; import org.apache.camel.Exchange; @@ -160,7 +162,13 @@ Optional queryRegistryAndCompare(String purlRef, String sbomSha256) } return Optional.of( PackageRef.builder() - .purl(PKG_PYPI_PREFIX + name + "@" + version + "?repository_url=" + baseUrl) + .purl( + PKG_PYPI_PREFIX + + name + + "@" + + version + + "?repository_url=" + + URLEncoder.encode(baseUrl, StandardCharsets.UTF_8)) .build()); } } diff --git a/src/test/java/io/github/guacsec/trustifyda/integration/registry/Pep691IntegrationTest.java b/src/test/java/io/github/guacsec/trustifyda/integration/registry/Pep691IntegrationTest.java index ea2cebe5..f189f9e5 100644 --- a/src/test/java/io/github/guacsec/trustifyda/integration/registry/Pep691IntegrationTest.java +++ b/src/test/java/io/github/guacsec/trustifyda/integration/registry/Pep691IntegrationTest.java @@ -17,25 +17,40 @@ package io.github.guacsec.trustifyda.integration.registry; +import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertNull; import static org.junit.jupiter.api.Assertions.assertTrue; - +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +import java.net.URLEncoder; +import java.nio.charset.StandardCharsets; import java.util.ArrayList; import java.util.Collections; import java.util.List; import java.util.Map; import java.util.Optional; +import org.apache.camel.Exchange; +import org.apache.camel.Message; +import org.apache.camel.Processor; +import org.apache.camel.ProducerTemplate; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; +import com.fasterxml.jackson.databind.ObjectMapper; + import io.github.guacsec.trustifyda.api.PackageRef; import io.github.guacsec.trustifyda.api.v5.AnalysisReport; import io.github.guacsec.trustifyda.api.v5.DependencyReport; import io.github.guacsec.trustifyda.api.v5.ProviderReport; import io.github.guacsec.trustifyda.api.v5.Source; import io.github.guacsec.trustifyda.model.DependencyTree; +import io.github.guacsec.trustifyda.model.registry.Pep691Response; public class Pep691IntegrationTest { @@ -95,6 +110,43 @@ private AnalysisReport buildReportWithPypiDep(String purl) { return report; } + /** Verifies that registry URLs with special characters are URL-encoded in the PURL qualifier. */ + @Test + void queryRegistryAndCompareEncodesRepositoryUrl() throws Exception { + // Given a registry URL with special characters + String registryUrl = "https://registry.example.com/path?token=abc&flag=true"; + integration.registryHost = Optional.of(registryUrl); + integration.producerTemplate = mock(ProducerTemplate.class); + integration.objectMapper = mock(ObjectMapper.class); + + Exchange responseExchange = mock(Exchange.class); + Message responseMessage = mock(Message.class); + when(responseExchange.getMessage()).thenReturn(responseMessage); + when(responseMessage.getHeader(Exchange.HTTP_RESPONSE_CODE, Integer.class)).thenReturn(200); + when(responseMessage.getBody(String.class)).thenReturn("{}"); + when(integration.producerTemplate.send(anyString(), any(Processor.class))) + .thenReturn(responseExchange); + + Pep691Response.FileInfo fileInfo = + new Pep691Response.FileInfo( + "requests-2.31.0.tar.gz", + "https://files.example.com/requests-2.31.0.tar.gz", + Map.of("sha256", "registrysha256")); + Pep691Response pep691Response = new Pep691Response("requests", List.of(fileInfo)); + when(integration.objectMapper.readValue(anyString(), eq(Pep691Response.class))) + .thenReturn(pep691Response); + + // When querying the registry with a non-matching hash + Optional result = + integration.queryRegistryAndCompare("pkg:pypi/requests@2.31.0", "sbomsha256"); + + // Then the PURL should contain the URL-encoded registry URL + assertTrue(result.isPresent()); + String expectedEncodedUrl = URLEncoder.encode(registryUrl, StandardCharsets.UTF_8); + String expectedPurl = "pkg:pypi/requests@2.31.0?repository_url=" + expectedEncodedUrl; + assertEquals(expectedPurl, result.get().purl().toString()); + } + private DependencyReport getFirstDep(AnalysisReport report) { return report .getProviders()