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.owasp.dependencycheck.utils.Settings;
import org.owasp.dependencycheck.utils.URLConnectionFactory;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import javax.annotation.concurrent.ThreadSafe;
import javax.json.Json;
import javax.json.JsonArray;
import javax.json.JsonObject;
import javax.json.JsonReader;
import java.io.BufferedInputStream;
import java.io.FileNotFoundException;
import java.io.IOException;
import java.io.InputStream;
import java.net.HttpURLConnection;
import java.net.MalformedURLException;
import java.net.URL;
import java.nio.charset.StandardCharsets;
import java.util.ArrayList;
import java.util.Base64;
import java.util.HashSet;
import java.util.List;
import java.util.Set;
/**
* 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);
String continuationToken = retrievePageAndAddMatchingArtifact(collectedMatchingArtifacts, sha1, null);
while (continuationToken != null && collectedMatchingArtifacts.isEmpty()) {
continuationToken = retrievePageAndAddMatchingArtifact(collectedMatchingArtifacts, sha1, continuationToken);
}
if (collectedMatchingArtifacts.isEmpty()) {
throw new FileNotFoundException("Artifact not found in Nexus");
} else {
return collectedMatchingArtifacts.get(0);
}
}
private String retrievePageAndAddMatchingArtifact(List<MavenArtifact> collectedMatchingArtifacts, String sha1, 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 HttpURLConnection conn;
final URLConnectionFactory factory = new URLConnectionFactory(settings);
conn = factory.createHttpURLConnection(url, useProxy);
conn.setDoOutput(true);
final String authHeader = buildHttpAuthHeaderValue();
if (!authHeader.isEmpty()) {
conn.addRequestProperty("Authorization", authHeader);
}
conn.addRequestProperty("Accept", "application/json");
conn.connect();
final String nextContinuationToken;
if (conn.getResponseCode() == 200) {
nextContinuationToken = parseResponse(conn, sha1, collectedMatchingArtifacts);
} else {
LOGGER.debug("Could not connect to Nexus received response code: {} {}",
conn.getResponseCode(), conn.getResponseMessage());
throw new IOException(String.format("Could not connect to Nexus, HTTP response code %d", conn.getResponseCode()));
}
return nextContinuationToken;
}
private String parseResponse(HttpURLConnection conn, String sha1, List<MavenArtifact> matchingArtifacts) throws IOException {
try (InputStream in = new BufferedInputStream(conn.getInputStream());
JsonReader jsonReader = Json.createReader(in)) {
final JsonObject jsonResponse = jsonReader.readObject();
final String continuationToken = jsonResponse.getString("continuationToken", null);
final JsonArray components = jsonResponse.getJsonArray("items");
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 = components.getJsonObject(0).getString("format", "unknown");
if ("maven2".equals(format)) {
final JsonArray assets = component.getJsonArray("assets");
for (int j = 0; !found && j < assets.size(); j++) {
final JsonObject asset = assets.getJsonObject(j);
final JsonObject checksums = asset.getJsonObject("checksum");
final JsonObject maven2 = asset.getJsonObject("maven2");
if (maven2 != null
&& "jar".equals(maven2.getString("extension", null))
&& acceptedClassifiers.contains(maven2.getString("classifier", null))
&& checksums != null && sha1.equals(checksums.getString("sha1", null))
) {
downloadUrl = asset.getString("downloadUrl");
groupId = maven2.getString("groupId");
artifactId = maven2.getString("artifactId");
version = maven2.getString("version");
jarFound = true;
} else if (maven2 != null && "pom".equals(maven2.getString("extension"))) {
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() {
final HttpURLConnection conn;
try {
final URL url = new URL(rootURL, "v1/status");
final URLConnectionFactory factory = new URLConnectionFactory(settings);
conn = factory.createHttpURLConnection(url, useProxy);
conn.addRequestProperty("Accept", "application/json");
final String authHeader = buildHttpAuthHeaderValue();
if (!authHeader.isEmpty()) {
conn.addRequestProperty("Authorization", authHeader);
}
conn.connect();
if (conn.getResponseCode() != 200) {
LOGGER.warn("Expected 200 result from Nexus, got {}", conn.getResponseCode());
return false;
}
if (conn.getContentLength() != 0) {
LOGGER.warn("Expected empty OK response (content-length 0), got content-length {}", conn.getContentLength());
return false;
}
} catch (IOException e) {
LOGGER.warn("Pre-flight request to Nexus failed: ", e);
return false;
}
return true;
}
/**
* Constructs the base64 encoded basic authentication header value.
*
* @return the base64 encoded basic authentication header value
*/
private String buildHttpAuthHeaderValue() {
final String user = settings.getString(Settings.KEYS.ANALYZER_NEXUS_USER, "");
final String pass = settings.getString(Settings.KEYS.ANALYZER_NEXUS_PASSWORD, "");
String result = "";
if (user.isEmpty() || pass.isEmpty()) {
LOGGER.debug("Skip authentication as user and/or password for nexus is empty");
} else {
final String auth = user + ':' + pass;
final String base64Auth = Base64.getEncoder().encodeToString(auth.getBytes(StandardCharsets.UTF_8));
result = "Basic " + base64Auth;
}
return result;
}
}