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