View Javadoc
1   /*
2    * This file is part of dependency-check-core.
3    *
4    * Licensed under the Apache License, Version 2.0 (the "License");
5    * you may not use this file except in compliance with the License.
6    * You may obtain a copy of the License at
7    *
8    *     http://www.apache.org/licenses/LICENSE-2.0
9    *
10   * Unless required by applicable law or agreed to in writing, software
11   * distributed under the License is distributed on an "AS IS" BASIS,
12   * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13   * See the License for the specific language governing permissions and
14   * limitations under the License.
15   *
16   * Copyright (c) 2023 Hans Aikema. All Rights Reserved.
17   */
18  package org.owasp.dependencycheck.data.nexus;
19  
20  import org.apache.hc.client5.http.HttpResponseException;
21  import org.apache.hc.client5.http.impl.classic.AbstractHttpClientResponseHandler;
22  import org.apache.hc.client5.http.impl.classic.CloseableHttpClient;
23  import org.apache.hc.core5.http.ContentType;
24  import org.apache.hc.core5.http.HttpEntity;
25  import org.apache.hc.core5.http.HttpHeaders;
26  import org.apache.hc.core5.http.message.BasicHeader;
27  import org.jetbrains.annotations.Nullable;
28  import org.owasp.dependencycheck.utils.DownloadFailedException;
29  import org.owasp.dependencycheck.utils.Downloader;
30  import org.owasp.dependencycheck.utils.ResourceNotFoundException;
31  import org.owasp.dependencycheck.utils.Settings;
32  import org.owasp.dependencycheck.utils.TooManyRequestsException;
33  import org.slf4j.Logger;
34  import org.slf4j.LoggerFactory;
35  
36  import javax.annotation.concurrent.ThreadSafe;
37  import jakarta.json.Json;
38  import jakarta.json.JsonArray;
39  import jakarta.json.JsonObject;
40  import jakarta.json.JsonReader;
41  import java.io.BufferedReader;
42  import java.io.FileNotFoundException;
43  import java.io.IOException;
44  import java.io.InputStream;
45  import java.io.InputStreamReader;
46  import java.io.StringReader;
47  import java.net.MalformedURLException;
48  import java.net.URL;
49  import java.nio.charset.StandardCharsets;
50  import java.util.ArrayList;
51  import java.util.HashSet;
52  import java.util.List;
53  import java.util.Set;
54  import java.util.stream.Collectors;
55  
56  /**
57   * Class of methods to search Nexus v3 repositories.
58   *
59   * @author Hans Aikema
60   */
61  @ThreadSafe
62  public class NexusV3Search implements NexusSearch {
63  
64      /**
65       * By default, NexusV3Search accepts only classifier-less artifacts.
66       * <p>
67       * This prevents, among others, sha1-collisions for empty jars on empty javadoc/sources jars.
68       * See e.g. issues #5559 and #5118
69       */
70      private final Set<String> acceptedClassifiers = new HashSet<>();
71  
72      /**
73       * The root URL for the Nexus repository service.
74       */
75      private final URL rootURL;
76  
77      /**
78       * Whether to use the Proxy when making requests.
79       */
80      private final boolean useProxy;
81      /**
82       * The configured settings.
83       */
84      private final Settings settings;
85      /**
86       * Used for logging.
87       */
88      private static final Logger LOGGER = LoggerFactory.getLogger(NexusV3Search.class);
89  
90      /**
91       * Creates a NexusV3Search for the given repository URL.
92       *
93       * @param settings the configured settings
94       * @param useProxy flag indicating if the proxy settings should be used
95       * @throws MalformedURLException thrown if the configured URL is
96       *                               invalid
97       */
98      public NexusV3Search(Settings settings, boolean useProxy) throws MalformedURLException {
99          this.settings = settings;
100         this.useProxy = useProxy;
101         this.acceptedClassifiers.add(null);
102         final String searchUrl = settings.getString(Settings.KEYS.ANALYZER_NEXUS_URL);
103         LOGGER.debug("Nexus Search URL: {}", searchUrl);
104         this.rootURL = new URL(searchUrl);
105 
106     }
107 
108     @Override
109     public MavenArtifact searchSha1(String sha1) throws IOException {
110         if (null == sha1 || !sha1.matches("^[0-9A-Fa-f]{40}$")) {
111             throw new IllegalArgumentException("Invalid SHA1 format");
112         }
113 
114         final List<MavenArtifact> collectedMatchingArtifacts = new ArrayList<>(1);
115         try (CloseableHttpClient client = Downloader.getInstance().getHttpClient(useProxy)) {
116             String continuationToken = retrievePageAndAddMatchingArtifact(client, collectedMatchingArtifacts, sha1, null);
117             while (continuationToken != null && collectedMatchingArtifacts.isEmpty()) {
118                 continuationToken = retrievePageAndAddMatchingArtifact(client, collectedMatchingArtifacts, sha1, continuationToken);
119             }
120         }
121         if (collectedMatchingArtifacts.isEmpty()) {
122             throw new FileNotFoundException("Artifact not found in Nexus");
123         } else {
124             return collectedMatchingArtifacts.get(0);
125         }
126     }
127 
128     private String retrievePageAndAddMatchingArtifact(CloseableHttpClient client, List<MavenArtifact> collectedMatchingArtifacts, String sha1,
129                                                       @Nullable String continuationToken) throws IOException {
130         final URL url;
131         LOGGER.debug("Search with continuation token {}", continuationToken);
132         if (continuationToken == null) {
133             url = new URL(rootURL, String.format("v1/search/?sha1=%s",
134                     sha1.toLowerCase()));
135         } else {
136             url = new URL(rootURL, String.format("v1/search/?sha1=%s&continuationToken=%s",
137                     sha1.toLowerCase(), continuationToken));
138         }
139 
140         LOGGER.debug("Searching Nexus url {}", url);
141         // Determine if we need to use a proxy. The rules:
142         // 1) If the proxy is set, AND the setting is set to true, use the proxy
143         // 2) Otherwise, don't use the proxy (either the proxy isn't configured,
144         // or proxy is specifically set to false
145         final NexusV3SearchResponseHandler handler = new NexusV3SearchResponseHandler(collectedMatchingArtifacts, sha1, acceptedClassifiers);
146         try {
147             return Downloader.getInstance().fetchAndHandle(client, url, handler, List.of(new BasicHeader(HttpHeaders.ACCEPT,
148                             ContentType.APPLICATION_JSON)));
149         } catch (TooManyRequestsException | ResourceNotFoundException | DownloadFailedException e) {
150             if (LOGGER.isDebugEnabled()) {
151                 int responseCode = -1;
152                 String responseMessage = "";
153                 if (e.getCause() instanceof HttpResponseException) {
154                     final HttpResponseException cause = (HttpResponseException) e.getCause();
155                     responseCode = cause.getStatusCode();
156                     responseMessage = cause.getReasonPhrase();
157                 }
158                 LOGGER.debug("Could not connect to Nexus received response code: {} {}",
159                         responseCode, responseMessage);
160             }
161             throw new IOException("Could not connect to Nexus", e);
162         }
163     }
164 
165     private static final class NexusV3SearchResponseHandler extends AbstractHttpClientResponseHandler<String> {
166 
167         /**
168          * The list to which matching artifacts are to be added
169          */
170         private final List<MavenArtifact> matchingArtifacts;
171         /**
172          * The sha1 for which the search results are being handled
173          */
174         private final String sha1;
175         /**
176          * The classifiers to be accepted
177          */
178         private final Set<String> acceptedClassifiers;
179 
180         private NexusV3SearchResponseHandler(List<MavenArtifact> matchingArtifacts, String sha1, Set<String> acceptedClassifiers) {
181             this.matchingArtifacts = matchingArtifacts;
182             this.sha1 = sha1;
183             this.acceptedClassifiers = acceptedClassifiers;
184         }
185 
186         @Override
187         public @Nullable String handleEntity(HttpEntity entity) throws IOException {
188             try (InputStream in = entity.getContent();
189                  InputStreamReader isReader = new InputStreamReader(in, StandardCharsets.UTF_8);
190                  BufferedReader reader = new BufferedReader(isReader);
191             ) {
192                 final String jsonString = reader.lines().collect(Collectors.joining("\n"));
193                 LOGGER.debug("JSON String was >>>{}<<<", jsonString);
194                 final JsonObject jsonResponse;
195                 try (
196                         StringReader stringReader = new StringReader(jsonString);
197                         JsonReader jsonReader = Json.createReader(stringReader)
198                 ) {
199                     jsonResponse = jsonReader.readObject();
200                 }
201                 LOGGER.debug("Response: {}", jsonResponse);
202                 final JsonArray components = jsonResponse.getJsonArray("items");
203                 LOGGER.debug("Items: {}", components);
204                 final String continuationToken = jsonResponse.getString("continuationToken", null);
205                 boolean found = false;
206                 for (int i = 0; i < components.size() && !found; i++) {
207                     boolean jarFound = false;
208                     boolean pomFound = false;
209                     String downloadUrl = null;
210                     String groupId = null;
211                     String artifactId = null;
212                     String version = null;
213                     String pomUrl = null;
214 
215                     final JsonObject component = components.getJsonObject(i);
216 
217                     final String format = component.getString("format", "unknown");
218                     if ("maven2".equals(format)) {
219                         LOGGER.debug("Checking Maven2 artifact for {}", component);
220                         final JsonArray assets = component.getJsonArray("assets");
221                         for (int j = 0; !found && j < assets.size(); j++) {
222                             final JsonObject asset = assets.getJsonObject(j);
223                             LOGGER.debug("Checking {}", asset);
224                             final JsonObject checksums = asset.getJsonObject("checksum");
225                             final JsonObject maven2 = asset.getJsonObject("maven2");
226                             if (maven2 != null) {
227                                 // logical names for the jar acceptance routine
228                                 final boolean shaMatch = checksums != null && sha1.equals(checksums.getString("sha1", null));
229                                 final boolean hasAcceptedClassifier = acceptedClassifiers.contains(maven2.getString("classifier", null));
230                                 final boolean isAJar = "jar".equals(maven2.getString("extension", null));
231                                 LOGGER.debug("shaMatch {}", shaMatch);
232                                 LOGGER.debug("hasAcceptedClassifier {}", hasAcceptedClassifier);
233                                 LOGGER.debug("isAJar {}", isAJar);
234                                 if (
235                                         isAJar
236                                         && hasAcceptedClassifier
237                                         && shaMatch
238                                 ) {
239                                     downloadUrl = asset.getString("downloadUrl");
240                                     groupId = maven2.getString("groupId");
241                                     artifactId = maven2.getString("artifactId");
242                                     version = maven2.getString("version");
243 
244                                     jarFound = true;
245                                 } else if ("pom".equals(maven2.getString("extension"))) {
246                                     LOGGER.debug("pom found {}", asset);
247                                     pomFound = true;
248                                     pomUrl = asset.getString("downloadUrl");
249                                 }
250                             }
251                             if (pomFound && jarFound) {
252                                 found = true;
253                             }
254                         }
255                         if (found) {
256                             matchingArtifacts.add(new MavenArtifact(groupId, artifactId, version, downloadUrl, pomUrl));
257                         } else if (jarFound) {
258                             final MavenArtifact ma = new MavenArtifact(groupId, artifactId, version, downloadUrl);
259                             ma.setPomUrl(MavenArtifact.derivePomUrl(artifactId, version, downloadUrl));
260                             matchingArtifacts.add(ma);
261                             found = true;
262                         }
263                     }
264                 }
265                 return continuationToken;
266             }
267         }
268     }
269 
270     @Override
271     public boolean preflightRequest() {
272         try {
273             final URL url = new URL(rootURL, "v1/status");
274             final String response = Downloader.getInstance().fetchContent(url, useProxy, StandardCharsets.UTF_8);
275             if (response == null || !response.isEmpty()) {
276                 LOGGER.warn("Expected empty OK response (content-length 0), got {}", response == null ? "null" : response.length());
277                 return false;
278             }
279         } catch (IOException | TooManyRequestsException | ResourceNotFoundException e) {
280             LOGGER.warn("Pre-flight request to Nexus failed: ", e);
281             return false;
282         }
283         return true;
284     }
285 
286 }