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
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
65
66 private static final Logger LOGGER = LoggerFactory.getLogger(YarnAuditAnalyzer.class);
67
68
69
70
71 private static final int YARN_CLASSIC_MAJOR_VERSION = 1;
72
73
74
75
76 public static final String YARN_PACKAGE_LOCK = "yarn.lock";
77
78
79
80
81 private static final FileFilter LOCK_FILE_FILTER = FileFilterBuilder.newInstance()
82 .addFilenames(YARN_PACKAGE_LOCK).build();
83
84
85
86
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
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
118
119
120
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
161
162
163
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
205
206
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
232
233
234
235
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
262
263
264
265
266
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
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
336
337
338
339
340
341
342
343
344
345
346
347
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
355 final JsonObject lockJson = fetchYarnAuditJson(dependency, skipDevDependencies);
356
357 final JsonObject packageJson;
358 try (JsonReader packageReader = Json.createReader(Files.newInputStream(packageFile.toPath()))) {
359 packageJson = packageReader.readObject();
360 }
361
362 final JsonObject payload = NpmPayloadBuilder.build(lockJson, packageJson, dependencyMap, skipDevDependencies);
363
364
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
422
423
424
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 }