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..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,6 +218,7 @@ public void configure() { .process(this::processAnalysisRequest) .process(monitoringProcessor::processOriginalRequest) .to(direct("analyzeSbom")) + .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 new file mode 100644 index 00000000..d0ce5709 --- /dev/null +++ b/src/main/java/io/github/guacsec/trustifyda/integration/registry/Pep691Integration.java @@ -0,0 +1,195 @@ +/* + * 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.net.URLEncoder; +import java.nio.charset.StandardCharsets; +import java.util.Optional; + +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; + +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.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; +import jakarta.ws.rs.HttpMethod; + +@ApplicationScoped +public class Pep691Integration extends EndpointRouteBuilder implements RegistryIntegration { + + 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 PEP691_URL_PROPERTY = "pep691RegistryUrl"; + private static final String PEP691_PACKAGE_PROPERTY = "pep691PackageName"; + + @ConfigProperty(name = "api.pypi.registry.host") + Optional registryHost; + + @ConfigProperty(name = "api.pypi.registry.timeout", defaultValue = "10s") + String timeout; + + private final RegistryEnrichmentService enrichmentService = new RegistryEnrichmentService(); + + @Inject ObjectMapper objectMapper; + + @Inject ProducerTemplate producerTemplate; + + @Override + public boolean isEnabled() { + return registryHost.isPresent() && !registryHost.get().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("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); + } + + 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 baseUrl = registryHost.get().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(); + } + + 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 : pep691Response.files()) { + if (file.filename() == null || file.hashes() == null) { + continue; + } + if (!matchesVersion(file.filename(), filePrefix)) { + continue; + } + String registrySha256 = file.hashes().get("sha256"); + 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=" + + URLEncoder.encode(baseUrl, StandardCharsets.UTF_8)) + .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/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/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/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..aac4a735 --- /dev/null +++ b/src/test/java/io/github/guacsec/trustifyda/integration/Pep691AnalysisTest.java @@ -0,0 +1,132 @@ +/* + * 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"))); + + 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 new file mode 100644 index 00000000..f189f9e5 --- /dev/null +++ b/src/test/java/io/github/guacsec/trustifyda/integration/registry/Pep691IntegrationTest.java @@ -0,0 +1,163 @@ +/* + * 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.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 { + + private Pep691Integration integration; + + @BeforeEach + void setUp() { + integration = new Pep691Integration(); + } + + @Test + void disabledWhenRegistryHostEmpty() { + integration.registryHost = Optional.of(""); + assertFalse(integration.isEnabled()); + } + + @Test + void disabledWhenRegistryHostAbsent() { + integration.registryHost = Optional.empty(); + assertFalse(integration.isEnabled()); + } + + @Test + void enabledWhenRegistryHostConfigured() { + integration.registryHost = Optional.of("https://registry.example.com"); + assertTrue(integration.isEnabled()); + } + + @Test + void enrichWithNoComponentHashes() { + integration.registryHost = Optional.of("https://registry.example.com"); + var report = buildReportWithPypiDep("pkg:pypi/requests@2.31.0"); + var tree = + DependencyTree.builder() + .dependencies(Collections.emptyMap()) + .componentHashes(Collections.emptyMap()) + .build(); + + integration.enrich(report, tree); + + var dep = getFirstDep(report); + assertNull(dep.getRecommendation()); + } + + private AnalysisReport buildReportWithPypiDep(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; + } + + /** 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() + .values() + .iterator() + .next() + .getSources() + .values() + .iterator() + .next() + .getDependencies() + .get(0); + } +} 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); + } +} 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); + } +} 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/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/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..9d86843c --- /dev/null +++ b/src/test/resources/__files/reports/pypi_report.json @@ -0,0 +1,111 @@ +{ + "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": 2, + "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" + } + } + } + }, + { + "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" + } + ] + } + } + } + }, + "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" + ] + } + ] +} 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 +}