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) {
217             if (!version1.equals(version2)) {
218                 return false;
219             }
220         }
221 
222         //filename check
223         final Matcher match1 = STARTING_TEXT_PATTERN.matcher(fileName1);
224         final Matcher match2 = STARTING_TEXT_PATTERN.matcher(fileName2);
225         if (match1.find() && match2.find()) {
226             return match1.group().equals(match2.group());
227         }
228 
229         return false;
230     }
231 
232     /**
233      * Returns true if the CPE identifiers in the two supplied dependencies are equal.
234      *
235      * @param dependency1 a dependency2 to compare
236      * @param dependency2 a dependency2 to compare
237      * @return true if the identifiers in the two supplied dependencies are equal
238      */
239     private boolean cpeIdentifiersMatch(Dependency dependency1, Dependency dependency2) {
240         if (dependency1 == null || dependency1.getIdentifiers() == null
241                 || dependency2 == null || dependency2.getIdentifiers() == null) {
242             return false;
243         }
244         boolean matches = false;
245         int cpeCount1 = 0;
246         int cpeCount2 = 0;
247         for (Identifier i : dependency1.getIdentifiers()) {
248             if ("cpe".equals(i.getType())) {
249                 cpeCount1 += 1;
250             }
251         }
252         for (Identifier i : dependency2.getIdentifiers()) {
253             if ("cpe".equals(i.getType())) {
254                 cpeCount2 += 1;
255             }
256         }
257         if (cpeCount1 > 0 && cpeCount1 == cpeCount2) {
258             for (Identifier i : dependency1.getIdentifiers()) {
259                 if ("cpe".equals(i.getType())) {
260                     matches |= dependency2.getIdentifiers().contains(i);
261                     if (!matches) {
262                         break;
263                     }
264                 }
265             }
266         }
267         LOGGER.debug("IdentifiersMatch={} ({}, {})", matches, dependency1.getFileName(), dependency2.getFileName());
268         return matches;
269     }
270 
271     /**
272      * Determines if the two dependencies have the same base path.
273      *
274      * @param dependency1 a Dependency object
275      * @param dependency2 a Dependency object
276      * @return true if the base paths of the dependencies are identical
277      */
278     private boolean hasSameBasePath(Dependency dependency1, Dependency dependency2) {
279         if (dependency1 == null || dependency2 == null) {
280             return false;
281         }
282         final File lFile = new File(dependency1.getFilePath());
283         String left = lFile.getParent();
284         final File rFile = new File(dependency2.getFilePath());
285         String right = rFile.getParent();
286         if (left == null) {
287             return right == null;
288         }
289         if (left.equalsIgnoreCase(right)) {
290             return true;
291         }
292         if (left.matches(".*[/\\\\]repository[/\\\\].*") && right.matches(".*[/\\\\]repository[/\\\\].*")) {
293             left = getBaseRepoPath(left);
294             right = getBaseRepoPath(right);
295         }
296         if (left.equalsIgnoreCase(right)) {
297             return true;
298         }
299         //new code
300         for (Dependency child : dependency2.getRelatedDependencies()) {
301             if (hasSameBasePath(dependency1, child)) {
302                 return true;
303             }
304         }
305         return false;
306     }
307 
308     /**
309      * This is likely a very broken attempt at determining if the 'left' dependency is the 'core' library in comparison to the
310      * 'right' library.
311      *
312      * @param left the dependency to test
313      * @param right the dependency to test against
314      * @return a boolean indicating whether or not the left dependency should be considered the "core" version.
315      */
316     boolean isCore(Dependency left, Dependency right) {
317         final String leftName = left.getFileName().toLowerCase();
318         final String rightName = right.getFileName().toLowerCase();
319 
320         final boolean returnVal;
321         if (!rightName.matches(".*\\.(tar|tgz|gz|zip|ear|war).+") && leftName.matches(".*\\.(tar|tgz|gz|zip|ear|war).+")
322                 || rightName.contains("core") && !leftName.contains("core")
323                 || rightName.contains("kernel") && !leftName.contains("kernel")) {
324             returnVal = false;
325         } else if (rightName.matches(".*\\.(tar|tgz|gz|zip|ear|war).+") && !leftName.matches(".*\\.(tar|tgz|gz|zip|ear|war).+")
326                 || !rightName.contains("core") && leftName.contains("core")
327                 || !rightName.contains("kernel") && leftName.contains("kernel")) {
328             returnVal = true;
329 //        } else if (leftName.matches(".*struts2\\-core.*") && rightName.matches(".*xwork\\-core.*")) {
330 //            returnVal = true;
331 //        } else if (rightName.matches(".*struts2\\-core.*") && leftName.matches(".*xwork\\-core.*")) {
332 //            returnVal = false;
333         } else {
334             /*
335              * considered splitting the names up and comparing the components,
336              * but decided that the file name length should be sufficient as the
337              * "core" component, if this follows a normal naming protocol should
338              * be shorter:
339              * axis2-saaj-1.4.1.jar
340              * axis2-1.4.1.jar       <-----
341              * axis2-kernel-1.4.1.jar
342              */
343             returnVal = leftName.length() <= rightName.length();
344         }
345         LOGGER.debug("IsCore={} ({}, {})", returnVal, left.getFileName(), right.getFileName());
346         return returnVal;
347     }
348 
349     /**
350      * Compares the SHA1 hashes of two dependencies to determine if they are equal.
351      *
352      * @param dependency1 a dependency object to compare
353      * @param dependency2 a dependency object to compare
354      * @return true if the sha1 hashes of the two dependencies match; otherwise false
355      */
356     private boolean hashesMatch(Dependency dependency1, Dependency dependency2) {
357         if (dependency1 == null || dependency2 == null || dependency1.getSha1sum() == null || dependency2.getSha1sum() == null) {
358             return false;
359         }
360         return dependency1.getSha1sum().equals(dependency2.getSha1sum());
361     }
362 
363     /**
364      * Determines if the jar is shaded and the created pom.xml identified the same CPE as the jar - if so, the pom.xml dependency
365      * should be removed.
366      *
367      * @param dependency a dependency to check
368      * @param nextDependency another dependency to check
369      * @return true if on of the dependencies is a pom.xml and the identifiers between the two collections match; otherwise false
370      */
371     private boolean isShadedJar(Dependency dependency, Dependency nextDependency) {
372         final String mainName = dependency.getFileName().toLowerCase();
373         final String nextName = nextDependency.getFileName().toLowerCase();
374         if (mainName.endsWith(".jar") && nextName.endsWith("pom.xml")) {
375             return dependency.getIdentifiers().containsAll(nextDependency.getIdentifiers());
376         } else if (nextName.endsWith(".jar") && mainName.endsWith("pom.xml")) {
377             return nextDependency.getIdentifiers().containsAll(dependency.getIdentifiers());
378         }
379         return false;
380     }
381 
382     /**
383      * Determines which path is shortest; if path lengths are equal then we use compareTo of the string method to determine if the
384      * first path is smaller.
385      *
386      * @param left the first path to compare
387      * @param right the second path to compare
388      * @return <code>true</code> if the leftPath is the shortest; otherwise <code>false</code>
389      */
390     protected boolean firstPathIsShortest(String left, String right) {
391         final String leftPath = left.replace('\\', '/');
392         final String rightPath = right.replace('\\', '/');
393 
394         final int leftCount = countChar(leftPath, '/');
395         final int rightCount = countChar(rightPath, '/');
396         if (leftCount == rightCount) {
397             return leftPath.compareTo(rightPath) <= 0;
398         } else {
399             return leftCount < rightCount;
400         }
401     }
402 
403     /**
404      * Counts the number of times the character is present in the string.
405      *
406      * @param string the string to count the characters in
407      * @param c the character to count
408      * @return the number of times the character is present in the string
409      */
410     private int countChar(String string, char c) {
411         int count = 0;
412         final int max = string.length();
413         for (int i = 0; i < max; i++) {
414             if (c == string.charAt(i)) {
415                 count++;
416             }
417         }
418         return count;
419     }
420 
421     /**
422      * Checks if the given file path is contained within a war or ear file.
423      *
424      * @param filePath the file path to check
425      * @return true if the path contains '.war\' or '.ear\'.
426      */
427     private boolean containedInWar(String filePath) {
428         return filePath == null ? false : filePath.matches(".*\\.(ear|war)[\\\\/].*");
429     }
430 }