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) 2012 Jeremy Long. All Rights Reserved.
17   */
18  package org.owasp.dependencycheck.analyzer;
19  
20  import java.io.File;
21  import java.util.HashSet;
22  import java.util.Iterator;
23  import java.util.ListIterator;
24  import java.util.Set;
25  import java.util.regex.Matcher;
26  import java.util.regex.Pattern;
27  import org.owasp.dependencycheck.Engine;
28  import org.owasp.dependencycheck.analyzer.exception.AnalysisException;
29  import org.owasp.dependencycheck.dependency.Dependency;
30  import org.owasp.dependencycheck.dependency.Identifier;
31  import org.owasp.dependencycheck.utils.DependencyVersion;
32  import org.owasp.dependencycheck.utils.DependencyVersionUtil;
33  import org.slf4j.Logger;
34  import org.slf4j.LoggerFactory;
35  
36  /**
37   * <p>
38   * This analyzer ensures dependencies that should be grouped together, to remove excess noise from the report, are grouped. An
39   * example would be Spring, Spring Beans, Spring MVC, etc. If they are all for the same version and have the same relative path
40   * then these should be grouped into a single dependency under the core/main library.</p>
41   * <p>
42   * Note, this grouping only works on dependencies with identified CVE entries</p>
43   *
44   * @author Jeremy Long
45   */
46  public class DependencyBundlingAnalyzer extends AbstractAnalyzer implements Analyzer {
47  
48      /**
49       * The Logger.
50       */
51      private static final Logger LOGGER = LoggerFactory.getLogger(DependencyBundlingAnalyzer.class);
52  
53      //<editor-fold defaultstate="collapsed" desc="Constants and Member Variables">
54      /**
55       * A pattern for obtaining the first part of a filename.
56       */
57      private static final Pattern STARTING_TEXT_PATTERN = Pattern.compile("^[a-zA-Z0-9]*");
58      /**
59       * a flag indicating if this analyzer has run. This analyzer only runs once.
60       */
61      private boolean analyzed = false;
62      //</editor-fold>
63      //<editor-fold defaultstate="collapsed" desc="All standard implementation details of Analyzer">
64      /**
65       * The name of the analyzer.
66       */
67      private static final String ANALYZER_NAME = "Dependency Bundling Analyzer";
68      /**
69       * The phase that this analyzer is intended to run in.
70       */
71      private static final AnalysisPhase ANALYSIS_PHASE = AnalysisPhase.PRE_FINDING_ANALYSIS;
72  
73      /**
74       * Returns the name of the analyzer.
75       *
76       * @return the name of the analyzer.
77       */
78      @Override
79      public String getName() {
80          return ANALYZER_NAME;
81      }
82  
83      /**
84       * Returns the phase that the analyzer is intended to run in.
85       *
86       * @return the phase that the analyzer is intended to run in.
87       */
88      @Override
89      public AnalysisPhase getAnalysisPhase() {
90          return ANALYSIS_PHASE;
91      }
92      //</editor-fold>
93  
94      /**
95       * Analyzes a set of dependencies. If they have been found to have the same base path and the same set of identifiers they are
96       * likely related. The related dependencies are bundled into a single reportable item.
97       *
98       * @param ignore this analyzer ignores the dependency being analyzed
99       * @param engine the engine that is scanning the dependencies
100      * @throws AnalysisException is thrown if there is an error reading the JAR file.
101      */
102     @Override
103     public void analyze(Dependency ignore, Engine engine) throws AnalysisException {
104         if (!analyzed) {
105             analyzed = true;
106             final Set<Dependency> dependenciesToRemove = new HashSet<Dependency>();
107             final ListIterator<Dependency> mainIterator = engine.getDependencies().listIterator();
108             //for (Dependency nextDependency : engine.getDependencies()) {
109             while (mainIterator.hasNext()) {
110                 final Dependency dependency = mainIterator.next();
111                 if (mainIterator.hasNext() && !dependenciesToRemove.contains(dependency)) {
112                     final ListIterator<Dependency> subIterator = engine.getDependencies().listIterator(mainIterator.nextIndex());
113                     while (subIterator.hasNext()) {
114                         final Dependency nextDependency = subIterator.next();
115                         if (hashesMatch(dependency, nextDependency) && !containedInWar(dependency.getFilePath())
116                                 && !containedInWar(nextDependency.getFilePath())) {
117                             if (firstPathIsShortest(dependency.getFilePath(), nextDependency.getFilePath())) {
118                                 mergeDependencies(dependency, nextDependency, dependenciesToRemove);
119                             } else {
120                                 mergeDependencies(nextDependency, dependency, dependenciesToRemove);
121                                 break; //since we merged into the next dependency - skip forward to the next in mainIterator
122                             }
123                         } else if (isShadedJar(dependency, nextDependency)) {
124                             if (dependency.getFileName().toLowerCase().endsWith("pom.xml")) {
125                                 mergeDependencies(nextDependency, dependency, dependenciesToRemove);
126                                 nextDependency.getRelatedDependencies().remove(dependency);
127                                 break;
128                             } else {
129                                 mergeDependencies(dependency, nextDependency, dependenciesToRemove);
130                                 dependency.getRelatedDependencies().remove(nextDependency);
131                             }
132                         } else if (cpeIdentifiersMatch(dependency, nextDependency)
133                                 && hasSameBasePath(dependency, nextDependency)
134                                 && fileNameMatch(dependency, nextDependency)) {
135                             if (isCore(dependency, nextDependency)) {
136                                 mergeDependencies(dependency, nextDependency, dependenciesToRemove);
137                             } else {
138                                 mergeDependencies(nextDependency, dependency, dependenciesToRemove);
139                                 break; //since we merged into the next dependency - skip forward to the next in mainIterator
140                             }
141                         }
142                     }
143                 }
144             }
145             //removing dependencies here as ensuring correctness and avoiding ConcurrentUpdateExceptions
146             // was difficult because of the inner iterator.
147             engine.getDependencies().removeAll(dependenciesToRemove);
148         }
149     }
150 
151     /**
152      * Adds the relatedDependency to the dependency's related dependencies.
153      *
154      * @param dependency the main dependency
155      * @param relatedDependency a collection of dependencies to be removed from the main analysis loop, this is the source of
156      * dependencies to remove
157      * @param dependenciesToRemove a collection of dependencies that will be removed from the main analysis loop, this function
158      * adds to this collection
159      */
160     private void mergeDependencies(final Dependency dependency, final Dependency relatedDependency, final Set<Dependency> dependenciesToRemove) {
161         dependency.addRelatedDependency(relatedDependency);
162         final Iterator<Dependency> i = relatedDependency.getRelatedDependencies().iterator();
163         while (i.hasNext()) {
164             dependency.addRelatedDependency(i.next());
165             i.remove();
166         }
167         if (dependency.getSha1sum().equals(relatedDependency.getSha1sum())) {
168             dependency.addAllProjectReferences(relatedDependency.getProjectReferences());
169         }
170         dependenciesToRemove.add(relatedDependency);
171     }
172 
173     /**
174      * Attempts to trim a maven repo to a common base path. This is typically [drive]\[repo_location]\repository\[path1]\[path2].
175      *
176      * @param path the path to trim
177      * @return a string representing the base path.
178      */
179     private String getBaseRepoPath(final String path) {
180         int pos = path.indexOf("repository" + File.separator) + 11;
181         if (pos < 0) {
182             return path;
183         }
184         int tmp = path.indexOf(File.separator, pos);
185         if (tmp <= 0) {
186             return path;
187         }
188         if (tmp > 0) {
189             pos = tmp + 1;
190         }
191         tmp = path.indexOf(File.separator, pos);
192         if (tmp > 0) {
193             pos = tmp + 1;
194         }
195         return path.substring(0, pos);
196     }
197 
198     /**
199      * Returns true if the file names (and version if it exists) of the two dependencies are sufficiently similar.
200      *
201      * @param dependency1 a dependency2 to compare
202      * @param dependency2 a dependency2 to compare
203      * @return true if the identifiers in the two supplied dependencies are equal
204      */
205     private boolean fileNameMatch(Dependency dependency1, Dependency dependency2) {
206         if (dependency1 == null || dependency1.getFileName() == null
207                 || dependency2 == null || dependency2.getFileName() == null) {
208             return false;
209         }
210         final String fileName1 = dependency1.getActualFile().getName();
211         final String fileName2 = dependency2.getActualFile().getName();
212 
213         //version check
214         final DependencyVersion version1 = DependencyVersionUtil.parseVersion(fileName1);
215         final DependencyVersion version2 = DependencyVersionUtil.parseVersion(fileName2);
216         if (version1 != null && version2 != null && !version1.equals(version2)) {
217             return false;
218         }
219 
220         //filename check
221         final Matcher match1 = STARTING_TEXT_PATTERN.matcher(fileName1);
222         final Matcher match2 = STARTING_TEXT_PATTERN.matcher(fileName2);
223         if (match1.find() && match2.find()) {
224             return match1.group().equals(match2.group());
225         }
226 
227         return false;
228     }
229 
230     /**
231      * Returns true if the CPE identifiers in the two supplied dependencies are equal.
232      *
233      * @param dependency1 a dependency2 to compare
234      * @param dependency2 a dependency2 to compare
235      * @return true if the identifiers in the two supplied dependencies are equal
236      */
237     private boolean cpeIdentifiersMatch(Dependency dependency1, Dependency dependency2) {
238         if (dependency1 == null || dependency1.getIdentifiers() == null
239                 || dependency2 == null || dependency2.getIdentifiers() == null) {
240             return false;
241         }
242         boolean matches = false;
243         int cpeCount1 = 0;
244         int cpeCount2 = 0;
245         for (Identifier i : dependency1.getIdentifiers()) {
246             if ("cpe".equals(i.getType())) {
247                 cpeCount1 += 1;
248             }
249         }
250         for (Identifier i : dependency2.getIdentifiers()) {
251             if ("cpe".equals(i.getType())) {
252                 cpeCount2 += 1;
253             }
254         }
255         if (cpeCount1 > 0 && cpeCount1 == cpeCount2) {
256             for (Identifier i : dependency1.getIdentifiers()) {
257                 if ("cpe".equals(i.getType())) {
258                     matches |= dependency2.getIdentifiers().contains(i);
259                     if (!matches) {
260                         break;
261                     }
262                 }
263             }
264         }
265         LOGGER.debug("IdentifiersMatch={} ({}, {})", matches, dependency1.getFileName(), dependency2.getFileName());
266         return matches;
267     }
268 
269     /**
270      * Determines if the two dependencies have the same base path.
271      *
272      * @param dependency1 a Dependency object
273      * @param dependency2 a Dependency object
274      * @return true if the base paths of the dependencies are identical
275      */
276     private boolean hasSameBasePath(Dependency dependency1, Dependency dependency2) {
277         if (dependency1 == null || dependency2 == null) {
278             return false;
279         }
280         final File lFile = new File(dependency1.getFilePath());
281         String left = lFile.getParent();
282         final File rFile = new File(dependency2.getFilePath());
283         String right = rFile.getParent();
284         if (left == null) {
285             return right == null;
286         }
287         if (left.equalsIgnoreCase(right)) {
288             return true;
289         }
290         if (left.matches(".*[/\\\\]repository[/\\\\].*") && right.matches(".*[/\\\\]repository[/\\\\].*")) {
291             left = getBaseRepoPath(left);
292             right = getBaseRepoPath(right);
293         }
294         if (left.equalsIgnoreCase(right)) {
295             return true;
296         }
297         //new code
298         for (Dependency child : dependency2.getRelatedDependencies()) {
299             if (hasSameBasePath(dependency1, child)) {
300                 return true;
301             }
302         }
303         return false;
304     }
305 
306     /**
307      * This is likely a very broken attempt at determining if the 'left' dependency is the 'core' library in comparison to the
308      * 'right' library.
309      *
310      * @param left the dependency to test
311      * @param right the dependency to test against
312      * @return a boolean indicating whether or not the left dependency should be considered the "core" version.
313      */
314     boolean isCore(Dependency left, Dependency right) {
315         final String leftName = left.getFileName().toLowerCase();
316         final String rightName = right.getFileName().toLowerCase();
317 
318         final boolean returnVal;
319         if (!rightName.matches(".*\\.(tar|tgz|gz|zip|ear|war).+") && leftName.matches(".*\\.(tar|tgz|gz|zip|ear|war).+")
320                 || rightName.contains("core") && !leftName.contains("core")
321                 || rightName.contains("kernel") && !leftName.contains("kernel")) {
322             returnVal = false;
323         } else if (rightName.matches(".*\\.(tar|tgz|gz|zip|ear|war).+") && !leftName.matches(".*\\.(tar|tgz|gz|zip|ear|war).+")
324                 || !rightName.contains("core") && leftName.contains("core")
325                 || !rightName.contains("kernel") && leftName.contains("kernel")) {
326             returnVal = true;
327 //        } else if (leftName.matches(".*struts2\\-core.*") && rightName.matches(".*xwork\\-core.*")) {
328 //            returnVal = true;
329 //        } else if (rightName.matches(".*struts2\\-core.*") && leftName.matches(".*xwork\\-core.*")) {
330 //            returnVal = false;
331         } else {
332             /*
333              * considered splitting the names up and comparing the components,
334              * but decided that the file name length should be sufficient as the
335              * "core" component, if this follows a normal naming protocol should
336              * be shorter:
337              * axis2-saaj-1.4.1.jar
338              * axis2-1.4.1.jar       <-----
339              * axis2-kernel-1.4.1.jar
340              */
341             returnVal = leftName.length() <= rightName.length();
342         }
343         LOGGER.debug("IsCore={} ({}, {})", returnVal, left.getFileName(), right.getFileName());
344         return returnVal;
345     }
346 
347     /**
348      * Compares the SHA1 hashes of two dependencies to determine if they are equal.
349      *
350      * @param dependency1 a dependency object to compare
351      * @param dependency2 a dependency object to compare
352      * @return true if the sha1 hashes of the two dependencies match; otherwise false
353      */
354     private boolean hashesMatch(Dependency dependency1, Dependency dependency2) {
355         if (dependency1 == null || dependency2 == null || dependency1.getSha1sum() == null || dependency2.getSha1sum() == null) {
356             return false;
357         }
358         return dependency1.getSha1sum().equals(dependency2.getSha1sum());
359     }
360 
361     /**
362      * Determines if the jar is shaded and the created pom.xml identified the same CPE as the jar - if so, the pom.xml dependency
363      * should be removed.
364      *
365      * @param dependency a dependency to check
366      * @param nextDependency another dependency to check
367      * @return true if on of the dependencies is a pom.xml and the identifiers between the two collections match; otherwise false
368      */
369     private boolean isShadedJar(Dependency dependency, Dependency nextDependency) {
370         final String mainName = dependency.getFileName().toLowerCase();
371         final String nextName = nextDependency.getFileName().toLowerCase();
372         if (mainName.endsWith(".jar") && nextName.endsWith("pom.xml")) {
373             return dependency.getIdentifiers().containsAll(nextDependency.getIdentifiers());
374         } else if (nextName.endsWith(".jar") && mainName.endsWith("pom.xml")) {
375             return nextDependency.getIdentifiers().containsAll(dependency.getIdentifiers());
376         }
377         return false;
378     }
379 
380     /**
381      * Determines which path is shortest; if path lengths are equal then we use compareTo of the string method to determine if the
382      * first path is smaller.
383      *
384      * @param left the first path to compare
385      * @param right the second path to compare
386      * @return <code>true</code> if the leftPath is the shortest; otherwise <code>false</code>
387      */
388     protected boolean firstPathIsShortest(String left, String right) {
389         final String leftPath = left.replace('\\', '/');
390         final String rightPath = right.replace('\\', '/');
391 
392         final int leftCount = countChar(leftPath, '/');
393         final int rightCount = countChar(rightPath, '/');
394         if (leftCount == rightCount) {
395             return leftPath.compareTo(rightPath) <= 0;
396         } else {
397             return leftCount < rightCount;
398         }
399     }
400 
401     /**
402      * Counts the number of times the character is present in the string.
403      *
404      * @param string the string to count the characters in
405      * @param c the character to count
406      * @return the number of times the character is present in the string
407      */
408     private int countChar(String string, char c) {
409         int count = 0;
410         final int max = string.length();
411         for (int i = 0; i < max; i++) {
412             if (c == string.charAt(i)) {
413                 count++;
414             }
415         }
416         return count;
417     }
418 
419     /**
420      * Checks if the given file path is contained within a war or ear file.
421      *
422      * @param filePath the file path to check
423      * @return true if the path contains '.war\' or '.ear\'.
424      */
425     private boolean containedInWar(String filePath) {
426         return filePath == null ? false : filePath.matches(".*\\.(ear|war)[\\\\/].*");
427     }
428 }