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