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      * Extracts the major version from a version string.
118      *
119      * @param dependency the dependency to extract the yarn version from
120      * @return the major version (e.g., `4` from "4.2.1")
121      */
122     private int getYarnMajorVersion(Dependency dependency) {
123         final var yarnVersion = getYarnVersion(dependency);
124         try {
125             final var semver = Semver.coerce(yarnVersion);
126             return semver.getMajor();
127         } catch (SemverException e) {
128             throw new IllegalStateException("Invalid version string format", e);
129         }
130     }
131 
132     private String getYarnVersion(Dependency dependency) {
133         final List<String> args = new ArrayList<>();
134         args.add(getYarn());
135         args.add("--version");
136         final ProcessBuilder builder = new ProcessBuilder(args);
137         builder.directory(getDependencyDirectory(dependency));
138         LOGGER.debug("Launching: {}", args);
139         try {
140             final Process process = builder.start();
141             try (ProcessReader processReader = new ProcessReader(process)) {
142                 processReader.readAll();
143                 final int exitValue = process.waitFor();
144                 if (exitValue != 0) {
145                     throw new IllegalStateException("Unable to determine yarn version, unexpected response.");
146                 }
147                 final var yarnVersion = processReader.getOutput();
148                 if (StringUtils.isBlank(yarnVersion)) {
149                     throw new IllegalStateException("Unable to determine yarn version, blank output.");
150                 }
151                 return yarnVersion;
152             }
153         } catch (Exception ex) {
154             throw new IllegalStateException("Unable to determine yarn version.", ex);
155         }
156     }
157 
158 
159     /**
160      * Initializes the analyzer once before any analysis is performed.
161      *
162      * @param engine a reference to the dependency-check engine
163      * @throws InitializationException if there's an error during initialization
164      */
165     @Override
166     protected void prepareFileTypeAnalyzer(Engine engine) throws InitializationException {
167         super.prepareFileTypeAnalyzer(engine);
168         if (!isEnabled()) {
169             LOGGER.debug("{} Analyzer is disabled skipping yarn executable check", getName());
170             return;
171         }
172         final List<String> args = new ArrayList<>();
173         args.add(getYarn());
174         args.add("--help");
175         final ProcessBuilder builder = new ProcessBuilder(args);
176         LOGGER.debug("Launching: {}", args);
177         try {
178             final Process process = builder.start();
179             try (ProcessReader processReader = new ProcessReader(process)) {
180                 processReader.readAll();
181                 final int exitValue = process.waitFor();
182                 final int expectedExitValue = 0;
183                 final int yarnExecutableNotFoundExitValue = 127;
184                 switch (exitValue) {
185                     case expectedExitValue:
186                         LOGGER.debug("{} is enabled.", getName());
187                         break;
188                     case yarnExecutableNotFoundExitValue:
189                     default:
190                         this.setEnabled(false);
191                         LOGGER.warn("The {} has been disabled after receiving exit value {}. Yarn executable was not " +
192                                         "found or received a non-zero exit value.", getName(), exitValue);
193                 }
194             }
195         } catch (Exception ex) {
196             this.setEnabled(false);
197             LOGGER.warn("The {} has been disabled after receiving an exception. This can occur when Yarn executable " +
198                     "is not found.", getName());
199             throw new InitializationException("Unable to read yarn audit output.", ex);
200         }
201     }
202 
203     /**
204      * Attempts to determine the path to `yarn`.
205      *
206      * @return the path to `yarn`
207      */
208     private String getYarn() {
209         final String value;
210         synchronized (this) {
211             if (yarnPath == null) {
212                 final String path = getSettings().getString(Settings.KEYS.ANALYZER_YARN_PATH);
213                 if (path == null) {
214                     yarnPath = "yarn";
215                 } else {
216                     final File yarnFile = new File(path);
217                     if (yarnFile.isFile()) {
218                         yarnPath = yarnFile.getAbsolutePath();
219                     } else {
220                         LOGGER.warn("Provided path to `yarn` executable is invalid.");
221                         yarnPath = "yarn";
222                     }
223                 }
224             }
225             value = yarnPath;
226         }
227         return value;
228     }
229 
230     /**
231      * Workaround 64k limitation of InputStream, redirect stdout to a file that we will read later
232      * instead of reading directly stdout from Process's InputStream which is topped at 64k
233      *
234      * @param builder a reference to the process builder
235      * @return returns the standard out from the process
236      */
237     private String startAndReadStdoutToString(ProcessBuilder builder) throws AnalysisException {
238         try {
239             final File tmpFile = getSettings().getTempFile("yarn_audit", "json");
240             builder.redirectOutput(tmpFile);
241             final Process process = builder.start();
242             try (ProcessReader processReader = new ProcessReader(process)) {
243                 processReader.readAll();
244                 final String errOutput = processReader.getError();
245 
246                 if (!StringUtils.isBlank(errOutput) && !EXPECTED_ERROR.equals(errOutput)) {
247                     LOGGER.debug("Process Error Out: {}", errOutput);
248                     LOGGER.debug("Process Out: {}", processReader.getOutput());
249                 }
250                 return new String(Files.readAllBytes(tmpFile.toPath()), StandardCharsets.UTF_8);
251             } catch (InterruptedException ex) {
252                 Thread.currentThread().interrupt();
253                 throw new AnalysisException("Yarn audit process was interrupted.", ex);
254             }
255         } catch (IOException ioe) {
256             throw new AnalysisException("yarn audit failure; this error can be ignored if you are not analyzing projects with a yarn lockfile.", ioe);
257         }
258     }
259 
260     /**
261      * Analyzes the yarn lock file to determine vulnerable dependencies. Uses
262      * yarn audit --offline to generate the payload to be sent to the NPM API.
263      *
264      * @param dependency the yarn lock file
265      * @param engine the analysis engine
266      * @throws AnalysisException thrown if there is an error analyzing the file
267      */
268     @Override
269     protected void analyzeDependency(Dependency dependency, Engine engine) throws AnalysisException {
270         if (dependency.getDisplayFileName().equals(dependency.getFileName())) {
271             engine.removeDependency(dependency);
272         }
273         final File packageLock = dependency.getActualFile();
274         if (!packageLock.isFile() || packageLock.length() == 0 || !shouldProcess(packageLock)) {
275             return;
276         }
277         final File packageJson = new File(packageLock.getParentFile(), "package.json");
278         final List<Advisory> advisories;
279         final MultiValuedMap<String, String> dependencyMap = new HashSetValuedHashMap<>();
280         final var yarnMajorVersion = getYarnMajorVersion(dependency);
281         if (YARN_CLASSIC_MAJOR_VERSION < yarnMajorVersion) {
282             LOGGER.info("Analyzing using Yarn Berry audit");
283             advisories = analyzePackageWithYarnBerry(dependency);
284         } else {
285             LOGGER.info("Analyzing using Yarn Classic audit");
286             advisories = analyzePackageWithYarnClassic(packageLock, packageJson, dependency, dependencyMap);
287         }
288         try {
289             processResults(advisories, engine, dependency, dependencyMap);
290         } catch (CpeValidationException ex) {
291             throw new UnexpectedAnalysisException(ex);
292         }
293     }
294 
295     private JsonObject fetchYarnAuditJson(Dependency dependency, boolean skipDevDependencies) throws AnalysisException {
296         final List<String> args = new ArrayList<>();
297         args.add(getYarn());
298         args.add("audit");
299         //offline audit is not supported - but the audit request is generated in the verbose output
300         args.add("--offline");
301         if (skipDevDependencies) {
302             args.add("--groups");
303             args.add("dependencies");
304         }
305         args.add("--json");
306         args.add("--verbose");
307         final ProcessBuilder builder = new ProcessBuilder(args);
308         builder.directory(getDependencyDirectory(dependency));
309         LOGGER.debug("Launching: {}", args);
310 
311         final String verboseJson = startAndReadStdoutToString(builder);
312         final String auditRequestJson = Arrays.stream(verboseJson.split("\n"))
313                 .filter(line -> line.contains("Audit Request"))
314                 .findFirst().get();
315         String auditRequest;
316         try (JsonReader reader = Json.createReader(IOUtils.toInputStream(auditRequestJson, StandardCharsets.UTF_8))) {
317             final JsonObject jsonObject = reader.readObject();
318             auditRequest = jsonObject.getString("data");
319             auditRequest = auditRequest.substring(15);
320         }
321         LOGGER.debug("Audit Request: {}", auditRequest);
322 
323         return Json.createReader(IOUtils.toInputStream(auditRequest, StandardCharsets.UTF_8)).readObject();
324     }
325 
326     private static File getDependencyDirectory(Dependency dependency) {
327         final File folder = dependency.getActualFile().getParentFile();
328         if (!folder.isDirectory()) {
329             throw new IllegalArgumentException(String.format("%s should have been a directory.", folder.getAbsolutePath()));
330         }
331         return folder;
332     }
333 
334     /**
335      * Analyzes the package and yarn lock files by extracting dependency
336      * information, creating a payload to submit to the npm audit API,
337      * submitting the payload, and returning the identified advisories.
338      *
339      * @param lockFile a reference to the package-lock.json
340      * @param packageFile a reference to the package.json
341      * @param dependency a reference to the dependency-object for the yarn.lock
342      * @param dependencyMap a collection of module/version pairs; during
343      * creation of the payload the dependency map is populated with the
344      * module/version information.
345      * @return a list of advisories
346      * @throws AnalysisException thrown when there is an error creating or
347      * submitting the npm audit API payload
348      */
349     private List<Advisory> analyzePackageWithYarnClassic(final File lockFile, final File packageFile,
350                                                          Dependency dependency, MultiValuedMap<String, String> dependencyMap)
351             throws AnalysisException {
352         try {
353             final boolean skipDevDependencies = getSettings().getBoolean(Settings.KEYS.ANALYZER_NODE_AUDIT_SKIPDEV, false);
354             // Retrieves the contents of package-lock.json from the Dependency
355             final JsonObject lockJson = fetchYarnAuditJson(dependency, skipDevDependencies);
356             // Retrieves the contents of package-lock.json from the Dependency
357             final JsonObject packageJson;
358             try (JsonReader packageReader = Json.createReader(Files.newInputStream(packageFile.toPath()))) {
359                 packageJson = packageReader.readObject();
360             }
361             // Modify the payload to meet the NPM Audit API requirements
362             final JsonObject payload = NpmPayloadBuilder.build(lockJson, packageJson, dependencyMap, skipDevDependencies);
363 
364             // Submits the package payload to the nsp check service
365             return getSearcher().submitPackage(payload);
366 
367         } catch (URLConnectionFailureException e) {
368             this.setEnabled(false);
369             throw new AnalysisException("Failed to connect to the NPM Audit API (YarnAuditAnalyzer); the analyzer "
370                     + "is being disabled and may result in false negatives.", e);
371         } catch (IOException e) {
372             LOGGER.debug("Error reading dependency or connecting to NPM Audit API", e);
373             this.setEnabled(false);
374             throw new AnalysisException("Failed to read results from the NPM Audit API (YarnAuditAnalyzer); "
375                     + "the analyzer is being disabled and may result in false negatives.", e);
376         } catch (JsonException e) {
377             throw new AnalysisException(String.format("Failed to parse %s file from the NPM Audit API "
378                     + "(YarnAuditAnalyzer).", lockFile.getPath()), e);
379         } catch (SearchException ex) {
380             LOGGER.error("YarnAuditAnalyzer failed on {}", dependency.getActualFilePath());
381             throw ex;
382         }
383     }
384 
385     private List<JSONObject> fetchYarnAdvisories(Dependency dependency, boolean skipDevDependencies) throws AnalysisException {
386         final List<String> args = new ArrayList<>();
387 
388         args.add(getYarn());
389         args.add("npm");
390         args.add("audit");
391         if (skipDevDependencies) {
392             args.add("--environment");
393             args.add("production");
394         }
395         args.add("--all");
396         args.add("--recursive");
397         args.add("--json");
398         final ProcessBuilder builder = new ProcessBuilder(args);
399         builder.directory(getDependencyDirectory(dependency));
400 
401         final String advisoriesJsons = startAndReadStdoutToString(builder);
402 
403         LOGGER.debug("Advisories JSON: {}", advisoriesJsons);
404         final String[] advisoriesJsonArray = Stream.of(advisoriesJsons.split("\n"))
405                 .filter(s -> !s.isBlank())
406                 .toArray(String[]::new);
407         try {
408             final List<JSONObject> advisories = new ArrayList<>();
409             for (String advisoriesJson : advisoriesJsonArray) {
410                 advisories.add(new JSONObject(advisoriesJson));
411             }
412 
413             return advisories;
414         } catch (JSONException e) {
415             throw new AnalysisException("Failed to parse the response from NPM Audit API "
416                     + "(YarnBerryAuditAnalyzer).", e);
417         }
418     }
419 
420     /**
421      * Analyzes the package and yarn lock files by calling yarn npm audit and returning the identified advisories.
422      *
423      * @param dependency a reference to the dependency-object for the yarn.lock
424      * @return a list of advisories
425      */
426     private List<Advisory> analyzePackageWithYarnBerry(Dependency dependency) throws AnalysisException {
427         try {
428             final var skipDevDependencies = getSettings().getBoolean(Settings.KEYS.ANALYZER_NODE_AUDIT_SKIPDEV, false);
429             final var advisoryJsons = fetchYarnAdvisories(dependency, skipDevDependencies);
430             return parseAdvisoryJsons(advisoryJsons);
431         } catch (JSONException e) {
432             throw new AnalysisException("Failed to parse the response from NPM Audit API "
433                     + "(YarnBerryAuditAnalyzer).", e);
434         } catch (SearchException ex) {
435             LOGGER.error("YarnBerryAuditAnalyzer failed on {}", dependency.getActualFilePath());
436             throw ex;
437         }
438     }
439 
440     private static List<Advisory> parseAdvisoryJsons(List<JSONObject> advisoryJsons) throws JSONException {
441         final List<Advisory> advisories = new ArrayList<>();
442         for (JSONObject advisoryJson : advisoryJsons) {
443             final var advisory = new Advisory();
444             final var object = advisoryJson.getJSONObject("children");
445             final var moduleName = advisoryJson.optString("value", null);
446             final var id = object.getString("ID");
447             final var url = object.optString("URL", null);
448             final var ghsaId = extractGhsaId(url);
449             final var issue = object.optString("Issue", null);
450             final var severity = object.optString("Severity", null);
451             final var vulnerableVersions = object.optString("Vulnerable Versions", null);
452             final var treeVersions = object.optJSONArray("Tree Versions");
453             final var treeVersionsLength = treeVersions == null ? 0 : treeVersions.length();
454             final var versions = new ArrayList<String>();
455             for (int i = 0; i < treeVersionsLength; i++) {
456                 versions.add(treeVersions.getString(i));
457             }
458             if (versions.isEmpty()) {
459                 versions.add(null);
460             }
461             for (String version : versions) {
462                 advisory.setGhsaId(ghsaId);
463                 advisory.setTitle(issue);
464                 advisory.setOverview("URL:" + url + "ID: " + id);
465                 advisory.setSeverity(severity);
466                 advisory.setVulnerableVersions(vulnerableVersions);
467                 advisory.setModuleName(moduleName);
468                 advisory.setVersion(version);
469                 advisory.setCwes(new ArrayList<>());
470                 advisories.add(advisory);
471             }
472         }
473         return advisories;
474     }
475 
476     private static String extractGhsaId(String url) {
477         if (url == null || url.isEmpty()) {
478             return null;
479         }
480         final int lastSlashIndex = url.lastIndexOf('/');
481         if (lastSlashIndex == -1 || lastSlashIndex == url.length() - 1) {
482             return null;
483         }
484         return url.substring(lastSlashIndex + 1);
485     }
486 }