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) 2015 Institute for Defense Analyses. All Rights Reserved.
17   */
18  package org.owasp.dependencycheck.analyzer;
19  
20  import java.io.BufferedReader;
21  import java.io.File;
22  import java.io.FileFilter;
23  import java.io.IOException;
24  import java.io.InputStreamReader;
25  import java.io.UnsupportedEncodingException;
26  import java.util.ArrayList;
27  import java.util.HashMap;
28  import java.util.List;
29  import java.util.Map;
30  import java.nio.charset.Charset;
31  import org.apache.commons.io.FileUtils;
32  import org.owasp.dependencycheck.Engine;
33  import org.owasp.dependencycheck.analyzer.exception.AnalysisException;
34  import org.owasp.dependencycheck.data.nvdcve.CveDB;
35  import org.owasp.dependencycheck.dependency.Confidence;
36  import org.owasp.dependencycheck.dependency.Dependency;
37  import org.owasp.dependencycheck.dependency.Reference;
38  import org.owasp.dependencycheck.dependency.Vulnerability;
39  import org.owasp.dependencycheck.utils.FileFilterBuilder;
40  import org.owasp.dependencycheck.utils.Settings;
41  import org.slf4j.Logger;
42  import org.slf4j.LoggerFactory;
43  import org.owasp.dependencycheck.data.nvdcve.DatabaseException;
44  import org.owasp.dependencycheck.exception.InitializationException;
45  
46  /**
47   * Used to analyze Ruby Bundler Gemspec.lock files utilizing the 3rd party
48   * bundle-audit tool.
49   *
50   * @author Dale Visser
51   */
52  @Experimental
53  public class RubyBundleAuditAnalyzer extends AbstractFileTypeAnalyzer {
54  
55      /**
56       * The logger.
57       */
58      private static final Logger LOGGER = LoggerFactory.getLogger(RubyBundleAuditAnalyzer.class);
59  
60      /**
61       * The name of the analyzer.
62       */
63      private static final String ANALYZER_NAME = "Ruby Bundle Audit Analyzer";
64  
65      /**
66       * The phase that this analyzer is intended to run in.
67       */
68      private static final AnalysisPhase ANALYSIS_PHASE = AnalysisPhase.PRE_INFORMATION_COLLECTION;
69      /**
70       * The filter defining which files will be analyzed.
71       */
72      private static final FileFilter FILTER = FileFilterBuilder.newInstance().addFilenames("Gemfile.lock").build();
73      /**
74       * Name.
75       */
76      public static final String NAME = "Name: ";
77      /**
78       * Version.
79       */
80      public static final String VERSION = "Version: ";
81      /**
82       * Advisory.
83       */
84      public static final String ADVISORY = "Advisory: ";
85      /**
86       * Criticality.
87       */
88      public static final String CRITICALITY = "Criticality: ";
89  
90      /**
91       * The DAL.
92       */
93      private CveDB cvedb;
94  
95      /**
96       * @return a filter that accepts files named Gemfile.lock
97       */
98      @Override
99      protected FileFilter getFileFilter() {
100         return FILTER;
101     }
102 
103     /**
104      * Launch bundle-audit.
105      *
106      * @param folder directory that contains bundle audit
107      * @return a handle to the process
108      * @throws AnalysisException thrown when there is an issue launching bundle
109      * audit
110      */
111     private Process launchBundleAudit(File folder) throws AnalysisException {
112         if (!folder.isDirectory()) {
113             throw new AnalysisException(String.format("%s should have been a directory.", folder.getAbsolutePath()));
114         }
115         final List<String> args = new ArrayList<String>();
116         final String bundleAuditPath = Settings.getString(Settings.KEYS.ANALYZER_BUNDLE_AUDIT_PATH);
117         args.add(null == bundleAuditPath ? "bundle-audit" : bundleAuditPath);
118         args.add("check");
119         args.add("--verbose");
120         final ProcessBuilder builder = new ProcessBuilder(args);
121         builder.directory(folder);
122         try {
123             LOGGER.info("Launching: " + args + " from " + folder);
124             return builder.start();
125         } catch (IOException ioe) {
126             throw new AnalysisException("bundle-audit failure", ioe);
127         }
128     }
129 
130     /**
131      * Initialize the analyzer. In this case, extract GrokAssembly.exe to a
132      * temporary location.
133      *
134      * @throws InitializationException if anything goes wrong
135      */
136     @Override
137     public void initializeFileTypeAnalyzer() throws InitializationException {
138         try {
139             cvedb = new CveDB();
140             cvedb.open();
141         } catch (DatabaseException ex) {
142             LOGGER.warn("Exception opening the database");
143             LOGGER.debug("error", ex);
144             setEnabled(false);
145             throw new InitializationException("Error connecting to the database", ex);
146         }
147         // Now, need to see if bundle-audit actually runs from this location.
148         Process process = null;
149         try {
150             process = launchBundleAudit(Settings.getTempDirectory());
151         } catch (AnalysisException ae) {
152 
153             setEnabled(false);
154             cvedb.close();
155             cvedb = null;
156             final String msg = String.format("Exception from bundle-audit process: %s. Disabling %s", ae.getCause(), ANALYZER_NAME);
157             throw new InitializationException(msg, ae);
158         } catch (IOException ex) {
159             setEnabled(false);
160             throw new InitializationException("Unable to create temporary file, the Ruby Bundle Audit Analyzer will be disabled", ex);
161         }
162 
163         final int exitValue;
164         try {
165             exitValue = process.waitFor();
166         } catch (InterruptedException ex) {
167             setEnabled(false);
168             final String msg = String.format("Bundle-audit process was interupted. Disabling %s", ANALYZER_NAME);
169             throw new InitializationException(msg);
170         }
171         if (0 == exitValue) {
172             setEnabled(false);
173             final String msg = String.format("Unexpected exit code from bundle-audit process. Disabling %s: %s", ANALYZER_NAME, exitValue);
174             throw new InitializationException(msg);
175         } else {
176             BufferedReader reader = null;
177             try {
178                 reader = new BufferedReader(new InputStreamReader(process.getErrorStream(), "UTF-8"));
179                 if (!reader.ready()) {
180                     LOGGER.warn("Bundle-audit error stream unexpectedly not ready. Disabling " + ANALYZER_NAME);
181                     setEnabled(false);
182                     throw new InitializationException("Bundle-audit error stream unexpectedly not ready.");
183                 } else {
184                     final String line = reader.readLine();
185                     if (line == null || !line.contains("Errno::ENOENT")) {
186                         LOGGER.warn("Unexpected bundle-audit output. Disabling {}: {}", ANALYZER_NAME, line);
187                         setEnabled(false);
188                         throw new InitializationException("Unexpected bundle-audit output.");
189                     }
190                 }
191             } catch (UnsupportedEncodingException ex) {
192                 setEnabled(false);
193                 throw new InitializationException("Unexpected bundle-audit encoding.", ex);
194             } catch (IOException ex) {
195                 setEnabled(false);
196                 throw new InitializationException("Unable to read bundle-audit output.", ex);
197             } finally {
198                 if (null != reader) {
199                     try {
200                         reader.close();
201                     } catch (IOException ex) {
202                         LOGGER.debug("Error closing reader", ex);
203                     }
204                 }
205             }
206         }
207 
208         if (isEnabled()) {
209             LOGGER.info(ANALYZER_NAME + " is enabled. It is necessary to manually run \"bundle-audit update\" "
210                     + "occasionally to keep its database up to date.");
211         }
212     }
213 
214     /**
215      * Returns the name of the analyzer.
216      *
217      * @return the name of the analyzer.
218      */
219     @Override
220     public String getName() {
221         return ANALYZER_NAME;
222     }
223 
224     /**
225      * Returns the phase that the analyzer is intended to run in.
226      *
227      * @return the phase that the analyzer is intended to run in.
228      */
229     @Override
230     public AnalysisPhase getAnalysisPhase() {
231         return ANALYSIS_PHASE;
232     }
233 
234     /**
235      * Returns the key used in the properties file to reference the analyzer's
236      * enabled property.
237      *
238      * @return the analyzer's enabled property setting key
239      */
240     @Override
241     protected String getAnalyzerEnabledSettingKey() {
242         return Settings.KEYS.ANALYZER_BUNDLE_AUDIT_ENABLED;
243     }
244 
245     /**
246      * If {@link #analyzeFileType(Dependency, Engine)} is called, then we have
247      * successfully initialized, and it will be necessary to disable
248      * {@link RubyGemspecAnalyzer}.
249      */
250     private boolean needToDisableGemspecAnalyzer = true;
251 
252     /**
253      * Determines if the analyzer can analyze the given file type.
254      *
255      * @param dependency the dependency to determine if it can analyze
256      * @param engine the dependency-check engine
257      * @throws AnalysisException thrown if there is an analysis exception.
258      */
259     @Override
260     protected void analyzeFileType(Dependency dependency, Engine engine)
261             throws AnalysisException {
262         if (needToDisableGemspecAnalyzer) {
263             boolean failed = true;
264             final String className = RubyGemspecAnalyzer.class.getName();
265             for (FileTypeAnalyzer analyzer : engine.getFileTypeAnalyzers()) {
266                 if (analyzer instanceof RubyBundlerAnalyzer) {
267                     ((RubyBundlerAnalyzer) analyzer).setEnabled(false);
268                     LOGGER.info("Disabled " + RubyBundlerAnalyzer.class.getName() + " to avoid noisy duplicate results.");
269                 } else if (analyzer instanceof RubyGemspecAnalyzer) {
270                     ((RubyGemspecAnalyzer) analyzer).setEnabled(false);
271                     LOGGER.info("Disabled " + className + " to avoid noisy duplicate results.");
272                     failed = false;
273                 }
274             }
275             if (failed) {
276                 LOGGER.warn("Did not find " + className + '.');
277             }
278             needToDisableGemspecAnalyzer = false;
279         }
280         final File parentFile = dependency.getActualFile().getParentFile();
281         final Process process = launchBundleAudit(parentFile);
282         try {
283             process.waitFor();
284         } catch (InterruptedException ie) {
285             throw new AnalysisException("bundle-audit process interrupted", ie);
286         }
287         BufferedReader rdr = null;
288         BufferedReader errReader = null;
289         try {
290             errReader = new BufferedReader(new InputStreamReader(process.getErrorStream(), "UTF-8"));
291             while (errReader.ready()) {
292                 final String error = errReader.readLine();
293                 LOGGER.warn(error);
294             }
295             rdr = new BufferedReader(new InputStreamReader(process.getInputStream(), "UTF-8"));
296             processBundlerAuditOutput(dependency, engine, rdr);
297         } catch (IOException ioe) {
298             LOGGER.warn("bundle-audit failure", ioe);
299         } finally {
300             if (errReader != null) {
301                 try {
302                     errReader.close();
303                 } catch (IOException ioe) {
304                     LOGGER.warn("bundle-audit close failure", ioe);
305                 }
306             }
307             if (null != rdr) {
308                 try {
309                     rdr.close();
310                 } catch (IOException ioe) {
311                     LOGGER.warn("bundle-audit close failure", ioe);
312                 }
313             }
314         }
315 
316     }
317 
318     /**
319      * Processes the bundler audit output.
320      *
321      * @param original the dependency
322      * @param engine the dependency-check engine
323      * @param rdr the reader of the report
324      * @throws IOException thrown if the report cannot be read.
325      */
326     private void processBundlerAuditOutput(Dependency original, Engine engine, BufferedReader rdr) throws IOException {
327         final String parentName = original.getActualFile().getParentFile().getName();
328         final String fileName = original.getFileName();
329         final String filePath = original.getFilePath();
330         Dependency dependency = null;
331         Vulnerability vulnerability = null;
332         String gem = null;
333         final Map<String, Dependency> map = new HashMap<String, Dependency>();
334         boolean appendToDescription = false;
335         while (rdr.ready()) {
336             final String nextLine = rdr.readLine();
337             if (null == nextLine) {
338                 break;
339             } else if (nextLine.startsWith(NAME)) {
340                 appendToDescription = false;
341                 gem = nextLine.substring(NAME.length());
342                 if (!map.containsKey(gem)) {
343                     map.put(gem, createDependencyForGem(engine, parentName, fileName, filePath, gem));
344                 }
345                 dependency = map.get(gem);
346                 LOGGER.debug(String.format("bundle-audit (%s): %s", parentName, nextLine));
347             } else if (nextLine.startsWith(VERSION)) {
348                 vulnerability = createVulnerability(parentName, dependency, gem, nextLine);
349             } else if (nextLine.startsWith(ADVISORY)) {
350                 setVulnerabilityName(parentName, dependency, vulnerability, nextLine);
351             } else if (nextLine.startsWith(CRITICALITY)) {
352                 addCriticalityToVulnerability(parentName, vulnerability, nextLine);
353             } else if (nextLine.startsWith("URL: ")) {
354                 addReferenceToVulnerability(parentName, vulnerability, nextLine);
355             } else if (nextLine.startsWith("Description:")) {
356                 appendToDescription = true;
357                 if (null != vulnerability) {
358                     vulnerability.setDescription("*** Vulnerability obtained from bundle-audit verbose report. "
359                             + "Title link may not work. CPE below is guessed. CVSS score is estimated (-1.0 "
360                             + " indicates unknown). See link below for full details. *** ");
361                 }
362             } else if (appendToDescription) {
363                 if (null != vulnerability) {
364                     vulnerability.setDescription(vulnerability.getDescription() + nextLine + "\n");
365                 }
366             }
367         }
368     }
369 
370     /**
371      * Sets the vulnerability name.
372      *
373      * @param parentName the parent name
374      * @param dependency the dependency
375      * @param vulnerability the vulnerability
376      * @param nextLine the line to parse
377      */
378     private void setVulnerabilityName(String parentName, Dependency dependency, Vulnerability vulnerability, String nextLine) {
379         final String advisory = nextLine.substring((ADVISORY.length()));
380         if (null != vulnerability) {
381             vulnerability.setName(advisory);
382         }
383         if (null != dependency) {
384             dependency.getVulnerabilities().add(vulnerability); // needed to wait for vulnerability name to avoid NPE
385         }
386         LOGGER.debug(String.format("bundle-audit (%s): %s", parentName, nextLine));
387     }
388 
389     /**
390      * Adds a reference to the vulnerability.
391      *
392      * @param parentName the parent name
393      * @param vulnerability the vulnerability
394      * @param nextLine the line to parse
395      */
396     private void addReferenceToVulnerability(String parentName, Vulnerability vulnerability, String nextLine) {
397         final String url = nextLine.substring(("URL: ").length());
398         if (null != vulnerability) {
399             final Reference ref = new Reference();
400             ref.setName(vulnerability.getName());
401             ref.setSource("bundle-audit");
402             ref.setUrl(url);
403             vulnerability.getReferences().add(ref);
404         }
405         LOGGER.debug(String.format("bundle-audit (%s): %s", parentName, nextLine));
406     }
407 
408     /**
409      * Adds the criticality to the vulnerability
410      *
411      * @param parentName the parent name
412      * @param vulnerability the vulnerability
413      * @param nextLine the line to parse
414      */
415     private void addCriticalityToVulnerability(String parentName, Vulnerability vulnerability, String nextLine) {
416         if (null != vulnerability) {
417             final String criticality = nextLine.substring(CRITICALITY.length()).trim();
418             float score = -1.0f;
419             Vulnerability v = null;
420             try {
421                 v = cvedb.getVulnerability(vulnerability.getName());
422             } catch (DatabaseException ex) {
423                 LOGGER.debug("Unable to look up vulnerability {}", vulnerability.getName());
424             }
425             if (v != null) {
426                 score = v.getCvssScore();
427             } else if ("High".equalsIgnoreCase(criticality)) {
428                 score = 8.5f;
429             } else if ("Medium".equalsIgnoreCase(criticality)) {
430                 score = 5.5f;
431             } else if ("Low".equalsIgnoreCase(criticality)) {
432                 score = 2.0f;
433             }
434             vulnerability.setCvssScore(score);
435         }
436         LOGGER.debug(String.format("bundle-audit (%s): %s", parentName, nextLine));
437     }
438 
439     /**
440      * Creates a vulnerability.
441      *
442      * @param parentName the parent name
443      * @param dependency the dependency
444      * @param gem the gem name
445      * @param nextLine the line to parse
446      * @return the vulnerability
447      */
448     private Vulnerability createVulnerability(String parentName, Dependency dependency, String gem, String nextLine) {
449         Vulnerability vulnerability = null;
450         if (null != dependency) {
451             final String version = nextLine.substring(VERSION.length());
452             dependency.getVersionEvidence().addEvidence(
453                     "bundler-audit",
454                     "Version",
455                     version,
456                     Confidence.HIGHEST);
457             vulnerability = new Vulnerability(); // don't add to dependency until we have name set later
458             vulnerability.setMatchedCPE(
459                     String.format("cpe:/a:%1$s_project:%1$s:%2$s::~~~ruby~~", gem, version),
460                     null);
461             vulnerability.setCvssAccessVector("-");
462             vulnerability.setCvssAccessComplexity("-");
463             vulnerability.setCvssAuthentication("-");
464             vulnerability.setCvssAvailabilityImpact("-");
465             vulnerability.setCvssConfidentialityImpact("-");
466             vulnerability.setCvssIntegrityImpact("-");
467         }
468         LOGGER.debug(String.format("bundle-audit (%s): %s", parentName, nextLine));
469         return vulnerability;
470     }
471 
472     /**
473      * Creates the dependency based off of the gem.
474      *
475      * @param engine the engine used for scanning
476      * @param parentName the gem parent
477      * @param fileName the file name
478      * @param filePath the file path
479      * @param gem the gem name
480      * @return the dependency to add
481      * @throws IOException thrown if a temporary gem file could not be written
482      */
483     private Dependency createDependencyForGem(Engine engine, String parentName, String fileName, String filePath, String gem) throws IOException {
484         final File gemFile = new File(Settings.getTempDirectory(), gem + "_Gemfile.lock");
485         gemFile.createNewFile();
486         final String displayFileName = String.format("%s%c%s:%s", parentName, File.separatorChar, fileName, gem);
487 
488         FileUtils.write(gemFile, displayFileName, Charset.defaultCharset()); // unique contents to avoid dependency bundling
489         final Dependency dependency = new Dependency(gemFile);
490         dependency.getProductEvidence().addEvidence("bundler-audit", "Name", gem, Confidence.HIGHEST);
491         dependency.setDisplayFileName(displayFileName);
492         dependency.setFileName(fileName);
493         dependency.setFilePath(filePath);
494         engine.getDependencies().add(dependency);
495         return dependency;
496     }
497 }