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) 2013 Jeremy Long. All Rights Reserved.
17   */
18  package org.owasp.dependencycheck.analyzer;
19  
20  import java.io.BufferedInputStream;
21  import java.io.Closeable;
22  import java.io.File;
23  import java.io.FileFilter;
24  import java.io.FileInputStream;
25  import java.io.FileNotFoundException;
26  import java.io.FileOutputStream;
27  import java.io.IOException;
28  import java.util.ArrayList;
29  import java.util.Collections;
30  import java.util.Enumeration;
31  import java.util.HashSet;
32  import java.util.List;
33  import java.util.Set;
34  
35  import org.apache.commons.compress.archivers.ArchiveEntry;
36  import org.apache.commons.compress.archivers.ArchiveInputStream;
37  import org.apache.commons.compress.archivers.tar.TarArchiveInputStream;
38  import org.apache.commons.compress.archivers.zip.ZipArchiveEntry;
39  import org.apache.commons.compress.archivers.zip.ZipArchiveInputStream;
40  import org.apache.commons.compress.archivers.zip.ZipFile;
41  import org.apache.commons.compress.compressors.CompressorInputStream;
42  import org.apache.commons.compress.compressors.bzip2.BZip2CompressorInputStream;
43  import org.apache.commons.compress.compressors.bzip2.BZip2Utils;
44  import org.apache.commons.compress.compressors.gzip.GzipCompressorInputStream;
45  import org.apache.commons.compress.compressors.gzip.GzipUtils;
46  import org.apache.commons.compress.utils.IOUtils;
47  
48  import org.owasp.dependencycheck.Engine;
49  import org.owasp.dependencycheck.analyzer.exception.AnalysisException;
50  import org.owasp.dependencycheck.analyzer.exception.ArchiveExtractionException;
51  import org.owasp.dependencycheck.dependency.Dependency;
52  import org.owasp.dependencycheck.utils.FileFilterBuilder;
53  import org.owasp.dependencycheck.utils.FileUtils;
54  import org.owasp.dependencycheck.utils.Settings;
55  
56  import org.slf4j.Logger;
57  import org.slf4j.LoggerFactory;
58  
59  /**
60   * <p>
61   * An analyzer that extracts files from archives and ensures any supported files contained within the archive are added to the
62   * dependency list.</p>
63   *
64   * @author Jeremy Long
65   */
66  public class ArchiveAnalyzer extends AbstractFileTypeAnalyzer {
67  
68      /**
69       * The logger.
70       */
71      private static final Logger LOGGER = LoggerFactory.getLogger(ArchiveAnalyzer.class);
72      /**
73       * The count of directories created during analysis. This is used for creating temporary directories.
74       */
75      private static int dirCount = 0;
76      /**
77       * The parent directory for the individual directories per archive.
78       */
79      private File tempFileLocation = null;
80      /**
81       * The max scan depth that the analyzer will recursively extract nested archives.
82       */
83      private static final int MAX_SCAN_DEPTH = Settings.getInt("archive.scan.depth", 3);
84      /**
85       * Tracks the current scan/extraction depth for nested archives.
86       */
87      private int scanDepth = 0;
88  
89      //<editor-fold defaultstate="collapsed" desc="All standard implementation details of Analyzer">
90      /**
91       * The name of the analyzer.
92       */
93      private static final String ANALYZER_NAME = "Archive Analyzer";
94      /**
95       * The phase that this analyzer is intended to run in.
96       */
97      private static final AnalysisPhase ANALYSIS_PHASE = AnalysisPhase.INITIAL;
98      /**
99       * The set of things we can handle with Zip methods
100      */
101     private static final Set<String> ZIPPABLES = newHashSet("zip", "ear", "war", "jar", "sar", "apk", "nupkg");
102     /**
103      * The set of file extensions supported by this analyzer. Note for developers, any additions to this list will need to be
104      * explicitly handled in {@link #extractFiles(File, File, Engine)}.
105      */
106     private static final Set<String> EXTENSIONS = newHashSet("tar", "gz", "tgz", "bz2", "tbz2");
107 
108     /**
109      * Detects files with extensions to remove from the engine's collection of dependencies.
110      */
111     private static final FileFilter REMOVE_FROM_ANALYSIS = FileFilterBuilder.newInstance().addExtensions("zip", "tar", "gz", "tgz", "bz2", "tbz2")
112             .build();
113 
114     static {
115         final String additionalZipExt = Settings.getString(Settings.KEYS.ADDITIONAL_ZIP_EXTENSIONS);
116         if (additionalZipExt != null) {
117             final String[] ext = additionalZipExt.split("\\s*,\\s*");
118             Collections.addAll(ZIPPABLES, ext);
119         }
120         EXTENSIONS.addAll(ZIPPABLES);
121     }
122 
123     /**
124      * The file filter used to filter supported files.
125      */
126     private static final FileFilter FILTER = FileFilterBuilder.newInstance().addExtensions(EXTENSIONS).build();
127 
128     @Override
129     protected FileFilter getFileFilter() {
130         return FILTER;
131     }
132 
133     /**
134      * Detects files with .zip extension.
135      */
136     private static final FileFilter ZIP_FILTER = FileFilterBuilder.newInstance().addExtensions("zip").build();
137 
138     /**
139      * Returns the name of the analyzer.
140      *
141      * @return the name of the analyzer.
142      */
143     @Override
144     public String getName() {
145         return ANALYZER_NAME;
146     }
147 
148     /**
149      * Returns the phase that the analyzer is intended to run in.
150      *
151      * @return the phase that the analyzer is intended to run in.
152      */
153     @Override
154     public AnalysisPhase getAnalysisPhase() {
155         return ANALYSIS_PHASE;
156     }
157     //</editor-fold>
158 
159     /**
160      * Returns the key used in the properties file to reference the analyzer's enabled property.
161      *
162      * @return the analyzer's enabled property setting key
163      */
164     @Override
165     protected String getAnalyzerEnabledSettingKey() {
166         return Settings.KEYS.ANALYZER_ARCHIVE_ENABLED;
167     }
168 
169     /**
170      * The initialize method does nothing for this Analyzer.
171      *
172      * @throws Exception is thrown if there is an exception deleting or creating temporary files
173      */
174     @Override
175     public void initializeFileTypeAnalyzer() throws Exception {
176         final File baseDir = Settings.getTempDirectory();
177         tempFileLocation = File.createTempFile("check", "tmp", baseDir);
178         if (!tempFileLocation.delete()) {
179             final String msg = String.format("Unable to delete temporary file '%s'.", tempFileLocation.getAbsolutePath());
180             throw new AnalysisException(msg);
181         }
182         if (!tempFileLocation.mkdirs()) {
183             final String msg = String.format("Unable to create directory '%s'.", tempFileLocation.getAbsolutePath());
184             throw new AnalysisException(msg);
185         }
186     }
187 
188     /**
189      * The close method deletes any temporary files and directories created during analysis.
190      *
191      * @throws Exception thrown if there is an exception deleting temporary files
192      */
193     @Override
194     public void close() throws Exception {
195         if (tempFileLocation != null && tempFileLocation.exists()) {
196             LOGGER.debug("Attempting to delete temporary files");
197             final boolean success = FileUtils.delete(tempFileLocation);
198             if (!success && tempFileLocation.exists()) {
199                 final String[] l = tempFileLocation.list();
200                 if (l != null && l.length > 0) {
201                     LOGGER.warn("Failed to delete some temporary files, see the log for more details");
202                 }
203             }
204         }
205     }
206 
207     /**
208      * Analyzes a given dependency. If the dependency is an archive, such as a WAR or EAR, the contents are extracted, scanned,
209      * and added to the list of dependencies within the engine.
210      *
211      * @param dependency the dependency to analyze
212      * @param engine the engine scanning
213      * @throws AnalysisException thrown if there is an analysis exception
214      */
215     @Override
216     public void analyzeFileType(Dependency dependency, Engine engine) throws AnalysisException {
217         final File f = new File(dependency.getActualFilePath());
218         final File tmpDir = getNextTempDirectory();
219         extractFiles(f, tmpDir, engine);
220 
221         //make a copy
222         final Set<Dependency> dependencySet = findMoreDependencies(engine, tmpDir);
223         if (!dependencySet.isEmpty()) {
224             for (Dependency d : dependencySet) {
225                 //fix the dependency's display name and path
226                 final String displayPath = String.format("%s%s",
227                         dependency.getFilePath(),
228                         d.getActualFilePath().substring(tmpDir.getAbsolutePath().length()));
229                 final String displayName = String.format("%s: %s",
230                         dependency.getFileName(),
231                         d.getFileName());
232                 d.setFilePath(displayPath);
233                 d.setFileName(displayName);
234 
235                 //TODO - can we get more evidence from the parent? EAR contains module name, etc.
236                 //analyze the dependency (i.e. extract files) if it is a supported type.
237                 if (this.accept(d.getActualFile()) && scanDepth < MAX_SCAN_DEPTH) {
238                     scanDepth += 1;
239                     analyze(d, engine);
240                     scanDepth -= 1;
241                 }
242             }
243         }
244         if (REMOVE_FROM_ANALYSIS.accept(dependency.getActualFile())) {
245             addDisguisedJarsToDependencies(dependency, engine);
246             engine.getDependencies().remove(dependency);
247         }
248         Collections.sort(engine.getDependencies());
249     }
250 
251     /**
252      * If a zip file was identified as a possible JAR, this method will add the zip to the list of dependencies.
253      *
254      * @param dependency the zip file
255      * @param engine the engine
256      * @throws AnalysisException thrown if there is an issue
257      */
258     private void addDisguisedJarsToDependencies(Dependency dependency, Engine engine) throws AnalysisException {
259         if (ZIP_FILTER.accept(dependency.getActualFile()) && isZipFileActuallyJarFile(dependency)) {
260             final File tdir = getNextTempDirectory();
261             final String fileName = dependency.getFileName();
262 
263             LOGGER.info("The zip file '{}' appears to be a JAR file, making a copy and analyzing it as a JAR.", fileName);
264 
265             final File tmpLoc = new File(tdir, fileName.substring(0, fileName.length() - 3) + "jar");
266             try {
267                 org.apache.commons.io.FileUtils.copyFile(tdir, tmpLoc);
268                 final Set<Dependency> dependencySet = findMoreDependencies(engine, tmpLoc);
269                 if (!dependencySet.isEmpty()) {
270                     if (dependencySet.size() != 1) {
271                         LOGGER.info("Deep copy of ZIP to JAR file resulted in more than one dependency?");
272                     }
273                     for (Dependency d : dependencySet) {
274                         //fix the dependency's display name and path
275                         d.setFilePath(dependency.getFilePath());
276                         d.setDisplayFileName(dependency.getFileName());
277                     }
278                 }
279             } catch (IOException ex) {
280                 LOGGER.debug("Unable to perform deep copy on '{}'", dependency.getActualFile().getPath(), ex);
281             }
282         }
283     }
284     /**
285      * An empty dependency set.
286      */
287     private static final Set<Dependency> EMPTY_DEPENDENCY_SET = Collections.emptySet();
288 
289     /**
290      * Scan the given file/folder, and return any new dependencies found.
291      *
292      * @param engine used to scan
293      * @param file target of scanning
294      * @return any dependencies that weren't known to the engine before
295      */
296     private static Set<Dependency> findMoreDependencies(Engine engine, File file) {
297         final List<Dependency> before = new ArrayList<Dependency>(engine.getDependencies());
298         engine.scan(file);
299         final List<Dependency> after = engine.getDependencies();
300         final boolean sizeChanged = before.size() != after.size();
301         final Set<Dependency> newDependencies;
302         if (sizeChanged) {
303             //get the new dependencies
304             newDependencies = new HashSet<Dependency>(after);
305             newDependencies.removeAll(before);
306         } else {
307             newDependencies = EMPTY_DEPENDENCY_SET;
308         }
309         return newDependencies;
310     }
311 
312     /**
313      * Retrieves the next temporary directory to extract an archive too.
314      *
315      * @return a directory
316      * @throws AnalysisException thrown if unable to create temporary directory
317      */
318     private File getNextTempDirectory() throws AnalysisException {
319         dirCount += 1;
320         final File directory = new File(tempFileLocation, String.valueOf(dirCount));
321         //getting an exception for some directories not being able to be created; might be because the directory already exists?
322         if (directory.exists()) {
323             return getNextTempDirectory();
324         }
325         if (!directory.mkdirs()) {
326             final String msg = String.format("Unable to create temp directory '%s'.", directory.getAbsolutePath());
327             throw new AnalysisException(msg);
328         }
329         return directory;
330     }
331 
332     /**
333      * Extracts the contents of an archive into the specified directory.
334      *
335      * @param archive an archive file such as a WAR or EAR
336      * @param destination a directory to extract the contents to
337      * @param engine the scanning engine
338      * @throws AnalysisException thrown if the archive is not found
339      */
340     private void extractFiles(File archive, File destination, Engine engine) throws AnalysisException {
341         if (archive != null && destination != null) {
342             FileInputStream fis;
343             try {
344                 fis = new FileInputStream(archive);
345             } catch (FileNotFoundException ex) {
346                 LOGGER.debug("", ex);
347                 throw new AnalysisException("Archive file was not found.", ex);
348             }
349             final String archiveExt = FileUtils.getFileExtension(archive.getName()).toLowerCase();
350             try {
351                 if (ZIPPABLES.contains(archiveExt)) {
352                     extractArchive(new ZipArchiveInputStream(new BufferedInputStream(fis)), destination, engine);
353                 } else if ("tar".equals(archiveExt)) {
354                     extractArchive(new TarArchiveInputStream(new BufferedInputStream(fis)), destination, engine);
355                 } else if ("gz".equals(archiveExt) || "tgz".equals(archiveExt)) {
356                     final String uncompressedName = GzipUtils.getUncompressedFilename(archive.getName());
357                     final File f = new File(destination, uncompressedName);
358                     if (engine.accept(f)) {
359                         decompressFile(new GzipCompressorInputStream(new BufferedInputStream(fis)), f);
360                     }
361                 } else if ("bz2".equals(archiveExt) || "tbz2".equals(archiveExt)) {
362                     final String uncompressedName = BZip2Utils.getUncompressedFilename(archive.getName());
363                     final File f = new File(destination, uncompressedName);
364                     if (engine.accept(f)) {
365                         decompressFile(new BZip2CompressorInputStream(new BufferedInputStream(fis)), f);
366                     }
367                 }
368             } catch (ArchiveExtractionException ex) {
369                 LOGGER.warn("Exception extracting archive '{}'.", archive.getName());
370                 LOGGER.debug("", ex);
371             } catch (IOException ex) {
372                 LOGGER.warn("Exception reading archive '{}'.", archive.getName());
373                 LOGGER.debug("", ex);
374             } finally {
375                 close(fis);
376             }
377         }
378     }
379 
380     /**
381      * Extracts files from an archive.
382      *
383      * @param input the archive to extract files from
384      * @param destination the location to write the files too
385      * @param engine the dependency-check engine
386      * @throws ArchiveExtractionException thrown if there is an exception extracting files from the archive
387      */
388     private void extractArchive(ArchiveInputStream input, File destination, Engine engine) throws ArchiveExtractionException {
389         ArchiveEntry entry;
390         try {
391             while ((entry = input.getNextEntry()) != null) {
392                 final File file = new File(destination, entry.getName());
393                 if (entry.isDirectory()) {
394                     if (!file.exists() && !file.mkdirs()) {
395                         final String msg = String.format("Unable to create directory '%s'.", file.getAbsolutePath());
396                         throw new AnalysisException(msg);
397                     }
398                 } else if (engine.accept(file)) {
399                     extractAcceptedFile(input, file);
400                 }
401             }
402         } catch (Throwable ex) {
403             throw new ArchiveExtractionException(ex);
404         } finally {
405             close(input);
406         }
407     }
408 
409     /**
410      * Extracts a file from an archive.
411      *
412      * @param input the archives input stream
413      * @param file the file to extract
414      * @throws AnalysisException thrown if there is an error
415      */
416     private static void extractAcceptedFile(ArchiveInputStream input, File file) throws AnalysisException {
417         LOGGER.debug("Extracting '{}'", file.getPath());
418         FileOutputStream fos = null;
419         try {
420             final File parent = file.getParentFile();
421             if (!parent.isDirectory() && !parent.mkdirs()) {
422                 final String msg = String.format("Unable to build directory '%s'.", parent.getAbsolutePath());
423                 throw new AnalysisException(msg);
424             }
425             fos = new FileOutputStream(file);
426             IOUtils.copy(input, fos);
427         } catch (FileNotFoundException ex) {
428             LOGGER.debug("", ex);
429             final String msg = String.format("Unable to find file '%s'.", file.getName());
430             throw new AnalysisException(msg, ex);
431         } catch (IOException ex) {
432             LOGGER.debug("", ex);
433             final String msg = String.format("IO Exception while parsing file '%s'.", file.getName());
434             throw new AnalysisException(msg, ex);
435         } finally {
436             close(fos);
437         }
438     }
439 
440     /**
441      * Decompresses a file.
442      *
443      * @param inputStream the compressed file
444      * @param outputFile the location to write the decompressed file
445      * @throws ArchiveExtractionException thrown if there is an exception decompressing the file
446      */
447     private void decompressFile(CompressorInputStream inputStream, File outputFile) throws ArchiveExtractionException {
448         LOGGER.debug("Decompressing '{}'", outputFile.getPath());
449         FileOutputStream out = null;
450         try {
451             out = new FileOutputStream(outputFile);
452             IOUtils.copy(inputStream, out);
453         } catch (FileNotFoundException ex) {
454             LOGGER.debug("", ex);
455             throw new ArchiveExtractionException(ex);
456         } catch (IOException ex) {
457             LOGGER.debug("", ex);
458             throw new ArchiveExtractionException(ex);
459         } finally {
460             close(out);
461         }
462     }
463 
464     /**
465      * Close the given {@link Closeable} instance, ignoring nulls, and logging any thrown {@link IOException}.
466      *
467      * @param closeable to be closed
468      */
469     private static void close(Closeable closeable) {
470         if (null != closeable) {
471             try {
472                 closeable.close();
473             } catch (IOException ex) {
474                 LOGGER.trace("", ex);
475             }
476         }
477     }
478 
479     /**
480      * Attempts to determine if a zip file is actually a JAR file.
481      *
482      * @param dependency the dependency to check
483      * @return true if the dependency appears to be a JAR file; otherwise false
484      */
485     private boolean isZipFileActuallyJarFile(Dependency dependency) {
486         boolean isJar = false;
487         ZipFile zip = null;
488         try {
489             zip = new ZipFile(dependency.getActualFilePath());
490             if (zip.getEntry("META-INF/MANIFEST.MF") != null
491                     || zip.getEntry("META-INF/maven") != null) {
492                 final Enumeration<ZipArchiveEntry> entries = zip.getEntries();
493                 while (entries.hasMoreElements()) {
494                     final ZipArchiveEntry entry = entries.nextElement();
495                     if (!entry.isDirectory()) {
496                         final String name = entry.getName().toLowerCase();
497                         if (name.endsWith(".class")) {
498                             isJar = true;
499                             break;
500                         }
501                     }
502                 }
503             }
504         } catch (IOException ex) {
505             LOGGER.debug("Unable to unzip zip file '{}'", dependency.getFilePath(), ex);
506         } finally {
507             ZipFile.closeQuietly(zip);
508         }
509 
510         return isJar;
511     }
512 }