View Javadoc
1   /*
2    * This file is part of dependency-check-ant.
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) 2021 The OWASP Foundation. All Rights Reserved.
17   */
18  package org.owasp.dependencycheck.analyzer;
19  
20  import org.apache.commons.collections4.MultiValuedMap;
21  import org.apache.commons.collections4.multimap.HashSetValuedHashMap;
22  import org.apache.commons.io.IOUtils;
23  import org.apache.commons.lang3.StringUtils;
24  import org.owasp.dependencycheck.Engine;
25  import org.owasp.dependencycheck.analyzer.exception.AnalysisException;
26  import org.owasp.dependencycheck.analyzer.exception.SearchException;
27  import org.owasp.dependencycheck.analyzer.exception.UnexpectedAnalysisException;
28  import org.owasp.dependencycheck.data.nodeaudit.Advisory;
29  import org.owasp.dependencycheck.data.nodeaudit.NpmPayloadBuilder;
30  import org.owasp.dependencycheck.dependency.Dependency;
31  import org.owasp.dependencycheck.exception.InitializationException;
32  import org.owasp.dependencycheck.utils.FileFilterBuilder;
33  import org.owasp.dependencycheck.utils.Settings;
34  import org.owasp.dependencycheck.utils.URLConnectionFailureException;
35  import org.owasp.dependencycheck.utils.processing.ProcessReader;
36  import org.slf4j.Logger;
37  import org.slf4j.LoggerFactory;
38  import us.springett.parsers.cpe.exceptions.CpeValidationException;
39  
40  import javax.annotation.concurrent.ThreadSafe;
41  import javax.json.Json;
42  import javax.json.JsonException;
43  import javax.json.JsonObject;
44  import javax.json.JsonReader;
45  import java.io.File;
46  import java.io.FileFilter;
47  import java.io.IOException;
48  import java.nio.charset.StandardCharsets;
49  import java.nio.file.Files;
50  import java.util.ArrayList;
51  import java.util.Arrays;
52  import java.util.List;
53  
54  @ThreadSafe
55  public class YarnAuditAnalyzer extends AbstractNpmAnalyzer {
56  
57      /**
58       * The logger.
59       */
60      private static final Logger LOGGER = LoggerFactory.getLogger(YarnAuditAnalyzer.class);
61  
62      /**
63       * The file name to scan.
64       */
65      public static final String YARN_PACKAGE_LOCK = "yarn.lock";
66  
67      /**
68       * Filter that detects files named "yarn.lock"
69       */
70      private static final FileFilter LOCK_FILE_FILTER = FileFilterBuilder.newInstance()
71              .addFilenames(YARN_PACKAGE_LOCK).build();
72  
73      /**
74       * An expected error from `yarn audit --offline --verbose --json` that will
75       * be ignored.
76       */
77      private static final String EXPECTED_ERROR = "{\"type\":\"error\",\"data\":\"Can't make a request in "
78              + "offline mode (\\\"https://registry.yarnpkg.com/-/npm/v1/security/audits\\\")\"}\n";
79  
80      /**
81       * The path to the `yarn` executable.
82       */
83      private String yarnPath;
84  
85      /**
86       * Analyzes the yarn lock file to determine vulnerable dependencies. Uses
87       * yarn audit --offline to generate the payload to be sent to the NPM API.
88       *
89       * @param dependency the yarn lock file
90       * @param engine the analysis engine
91       * @throws AnalysisException thrown if there is an error analyzing the file
92       */
93      @Override
94      protected void analyzeDependency(Dependency dependency, Engine engine) throws AnalysisException {
95          if (dependency.getDisplayFileName().equals(dependency.getFileName())) {
96              engine.removeDependency(dependency);
97          }
98          final File packageLock = dependency.getActualFile();
99          if (!packageLock.isFile() || packageLock.length() == 0 || !shouldProcess(packageLock)) {
100             return;
101         }
102         final File packageJson = new File(packageLock.getParentFile(), "package.json");
103         final List<Advisory> advisories;
104         final MultiValuedMap<String, String> dependencyMap = new HashSetValuedHashMap<>();
105         advisories = analyzePackage(packageLock, packageJson, dependency, dependencyMap);
106         try {
107             processResults(advisories, engine, dependency, dependencyMap);
108         } catch (CpeValidationException ex) {
109             throw new UnexpectedAnalysisException(ex);
110         }
111     }
112 
113     @Override
114     protected String getAnalyzerEnabledSettingKey() {
115         return Settings.KEYS.ANALYZER_YARN_AUDIT_ENABLED;
116     }
117 
118     @Override
119     protected FileFilter getFileFilter() {
120         return LOCK_FILE_FILTER;
121     }
122 
123     @Override
124     public String getName() {
125         return "Yarn Audit Analyzer";
126     }
127 
128     @Override
129     public AnalysisPhase getAnalysisPhase() {
130         return AnalysisPhase.FINDING_ANALYSIS;
131     }
132 
133     /**
134      * Initializes the analyzer once before any analysis is performed.
135      *
136      * @param engine a reference to the dependency-check engine
137      * @throws InitializationException if there's an error during initialization
138      */
139     @Override
140     protected void prepareFileTypeAnalyzer(Engine engine) throws InitializationException {
141         super.prepareFileTypeAnalyzer(engine);
142         if (!isEnabled()) {
143             LOGGER.debug("{} Analyzer is disabled skipping yarn executable check", getName());
144             return;
145         }
146         final List<String> args = new ArrayList<>();
147         args.add(getYarn());
148         args.add("--help");
149         final ProcessBuilder builder = new ProcessBuilder(args);
150         LOGGER.debug("Launching: {}", args);
151         try {
152             final Process process = builder.start();
153             try (ProcessReader processReader = new ProcessReader(process)) {
154                 processReader.readAll();
155                 final int exitValue = process.waitFor();
156                 final int expectedExitValue = 0;
157                 final int yarnExecutableNotFoundExitValue = 127;
158                 switch (exitValue) {
159                     case expectedExitValue:
160                         LOGGER.debug("{} is enabled.", getName());
161                         break;
162                     case yarnExecutableNotFoundExitValue:
163                     default:
164                         this.setEnabled(false);
165                         LOGGER.warn("The {} has been disabled. Yarn executable was not found.", getName());
166                 }
167             }
168         } catch (Exception ex) {
169             this.setEnabled(false);
170             LOGGER.warn("The {} has been disabled. Yarn executable was not found.", getName());
171             throw new InitializationException("Unable to read yarn audit output.", ex);
172         }
173     }
174 
175     /**
176      * Attempts to determine the path to `yarn`.
177      *
178      * @return the path to `yarn`
179      */
180     private String getYarn() {
181         final String value;
182         synchronized (this) {
183             if (yarnPath == null) {
184                 final String path = getSettings().getString(Settings.KEYS.ANALYZER_YARN_PATH);
185                 if (path == null) {
186                     yarnPath = "yarn";
187                 } else {
188                     final File yarnFile = new File(path);
189                     if (yarnFile.isFile()) {
190                         yarnPath = yarnFile.getAbsolutePath();
191                     } else {
192                         LOGGER.warn("Provided path to `yarn` executable is invalid.");
193                         yarnPath = "yarn";
194                     }
195                 }
196             }
197             value = yarnPath;
198         }
199         return value;
200     }
201 
202     private JsonObject fetchYarnAuditJson(Dependency dependency, boolean skipDevDependencies) throws AnalysisException {
203         final File folder = dependency.getActualFile().getParentFile();
204         if (!folder.isDirectory()) {
205             throw new AnalysisException(String.format("%s should have been a directory.", folder.getAbsolutePath()));
206         }
207         try {
208             final List<String> args = new ArrayList<>();
209 
210             args.add(getYarn());
211             args.add("audit");
212             //offline audit is not supported - but the audit request is generated in the verbose output
213             args.add("--offline");
214             if (skipDevDependencies) {
215                 args.add("--groups");
216                 args.add("dependencies");
217             }
218             args.add("--json");
219             args.add("--verbose");
220             final ProcessBuilder builder = new ProcessBuilder(args);
221             builder.directory(folder);
222             LOGGER.debug("Launching: {}", args);
223             // Workaround 64k limitation of InputStream, redirect stdout to a file that we will read later
224             // instead of reading directly stdout from Process's InputStream which is topped at 64k
225 
226             final File tmpFile = getSettings().getTempFile("yarn_audit", "json");
227             builder.redirectOutput(tmpFile);
228             final Process process = builder.start();
229             try (ProcessReader processReader = new ProcessReader(process)) {
230                 processReader.readAll();
231                 final String errOutput = processReader.getError();
232 
233                 if (!StringUtils.isBlank(errOutput) && !EXPECTED_ERROR.equals(errOutput)) {
234                     LOGGER.debug("Process Error Out: {}", errOutput);
235                     LOGGER.debug("Process Out: {}", processReader.getOutput());
236                 }
237                 final String verboseJson = new String(Files.readAllBytes(tmpFile.toPath()), StandardCharsets.UTF_8);
238                 final String auditRequestJson = Arrays.stream(verboseJson.split("\n"))
239                         .filter(line -> line.contains("Audit Request"))
240                         .findFirst().get();
241                 String auditRequest;
242                 try (JsonReader reader = Json.createReader(IOUtils.toInputStream(auditRequestJson, StandardCharsets.UTF_8))) {
243                     final JsonObject jsonObject = reader.readObject();
244                     auditRequest = jsonObject.getString("data");
245                     auditRequest = auditRequest.substring(15);
246                 }
247                 LOGGER.debug("Audit Request: {}", auditRequest);
248 
249                 return Json.createReader(IOUtils.toInputStream(auditRequest, StandardCharsets.UTF_8)).readObject();
250             } catch (InterruptedException ex) {
251                 Thread.currentThread().interrupt();
252                 throw new AnalysisException("Yarn audit process was interrupted.", ex);
253             }
254         } catch (IOException ioe) {
255             throw new AnalysisException("yarn audit failure; this error can be ignored if you are not analyzing projects with a yarn lockfile.", ioe);
256         }
257     }
258 
259     /**
260      * Analyzes the package and yarn lock files by extracting dependency
261      * information, creating a payload to submit to the npm audit API,
262      * submitting the payload, and returning the identified advisories.
263      *
264      * @param lockFile a reference to the package-lock.json
265      * @param packageFile a reference to the package.json
266      * @param dependency a reference to the dependency-object for the yarn.lock
267      * @param dependencyMap a collection of module/version pairs; during
268      * creation of the payload the dependency map is populated with the
269      * module/version information.
270      * @return a list of advisories
271      * @throws AnalysisException thrown when there is an error creating or
272      * submitting the npm audit API payload
273      */
274     private List<Advisory> analyzePackage(final File lockFile, final File packageFile,
275             Dependency dependency, MultiValuedMap<String, String> dependencyMap)
276             throws AnalysisException {
277         try {
278             final boolean skipDevDependencies = getSettings().getBoolean(Settings.KEYS.ANALYZER_NODE_AUDIT_SKIPDEV, false);
279             // Retrieves the contents of package-lock.json from the Dependency
280             final JsonObject lockJson = fetchYarnAuditJson(dependency, skipDevDependencies);
281             // Retrieves the contents of package-lock.json from the Dependency
282             final JsonObject packageJson;
283             try (final JsonReader packageReader = Json.createReader(Files.newInputStream(packageFile.toPath()))) {
284                 packageJson = packageReader.readObject();
285             }
286             // Modify the payload to meet the NPM Audit API requirements
287             final JsonObject payload = NpmPayloadBuilder.build(lockJson, packageJson, dependencyMap, skipDevDependencies);
288 
289             // Submits the package payload to the nsp check service
290             return getSearcher().submitPackage(payload);
291 
292         } catch (URLConnectionFailureException e) {
293             this.setEnabled(false);
294             throw new AnalysisException("Failed to connect to the NPM Audit API (YarnAuditAnalyzer); the analyzer "
295                     + "is being disabled and may result in false negatives.", e);
296         } catch (IOException e) {
297             LOGGER.debug("Error reading dependency or connecting to NPM Audit API", e);
298             this.setEnabled(false);
299             throw new AnalysisException("Failed to read results from the NPM Audit API (YarnAuditAnalyzer); "
300                     + "the analyzer is being disabled and may result in false negatives.", e);
301         } catch (JsonException e) {
302             throw new AnalysisException(String.format("Failed to parse %s file from the NPM Audit API "
303                     + "(YarnAuditAnalyzer).", lockFile.getPath()), e);
304         } catch (SearchException ex) {
305             LOGGER.error("YarnAuditAnalyzer failed on {}", dependency.getActualFilePath());
306             throw ex;
307         }
308     }
309 }