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