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.json.JSONException;
25  import org.json.JSONObject;
26  import org.owasp.dependencycheck.Engine;
27  import org.owasp.dependencycheck.analyzer.exception.AnalysisException;
28  import org.owasp.dependencycheck.analyzer.exception.SearchException;
29  import org.owasp.dependencycheck.analyzer.exception.UnexpectedAnalysisException;
30  import org.owasp.dependencycheck.data.nodeaudit.Advisory;
31  import org.owasp.dependencycheck.data.nodeaudit.NpmPayloadBuilder;
32  import org.owasp.dependencycheck.dependency.Dependency;
33  import org.owasp.dependencycheck.exception.InitializationException;
34  import org.owasp.dependencycheck.utils.FileFilterBuilder;
35  import org.owasp.dependencycheck.utils.Settings;
36  import org.owasp.dependencycheck.utils.URLConnectionFailureException;
37  import org.owasp.dependencycheck.utils.processing.ProcessReader;
38  import org.semver4j.Semver;
39  import org.semver4j.SemverException;
40  import org.slf4j.Logger;
41  import org.slf4j.LoggerFactory;
42  import us.springett.parsers.cpe.exceptions.CpeValidationException;
43  
44  import jakarta.json.Json;
45  import jakarta.json.JsonException;
46  import jakarta.json.JsonObject;
47  import jakarta.json.JsonReader;
48  
49  import javax.annotation.concurrent.ThreadSafe;
50  import java.io.File;
51  import java.io.FileFilter;
52  import java.io.IOException;
53  import java.nio.charset.StandardCharsets;
54  import java.nio.file.Files;
55  import java.util.ArrayList;
56  import java.util.Arrays;
57  import java.util.List;
58  import java.util.stream.Stream;
59  
60  @ThreadSafe
61  public class YarnAuditAnalyzer extends AbstractNpmAnalyzer {
62  
63      /**
64       * The Logger for use throughout the class.
65       */
66      private static final Logger LOGGER = LoggerFactory.getLogger(YarnAuditAnalyzer.class);
67  
68      /**
69       * The major version of the Yarn Classic CLI.
70       */
71      private static final int YARN_CLASSIC_MAJOR_VERSION = 1;
72  
73      /**
74       * The file name to scan.
75       */
76      public static final String YARN_PACKAGE_LOCK = "yarn.lock";
77  
78      /**
79       * Filter that detects files named "yarn.lock"
80       */
81      private static final FileFilter LOCK_FILE_FILTER = FileFilterBuilder.newInstance()
82              .addFilenames(YARN_PACKAGE_LOCK).build();
83  
84      /**
85       * An expected error from `yarn audit --offline --verbose --json` that will
86       * be ignored.
87       */
88      private static final String EXPECTED_ERROR = "{\"type\":\"error\",\"data\":\"Can't make a request in "
89              + "offline mode (\\\"https://registry.yarnpkg.com/-/npm/v1/security/audits\\\")\"}\n";
90  
91      /**
92       * The path to the `yarn` executable.
93       */
94      private String yarnPath;
95  
96      @Override
97      protected String getAnalyzerEnabledSettingKey() {
98          return Settings.KEYS.ANALYZER_YARN_AUDIT_ENABLED;
99      }
100 
101     @Override
102     protected FileFilter getFileFilter() {
103         return LOCK_FILE_FILTER;
104     }
105 
106     @Override
107     public String getName() {
108         return "Yarn Audit Analyzer";
109     }
110 
111     @Override
112     public AnalysisPhase getAnalysisPhase() {
113         return AnalysisPhase.FINDING_ANALYSIS;
114     }
115 
116     /**
117      * Determines the Yarn major version implied by the metadata in the passed directory.
118      *
119      * @param dependencyDirectory The directory containing the lockfile and/or package.json
120      * @return the yarn version detected
121      */
122     private Semver getYarnVersion(File dependencyDirectory) {
123         List<String> args = List.of(yarnPath, "--version");
124         final ProcessBuilder builder = new ProcessBuilder(args);
125         builder.directory(dependencyDirectory);
126         try {
127             final Process process = builder.start();
128             try (ProcessReader processReader = new ProcessReader(process)) {
129                 processReader.readAll();
130                 final int exitValue = process.waitFor();
131                 final var yarnVersion = StringUtils.trimToEmpty(processReader.getOutput());
132                 if (exitValue != 0) {
133                     throw new IllegalStateException(String.format("Unable to determine yarn version, unexpected response (exit value %s, output: %s, error: %s)", exitValue, yarnVersion, processReader.getError()));
134                 }
135                 if (StringUtils.isBlank(yarnVersion)) {
136                     throw new IllegalStateException("Unable to determine yarn version, blank output.");
137                 }
138                 return Semver.coerce(yarnVersion);
139             }
140         }  catch (SemverException e) {
141             throw new IllegalStateException("Invalid version string format", e);
142         } catch (Exception ex) {
143             throw new IllegalStateException("Unable to determine yarn version.", ex);
144         }
145     }
146 
147 
148     /**
149      * Initializes the analyzer once before any analysis is performed.
150      *
151      * @param engine a reference to the dependency-check engine
152      * @throws InitializationException if there's an error during initialization
153      */
154     @Override
155     protected void prepareFileTypeAnalyzer(Engine engine) throws InitializationException {
156         super.prepareFileTypeAnalyzer(engine);
157         if (!isEnabled()) {
158             LOGGER.debug("{} Analyzer is disabled skipping yarn executable check", getName());
159             return;
160         }
161         try {
162             cacheYarnCommandPath();
163             getYarnVersion(new File("."));
164         } catch (Exception ex){
165             this.setEnabled(false);
166             LOGGER.warn("The {} has been disabled after failing to find yarn. Yarn executable was not " +
167                     "found or received a non-zero exit value: {}", getName(), ex.getMessage());
168             throw new InitializationException("Unable to determine yarn executable to use.", ex);
169         }
170     }
171 
172     /**
173      * Attempts to determine and cache the path to `yarn`.
174      */
175     private void cacheYarnCommandPath() {
176         String value = getSettings().getString(Settings.KEYS.ANALYZER_YARN_PATH);
177         if (value == null || value.isBlank()) {
178             value = "yarn";
179         } else {
180             File fileValue = new File(value);
181             if (fileValue.isFile()) {
182                 value = fileValue.getAbsolutePath();
183             } else {
184                 LOGGER.warn("Provided path to `yarn` executable is invalid; defaulting to `yarn`.");
185                 value = "yarn";
186             }
187         }
188 
189         yarnPath = value;
190     }
191 
192     /**
193      * Workaround 64k limitation of InputStream, redirect stdout to a file that we will read later
194      * instead of reading directly stdout from Process's InputStream which is topped at 64k
195      *
196      * @param builder a reference to the process builder
197      * @return returns the standard out from the process
198      */
199     private String startAndReadStdoutToString(ProcessBuilder builder) throws AnalysisException {
200         try {
201             final File tmpFile = getSettings().getTempFile("yarn_audit", "json");
202             builder.redirectOutput(tmpFile);
203             final Process process = builder.start();
204             try (ProcessReader processReader = new ProcessReader(process)) {
205                 processReader.readAll();
206                 final String errOutput = processReader.getError();
207 
208                 if (!StringUtils.isBlank(errOutput) && !EXPECTED_ERROR.equals(errOutput)) {
209                     LOGGER.debug("Process Error Out: {}", errOutput);
210                     LOGGER.debug("Process Out: {}", processReader.getOutput());
211                 }
212                 return Files.readString(tmpFile.toPath());
213             } catch (InterruptedException ex) {
214                 Thread.currentThread().interrupt();
215                 throw new AnalysisException("Yarn audit process was interrupted.", ex);
216             }
217         } catch (IOException ioe) {
218             throw new AnalysisException("yarn audit failure; this error can be ignored if you are not analyzing projects with a yarn lockfile.", ioe);
219         }
220     }
221 
222     /**
223      * Analyzes the yarn lock file to determine vulnerable dependencies. Uses
224      * yarn audit --offline to generate the payload to be sent to the NPM API.
225      *
226      * @param dependency the yarn lock file
227      * @param engine the analysis engine
228      * @throws AnalysisException thrown if there is an error analyzing the file
229      */
230     @Override
231     protected void analyzeDependency(Dependency dependency, Engine engine) throws AnalysisException {
232         if (dependency.getDisplayFileName().equals(dependency.getFileName())) {
233             engine.removeDependency(dependency);
234         }
235         final File packageLock = dependency.getActualFile();
236         if (!packageLock.isFile() || packageLock.length() == 0 || !shouldProcess(packageLock)) {
237             return;
238         }
239         File dependencyDirectory = getDependencyDirectory(packageLock);
240         final var yarnVersion = getYarnVersion(dependencyDirectory);
241         final List<Advisory> advisories;
242         final MultiValuedMap<String, String> dependencyMap = new HashSetValuedHashMap<>();
243         if (YARN_CLASSIC_MAJOR_VERSION < yarnVersion.getMajor()) {
244             LOGGER.info("Analyzing using Yarn Berry ({}) audit for {}", yarnVersion, dependency.getActualFilePath());
245             advisories = analyzePackageWithYarnBerry(dependency);
246         } else {
247             LOGGER.info("Analyzing using Yarn Classic ({}) audit for {}", yarnVersion, dependency.getActualFilePath());
248             advisories = analyzePackageWithYarnClassic(packageLock, dependency, dependencyMap);
249         }
250         try {
251             processResults(advisories, engine, dependency, dependencyMap);
252         } catch (CpeValidationException ex) {
253             throw new UnexpectedAnalysisException(ex);
254         }
255     }
256 
257     private JsonObject fetchYarnAuditJson(File dependencyDirectory, boolean skipDevDependencies) throws AnalysisException {
258         final List<String> args = new ArrayList<>();
259         args.add(yarnPath);
260         args.add("audit");
261         //offline audit is not supported - but the audit request is generated in the verbose output
262         args.add("--offline");
263         if (skipDevDependencies) {
264             args.add("--groups");
265             args.add("dependencies");
266         }
267         args.add("--json");
268         args.add("--verbose");
269         final ProcessBuilder builder = new ProcessBuilder(args);
270         builder.directory(dependencyDirectory);
271         LOGGER.debug("Launching: {}", args);
272 
273         final String verboseJson = startAndReadStdoutToString(builder);
274         final String auditRequestJson = Arrays.stream(verboseJson.split("\n"))
275                 .filter(line -> line.contains("Audit Request"))
276                 .findFirst()
277                 .orElseThrow(() -> new AnalysisException("No results from Yarn Classic (offline step) - possibly trying to use classic analyzer on Yarn Berry lockfile"));
278         String auditRequest;
279         try (JsonReader reader = Json.createReader(IOUtils.toInputStream(auditRequestJson, StandardCharsets.UTF_8))) {
280             final JsonObject jsonObject = reader.readObject();
281             auditRequest = jsonObject.getString("data");
282             auditRequest = auditRequest.substring(15);
283         }
284         LOGGER.debug("Audit Request: {}", auditRequest);
285 
286         return Json.createReader(IOUtils.toInputStream(auditRequest, StandardCharsets.UTF_8)).readObject();
287     }
288 
289     private static File getDependencyDirectory(File lockFile) {
290         final File folder = lockFile.getParentFile();
291         if (!folder.isDirectory()) {
292             throw new IllegalArgumentException(String.format("%s should have been a directory.", folder.getAbsolutePath()));
293         }
294         return folder;
295     }
296 
297     /**
298      * Analyzes the package and yarn lock files by extracting dependency
299      * information, creating a payload to submit to the npm audit API,
300      * submitting the payload, and returning the identified advisories.
301      *
302      * @param lockFile a reference to the package-lock.json
303      * @param dependency a reference to the dependency-object for the yarn.lock
304      * @param dependencyMap a collection of module/version pairs; during
305      * creation of the payload the dependency map is populated with the
306      * module/version information.
307      * @return a list of advisories
308      * @throws AnalysisException thrown when there is an error creating or
309      * submitting the npm audit API payload
310      */
311     private List<Advisory> analyzePackageWithYarnClassic(final File lockFile, Dependency dependency,
312                                                          MultiValuedMap<String, String> dependencyMap)
313             throws AnalysisException {
314         try {
315             final boolean skipDevDependencies = getSettings().getBoolean(Settings.KEYS.ANALYZER_NODE_AUDIT_SKIPDEV, false);
316             // Retrieves the contents of package-lock.json from the Dependency
317             final JsonObject lockJson = fetchYarnAuditJson(getDependencyDirectory(lockFile), skipDevDependencies);
318             // Retrieves the contents of package-lock.json from the Dependency
319             final JsonObject packageJson;
320             try (JsonReader packageReader = Json.createReader(Files.newInputStream(lockFile.getParentFile().toPath().resolve("package.json")))) {
321                 packageJson = packageReader.readObject();
322             }
323             // Modify the payload to meet the NPM Audit API requirements
324             final JsonObject payload = NpmPayloadBuilder.build(lockJson, packageJson, dependencyMap, skipDevDependencies);
325 
326             // Submits the package payload to the nsp check service
327             return getSearcher().submitPackage(payload);
328 
329         } catch (URLConnectionFailureException e) {
330             this.setEnabled(false);
331             throw new AnalysisException("Failed to connect to the NPM Audit API (YarnAuditAnalyzer); the analyzer "
332                     + "is being disabled and may result in false negatives.", e);
333         } catch (IOException e) {
334             LOGGER.debug("Error reading dependency or connecting to NPM Audit API", e);
335             this.setEnabled(false);
336             throw new AnalysisException("Failed to read results from the NPM Audit API (YarnAuditAnalyzer); "
337                     + "the analyzer is being disabled and may result in false negatives.", e);
338         } catch (JsonException e) {
339             throw new AnalysisException(String.format("Failed to parse %s file from the NPM Audit API "
340                     + "(YarnAuditAnalyzer).", lockFile.getPath()), e);
341         } catch (SearchException ex) {
342             LOGGER.error("YarnAuditAnalyzer failed on {}", dependency.getActualFilePath());
343             throw ex;
344         }
345     }
346 
347     private List<JSONObject> fetchYarnAdvisories(Dependency dependency, boolean skipDevDependencies) throws AnalysisException {
348         final List<String> args = new ArrayList<>();
349 
350         args.add(yarnPath);
351         args.add("npm");
352         args.add("audit");
353         if (skipDevDependencies) {
354             args.add("--environment");
355             args.add("production");
356         }
357         args.add("--all");
358         args.add("--recursive");
359         args.add("--no-deprecations");
360         args.add("--json");
361         final ProcessBuilder builder = new ProcessBuilder(args);
362         builder.directory(getDependencyDirectory(dependency.getActualFile()));
363 
364         final String advisoriesJsons = startAndReadStdoutToString(builder);
365 
366         LOGGER.debug("Advisories JSON: {}", advisoriesJsons);
367         final String[] advisoriesJsonArray = Stream.of(advisoriesJsons.split("\n"))
368                 .filter(s -> !s.isBlank())
369                 .toArray(String[]::new);
370         try {
371             final List<JSONObject> advisories = new ArrayList<>();
372             for (String advisoriesJson : advisoriesJsonArray) {
373                 advisories.add(new JSONObject(advisoriesJson));
374             }
375 
376             return advisories;
377         } catch (JSONException e) {
378             throw new AnalysisException("Failed to parse the response from NPM Audit API "
379                     + "(YarnBerryAuditAnalyzer).", e);
380         }
381     }
382 
383     /**
384      * Analyzes the package and yarn lock files by calling yarn npm audit and returning the identified advisories.
385      *
386      * @param dependency a reference to the dependency-object for the yarn.lock
387      * @return a list of advisories
388      */
389     private List<Advisory> analyzePackageWithYarnBerry(Dependency dependency) throws AnalysisException {
390         try {
391             final var skipDevDependencies = getSettings().getBoolean(Settings.KEYS.ANALYZER_NODE_AUDIT_SKIPDEV, false);
392             final var advisoryJsons = fetchYarnAdvisories(dependency, skipDevDependencies);
393             return parseAdvisoryJsons(advisoryJsons);
394         } catch (JSONException e) {
395             throw new AnalysisException("Failed to parse the response from NPM Audit API "
396                     + "(YarnBerryAuditAnalyzer).", e);
397         } catch (SearchException ex) {
398             LOGGER.error("YarnBerryAuditAnalyzer failed on {}", dependency.getActualFilePath());
399             throw ex;
400         }
401     }
402 
403     private static List<Advisory> parseAdvisoryJsons(List<JSONObject> advisoryJsons) throws JSONException {
404         final List<Advisory> advisories = new ArrayList<>();
405         for (JSONObject advisoryJson : advisoryJsons) {
406             final var advisory = new Advisory();
407             final var object = advisoryJson.getJSONObject("children");
408             final var moduleName = advisoryJson.optString("value", null);
409             final var id = object.get("ID");
410             final var url = object.optString("URL", null);
411             final var ghsaId = extractGhsaId(url);
412             final var issue = object.optString("Issue", null);
413             final var severity = object.optString("Severity", null);
414             final var vulnerableVersions = object.optString("Vulnerable Versions", null);
415             final var treeVersions = object.optJSONArray("Tree Versions");
416             final var treeVersionsLength = treeVersions == null ? 0 : treeVersions.length();
417             final var versions = new ArrayList<String>();
418             for (int i = 0; i < treeVersionsLength; i++) {
419                 versions.add(treeVersions.getString(i));
420             }
421             if (versions.isEmpty()) {
422                 versions.add(null);
423             }
424             for (String version : versions) {
425                 advisory.setGhsaId(ghsaId);
426                 advisory.setTitle(issue);
427                 advisory.setOverview("URL:" + url + "ID: " + id);
428                 advisory.setSeverity(severity);
429                 advisory.setVulnerableVersions(vulnerableVersions);
430                 advisory.setModuleName(moduleName);
431                 advisory.setVersion(version);
432                 advisory.setCwes(new ArrayList<>());
433                 advisories.add(advisory);
434             }
435         }
436         return advisories;
437     }
438 
439     private static String extractGhsaId(String url) {
440         if (url == null || url.isEmpty()) {
441             return null;
442         }
443         final int lastSlashIndex = url.lastIndexOf('/');
444         if (lastSlashIndex == -1 || lastSlashIndex == url.length() - 1) {
445             return null;
446         }
447         return url.substring(lastSlashIndex + 1);
448     }
449 }