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 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
66
67 private static final Logger LOGGER = LoggerFactory.getLogger(YarnAuditAnalyzer.class);
68
69
70
71
72 private static final int YARN_CLASSIC_MAJOR_VERSION = 1;
73
74
75
76
77 public static final String YARN_PACKAGE_LOCK = "yarn.lock";
78
79
80
81
82 private static final FileFilter LOCK_FILE_FILTER = FileFilterBuilder.newInstance()
83 .addFilenames(YARN_PACKAGE_LOCK).build();
84
85
86
87
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
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
119
120
121
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
151
152
153
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
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
195
196
197
198
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
225
226
227
228
229
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
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
300
301
302
303
304
305
306
307
308
309
310
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
318 final JsonObject lockJson = fetchYarnAuditJson(getDependencyDirectory(lockFile), skipDevDependencies);
319
320 final JsonObject packageJson;
321 try (JsonReader packageReader = Json.createReader(Files.newInputStream(lockFile.getParentFile().toPath().resolve("package.json")))) {
322 packageJson = packageReader.readObject();
323 }
324
325 final JsonObject payload = NpmPayloadBuilder.build(lockJson, packageJson, dependencyMap, skipDevDependencies);
326
327
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
386
387
388
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 }