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) 2018 - 2024 Nicolas Henneaux; Hans Aikema. All Rights Reserved.
17   */
18  package org.owasp.dependencycheck.data.artifactory;
19  
20  import com.fasterxml.jackson.core.JsonParser;
21  import com.fasterxml.jackson.core.JsonToken;
22  import com.fasterxml.jackson.databind.DeserializationFeature;
23  import com.fasterxml.jackson.databind.ObjectMapper;
24  import com.fasterxml.jackson.databind.ObjectReader;
25  import org.apache.hc.core5.http.ClassicHttpResponse;
26  import org.apache.hc.core5.http.io.HttpClientResponseHandler;
27  import org.owasp.dependencycheck.data.nexus.MavenArtifact;
28  import org.owasp.dependencycheck.dependency.Dependency;
29  import org.slf4j.Logger;
30  import org.slf4j.LoggerFactory;
31  
32  import java.io.FileNotFoundException;
33  import java.io.IOException;
34  import java.io.InputStreamReader;
35  import java.net.URL;
36  import java.nio.charset.StandardCharsets;
37  import java.util.ArrayList;
38  import java.util.List;
39  import java.util.Optional;
40  import java.util.regex.Matcher;
41  import java.util.regex.Pattern;
42  
43  import static org.owasp.dependencycheck.data.artifactory.ArtifactorySearch.X_RESULT_DETAIL_HEADER;
44  
45  class ArtifactorySearchResponseHandler implements HttpClientResponseHandler<List<MavenArtifact>> {
46      /**
47       * Pattern to match the path returned by the Artifactory AQL API.
48       */
49      private static final Pattern PATH_PATTERN = Pattern.compile("^/(?<groupId>.+)/(?<artifactId>[^/]+)/(?<version>[^/]+)/[^/]+$");
50  
51      /**
52       * Used for logging.
53       */
54      private static final Logger LOGGER = LoggerFactory.getLogger(ArtifactorySearchResponseHandler.class);
55  
56      /**
57       * Search result reader
58       */
59      private final ObjectReader fileImplReader;
60  
61      /**
62       * The dependency that is expected to be in the response from Artifactory (if found)
63       */
64      private final Dependency expectedDependency;
65  
66      /**
67       * The search request URL i.e., the location at which to search artifacts with checksum information
68       */
69      private final URL sourceUrl;
70  
71      /**
72       * Creates a response handler for the response on a single dependency-search attempt.
73       *
74       * @param sourceUrl The search request URL
75       * @param dependency The dependency that is expected to be in the response when found (for validating the FileItem(s) in the response)
76       */
77      ArtifactorySearchResponseHandler(URL sourceUrl, Dependency dependency) {
78          this.fileImplReader = new ObjectMapper().configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false).readerFor(FileImpl.class);
79          this.expectedDependency = dependency;
80          this.sourceUrl = sourceUrl;
81      }
82  
83      protected boolean init(JsonParser parser) throws IOException {
84          com.fasterxml.jackson.core.JsonToken nextToken = parser.nextToken();
85          if (nextToken != com.fasterxml.jackson.core.JsonToken.START_OBJECT) {
86              throw new IOException("Expected " + com.fasterxml.jackson.core.JsonToken.START_OBJECT + ", got " + nextToken);
87          }
88  
89          do {
90              nextToken = parser.nextToken();
91              if (nextToken == null) {
92                  break;
93              }
94  
95              if (nextToken.isStructStart()) {
96                  if (nextToken == com.fasterxml.jackson.core.JsonToken.START_ARRAY && "results".equals(parser.currentName())) {
97                      return true;
98                  } else {
99                      parser.skipChildren();
100                 }
101             }
102         } while (true);
103 
104         return false;
105     }
106 
107     /**
108      * Validates the hashes of the dependency.
109      *
110      * @param checksums the collection of checksums (md5, sha1, [sha256])
111      * @return Whether all available hashes match
112      */
113     private boolean checkHashes(ChecksumsImpl checksums) {
114         final String md5sum = expectedDependency.getMd5sum();
115         final String hashMismatchFormat = "Artifact found by API is not matching the {} of the artifact (repository hash is {} while actual is {}) !";
116         boolean match = true;
117         if (!checksums.getMd5().equals(md5sum)) {
118             LOGGER.warn(hashMismatchFormat, "md5", md5sum, checksums.getMd5());
119             match = false;
120         }
121         final String sha1sum = expectedDependency.getSha1sum();
122         if (!checksums.getSha1().equals(sha1sum)) {
123             LOGGER.warn(hashMismatchFormat, "sha1", sha1sum, checksums.getSha1());
124             match = false;
125         }
126         final String sha256sum = expectedDependency.getSha256sum();
127         /* For SHA-256, we need to validate that the checksum is non-null in the artifactory response.
128          * Extract from Artifactory documentation:
129          * New artifacts that are uploaded to Artifactory 5.5 and later will automatically have their SHA-256 checksum calculated.
130          * However, artifacts that were already hosted in Artifactory before the upgrade will not have their SHA-256 checksum in the database yet.
131          * To make full use of Artifactory's SHA-256 capabilities, you need to Migrate the Database to Include SHA-256 making sure that the record
132          * for each artifact includes its SHA-256 checksum.
133          */
134         if (checksums.getSha256() != null && !checksums.getSha256().equals(sha256sum)) {
135             LOGGER.warn(hashMismatchFormat, "sha256", sha256sum, checksums.getSha256());
136             match = false;
137         }
138         return match;
139     }
140 
141     /**
142      * Process the Artifactory response.
143      *
144      * @param response the HTTP response
145      * @return a list of the Maven Artifact information that matches the searched dependency hash
146      * @throws FileNotFoundException When a matching artifact is not found
147      * @throws IOException           thrown if there is an I/O error
148      */
149     @Override
150     public List<MavenArtifact> handleResponse(ClassicHttpResponse response) throws IOException {
151         final List<MavenArtifact> result = new ArrayList<>();
152 
153         try (InputStreamReader streamReader = new InputStreamReader(response.getEntity().getContent(), StandardCharsets.UTF_8);
154              JsonParser parser = fileImplReader.getFactory().createParser(streamReader)) {
155 
156             if (init(parser) && parser.nextToken() == JsonToken.START_OBJECT) {
157                 // at least one result
158                 do {
159                     final FileImpl file = fileImplReader.readValue(parser);
160 
161                     if (file.getChecksums() == null) {
162                         LOGGER.warn(
163                                 "No checksums found in Artifactory search result for '{}'. " +
164                                 "Specifically, the result set contains URI '{}' but it is missing the 'checksums' property. " +
165                                 "Please make sure that the '{}' header is retained on any (reverse-)proxy, load-balancer or Web Application Firewall in the network path to your Artifactory server.",
166                                 sourceUrl, file.getUri(), X_RESULT_DETAIL_HEADER);
167                         continue;
168                     }
169 
170                     final Optional<Matcher> validationResult = validateUsability(file);
171                     if (validationResult.isEmpty()) {
172                         continue;
173                     }
174                     final Matcher pathMatcher = validationResult.get();
175 
176                     final String groupId = pathMatcher.group("groupId").replace('/', '.');
177                     final String artifactId = pathMatcher.group("artifactId");
178                     final String version = pathMatcher.group("version");
179 
180                     result.add(new MavenArtifact(groupId, artifactId, version, file.getDownloadUri(),
181                             MavenArtifact.derivePomUrl(artifactId, version, file.getDownloadUri())));
182 
183                 } while (parser.nextToken() == JsonToken.START_OBJECT);
184             } else {
185                 throw new FileNotFoundException("Artifact " + expectedDependency + " not found in Artifactory");
186             }
187         }
188         if (result.isEmpty()) {
189             throw new FileNotFoundException("Artifact " + expectedDependency
190                     + " not found in Artifactory; discovered SHA1 hits not recognized as matching Maven artifacts");
191         }
192         return result;
193     }
194 
195     /**
196      * Validate the FileImpl result for usability as a dependency.
197      * <br/>
198      * Checks that the file actually matches all known hashes and the path appears to match a maven repository G/A/V pattern.
199      *
200      * @param file The FileImpl from an Artifactory search response
201      * @return An Optional with the Matcher for the file path to retrieve the Maven G/A/V coordinates in case the result is usable for further
202      * processing, otherwise an empty Optional.
203      */
204     private Optional<Matcher> validateUsability(FileImpl file) {
205         final Optional<Matcher> result;
206         if (!checkHashes(file.getChecksums())) {
207             result = Optional.empty();
208         } else {
209             final Matcher pathMatcher = PATH_PATTERN.matcher(file.getPath());
210             if (!pathMatcher.matches()) {
211                 LOGGER.debug("Cannot extract the Maven information from the path retrieved in Artifactory {}", file.getPath());
212                 result = Optional.empty();
213             } else {
214                 result = Optional.of(pathMatcher);
215             }
216         }
217         return result;
218     }
219 }