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