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