1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
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
67
68 public static final String YARN_PACKAGE_LOCK = "yarn.lock";
69
70
71
72
73 private static final FileFilter LOCK_FILE_FILTER = FileFilterBuilder.newInstance()
74 .addFilenames(YARN_PACKAGE_LOCK).build();
75
76
77
78
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
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
110
111
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
153
154
155
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
195
196
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
222
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
249
250
251
252
253
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
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
323
324
325
326
327
328
329
330
331
332
333
334
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
342 final JsonObject lockJson = fetchYarnAuditJson(dependency, skipDevDependencies);
343
344 final JsonObject packageJson;
345 try (JsonReader packageReader = Json.createReader(Files.newInputStream(packageFile.toPath()))) {
346 packageJson = packageReader.readObject();
347 }
348
349 final JsonObject payload = NpmPayloadBuilder.build(lockJson, packageJson, dependencyMap, skipDevDependencies);
350
351
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
407
408
409
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 }