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