NexusV3Search.java
/*
* This file is part of dependency-check-core.
*
* 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.
*
* Copyright (c) 2023 Hans Aikema. All Rights Reserved.
*/
package org.owasp.dependencycheck.data.nexus;
import org.apache.hc.client5.http.HttpResponseException;
import org.apache.hc.client5.http.impl.classic.AbstractHttpClientResponseHandler;
import org.apache.hc.client5.http.impl.classic.CloseableHttpClient;
import org.apache.hc.core5.http.ContentType;
import org.apache.hc.core5.http.HttpEntity;
import org.apache.hc.core5.http.HttpHeaders;
import org.apache.hc.core5.http.message.BasicHeader;
import org.jetbrains.annotations.Nullable;
import org.owasp.dependencycheck.utils.DownloadFailedException;
import org.owasp.dependencycheck.utils.Downloader;
import org.owasp.dependencycheck.utils.ResourceNotFoundException;
import org.owasp.dependencycheck.utils.Settings;
import org.owasp.dependencycheck.utils.TooManyRequestsException;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import javax.annotation.concurrent.ThreadSafe;
import jakarta.json.Json;
import jakarta.json.JsonArray;
import jakarta.json.JsonObject;
import jakarta.json.JsonReader;
import java.io.BufferedReader;
import java.io.FileNotFoundException;
import java.io.IOException;
import java.io.InputStream;
import java.io.InputStreamReader;
import java.io.StringReader;
import java.net.MalformedURLException;
import java.net.URL;
import java.nio.charset.StandardCharsets;
import java.util.ArrayList;
import java.util.HashSet;
import java.util.List;
import java.util.Set;
import java.util.stream.Collectors;
/**
* Class of methods to search Nexus v3 repositories.
*
* @author Hans Aikema
*/
@ThreadSafe
public class NexusV3Search implements NexusSearch {
/**
* By default, NexusV3Search accepts only classifier-less artifacts.
* <p>
* This prevents, among others, sha1-collisions for empty jars on empty javadoc/sources jars.
* See e.g. issues #5559 and #5118
*/
private final Set<String> acceptedClassifiers = new HashSet<>();
/**
* The root URL for the Nexus repository service.
*/
private final URL rootURL;
/**
* Whether to use the Proxy when making requests.
*/
private final boolean useProxy;
/**
* The configured settings.
*/
private final Settings settings;
/**
* Used for logging.
*/
private static final Logger LOGGER = LoggerFactory.getLogger(NexusV3Search.class);
/**
* Creates a NexusV3Search for the given repository URL.
*
* @param settings the configured settings
* @param useProxy flag indicating if the proxy settings should be used
* @throws MalformedURLException thrown if the configured URL is
* invalid
*/
public NexusV3Search(Settings settings, boolean useProxy) throws MalformedURLException {
this.settings = settings;
this.useProxy = useProxy;
this.acceptedClassifiers.add(null);
final String searchUrl = settings.getString(Settings.KEYS.ANALYZER_NEXUS_URL);
LOGGER.debug("Nexus Search URL: {}", searchUrl);
this.rootURL = new URL(searchUrl);
}
@Override
public MavenArtifact searchSha1(String sha1) throws IOException {
if (null == sha1 || !sha1.matches("^[0-9A-Fa-f]{40}$")) {
throw new IllegalArgumentException("Invalid SHA1 format");
}
final List<MavenArtifact> collectedMatchingArtifacts = new ArrayList<>(1);
try (CloseableHttpClient client = Downloader.getInstance().getHttpClient(useProxy)) {
String continuationToken = retrievePageAndAddMatchingArtifact(client, collectedMatchingArtifacts, sha1, null);
while (continuationToken != null && collectedMatchingArtifacts.isEmpty()) {
continuationToken = retrievePageAndAddMatchingArtifact(client, collectedMatchingArtifacts, sha1, continuationToken);
}
}
if (collectedMatchingArtifacts.isEmpty()) {
throw new FileNotFoundException("Artifact not found in Nexus");
} else {
return collectedMatchingArtifacts.get(0);
}
}
private String retrievePageAndAddMatchingArtifact(CloseableHttpClient client, List<MavenArtifact> collectedMatchingArtifacts, String sha1,
@Nullable String continuationToken) throws IOException {
final URL url;
LOGGER.debug("Search with continuation token {}", continuationToken);
if (continuationToken == null) {
url = new URL(rootURL, String.format("v1/search/?sha1=%s",
sha1.toLowerCase()));
} else {
url = new URL(rootURL, String.format("v1/search/?sha1=%s&continuationToken=%s",
sha1.toLowerCase(), continuationToken));
}
LOGGER.debug("Searching Nexus url {}", url);
// Determine if we need to use a proxy. The rules:
// 1) If the proxy is set, AND the setting is set to true, use the proxy
// 2) Otherwise, don't use the proxy (either the proxy isn't configured,
// or proxy is specifically set to false
final NexusV3SearchResponseHandler handler = new NexusV3SearchResponseHandler(collectedMatchingArtifacts, sha1, acceptedClassifiers);
try {
return Downloader.getInstance().fetchAndHandle(client, url, handler, List.of(new BasicHeader(HttpHeaders.ACCEPT,
ContentType.APPLICATION_JSON)));
} catch (TooManyRequestsException | ResourceNotFoundException | DownloadFailedException e) {
if (LOGGER.isDebugEnabled()) {
int responseCode = -1;
String responseMessage = "";
if (e.getCause() instanceof HttpResponseException) {
final HttpResponseException cause = (HttpResponseException) e.getCause();
responseCode = cause.getStatusCode();
responseMessage = cause.getReasonPhrase();
}
LOGGER.debug("Could not connect to Nexus received response code: {} {}",
responseCode, responseMessage);
}
throw new IOException("Could not connect to Nexus", e);
}
}
private static final class NexusV3SearchResponseHandler extends AbstractHttpClientResponseHandler<String> {
/**
* The list to which matching artifacts are to be added
*/
private final List<MavenArtifact> matchingArtifacts;
/**
* The sha1 for which the search results are being handled
*/
private final String sha1;
/**
* The classifiers to be accepted
*/
private final Set<String> acceptedClassifiers;
private NexusV3SearchResponseHandler(List<MavenArtifact> matchingArtifacts, String sha1, Set<String> acceptedClassifiers) {
this.matchingArtifacts = matchingArtifacts;
this.sha1 = sha1;
this.acceptedClassifiers = acceptedClassifiers;
}
@Override
public @Nullable String handleEntity(HttpEntity entity) throws IOException {
try (InputStream in = entity.getContent();
InputStreamReader isReader = new InputStreamReader(in, StandardCharsets.UTF_8);
BufferedReader reader = new BufferedReader(isReader);
) {
final String jsonString = reader.lines().collect(Collectors.joining("\n"));
LOGGER.debug("JSON String was >>>{}<<<", jsonString);
final JsonObject jsonResponse;
try (
StringReader stringReader = new StringReader(jsonString);
JsonReader jsonReader = Json.createReader(stringReader)
) {
jsonResponse = jsonReader.readObject();
}
LOGGER.debug("Response: {}", jsonResponse);
final JsonArray components = jsonResponse.getJsonArray("items");
LOGGER.debug("Items: {}", components);
final String continuationToken = jsonResponse.getString("continuationToken", null);
boolean found = false;
for (int i = 0; i < components.size() && !found; i++) {
boolean jarFound = false;
boolean pomFound = false;
String downloadUrl = null;
String groupId = null;
String artifactId = null;
String version = null;
String pomUrl = null;
final JsonObject component = components.getJsonObject(i);
final String format = component.getString("format", "unknown");
if ("maven2".equals(format)) {
LOGGER.debug("Checking Maven2 artifact for {}", component);
final JsonArray assets = component.getJsonArray("assets");
for (int j = 0; !found && j < assets.size(); j++) {
final JsonObject asset = assets.getJsonObject(j);
LOGGER.debug("Checking {}", asset);
final JsonObject checksums = asset.getJsonObject("checksum");
final JsonObject maven2 = asset.getJsonObject("maven2");
if (maven2 != null) {
// logical names for the jar acceptance routine
final boolean shaMatch = checksums != null && sha1.equals(checksums.getString("sha1", null));
final boolean hasAcceptedClassifier = acceptedClassifiers.contains(maven2.getString("classifier", null));
final boolean isAJar = "jar".equals(maven2.getString("extension", null));
LOGGER.debug("shaMatch {}", shaMatch);
LOGGER.debug("hasAcceptedClassifier {}", hasAcceptedClassifier);
LOGGER.debug("isAJar {}", isAJar);
if (
isAJar
&& hasAcceptedClassifier
&& shaMatch
) {
downloadUrl = asset.getString("downloadUrl");
groupId = maven2.getString("groupId");
artifactId = maven2.getString("artifactId");
version = maven2.getString("version");
jarFound = true;
} else if ("pom".equals(maven2.getString("extension"))) {
LOGGER.debug("pom found {}", asset);
pomFound = true;
pomUrl = asset.getString("downloadUrl");
}
}
if (pomFound && jarFound) {
found = true;
}
}
if (found) {
matchingArtifacts.add(new MavenArtifact(groupId, artifactId, version, downloadUrl, pomUrl));
} else if (jarFound) {
final MavenArtifact ma = new MavenArtifact(groupId, artifactId, version, downloadUrl);
ma.setPomUrl(MavenArtifact.derivePomUrl(artifactId, version, downloadUrl));
matchingArtifacts.add(ma);
found = true;
}
}
}
return continuationToken;
}
}
}
@Override
public boolean preflightRequest() {
try {
final URL url = new URL(rootURL, "v1/status");
final String response = Downloader.getInstance().fetchContent(url, useProxy, StandardCharsets.UTF_8);
if (response == null || !response.isEmpty()) {
LOGGER.warn("Expected empty OK response (content-length 0), got {}", response == null ? "null" : response.length());
return false;
}
} catch (IOException | TooManyRequestsException | ResourceNotFoundException e) {
LOGGER.warn("Pre-flight request to Nexus failed: ", e);
return false;
}
return true;
}
}