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 Semver getYarnVersion(File dependencyDirectory) {
123 List<String> args = List.of(yarnPath, "--version");
124 final ProcessBuilder builder = new ProcessBuilder(args);
125 builder.directory(dependencyDirectory);
126 try {
127 final Process process = builder.start();
128 try (ProcessReader processReader = new ProcessReader(process)) {
129 processReader.readAll();
130 final int exitValue = process.waitFor();
131 final var yarnVersion = StringUtils.trimToEmpty(processReader.getOutput());
132 if (exitValue != 0) {
133 throw new IllegalStateException(String.format("Unable to determine yarn version, unexpected response (exit value %s, output: %s, error: %s)", exitValue, yarnVersion, processReader.getError()));
134 }
135 if (StringUtils.isBlank(yarnVersion)) {
136 throw new IllegalStateException("Unable to determine yarn version, blank output.");
137 }
138 return Semver.coerce(yarnVersion);
139 }
140 } catch (SemverException e) {
141 throw new IllegalStateException("Invalid version string format", e);
142 } catch (Exception ex) {
143 throw new IllegalStateException("Unable to determine yarn version.", ex);
144 }
145 }
146
147
148
149
150
151
152
153
154 @Override
155 protected void prepareFileTypeAnalyzer(Engine engine) throws InitializationException {
156 super.prepareFileTypeAnalyzer(engine);
157 if (!isEnabled()) {
158 LOGGER.debug("{} Analyzer is disabled skipping yarn executable check", getName());
159 return;
160 }
161 try {
162 cacheYarnCommandPath();
163 getYarnVersion(new File("."));
164 } catch (Exception ex){
165 this.setEnabled(false);
166 LOGGER.warn("The {} has been disabled after failing to find yarn. Yarn executable was not " +
167 "found or received a non-zero exit value: {}", getName(), ex.getMessage());
168 throw new InitializationException("Unable to determine yarn executable to use.", ex);
169 }
170 }
171
172
173
174
175 private void cacheYarnCommandPath() {
176 String value = getSettings().getString(Settings.KEYS.ANALYZER_YARN_PATH);
177 if (value == null || value.isBlank()) {
178 value = "yarn";
179 } else {
180 File fileValue = new File(value);
181 if (fileValue.isFile()) {
182 value = fileValue.getAbsolutePath();
183 } else {
184 LOGGER.warn("Provided path to `yarn` executable is invalid; defaulting to `yarn`.");
185 value = "yarn";
186 }
187 }
188
189 yarnPath = value;
190 }
191
192
193
194
195
196
197
198
199 private String startAndReadStdoutToString(ProcessBuilder builder) throws AnalysisException {
200 try {
201 final File tmpFile = getSettings().getTempFile("yarn_audit", "json");
202 builder.redirectOutput(tmpFile);
203 final Process process = builder.start();
204 try (ProcessReader processReader = new ProcessReader(process)) {
205 processReader.readAll();
206 final String errOutput = processReader.getError();
207
208 if (!StringUtils.isBlank(errOutput) && !EXPECTED_ERROR.equals(errOutput)) {
209 LOGGER.debug("Process Error Out: {}", errOutput);
210 LOGGER.debug("Process Out: {}", processReader.getOutput());
211 }
212 return Files.readString(tmpFile.toPath());
213 } catch (InterruptedException ex) {
214 Thread.currentThread().interrupt();
215 throw new AnalysisException("Yarn audit process was interrupted.", ex);
216 }
217 } catch (IOException ioe) {
218 throw new AnalysisException("yarn audit failure; this error can be ignored if you are not analyzing projects with a yarn lockfile.", ioe);
219 }
220 }
221
222
223
224
225
226
227
228
229
230 @Override
231 protected void analyzeDependency(Dependency dependency, Engine engine) throws AnalysisException {
232 if (dependency.getDisplayFileName().equals(dependency.getFileName())) {
233 engine.removeDependency(dependency);
234 }
235 final File packageLock = dependency.getActualFile();
236 if (!packageLock.isFile() || packageLock.length() == 0 || !shouldProcess(packageLock)) {
237 return;
238 }
239 File dependencyDirectory = getDependencyDirectory(packageLock);
240 final var yarnVersion = getYarnVersion(dependencyDirectory);
241 final List<Advisory> advisories;
242 final MultiValuedMap<String, String> dependencyMap = new HashSetValuedHashMap<>();
243 if (YARN_CLASSIC_MAJOR_VERSION < yarnVersion.getMajor()) {
244 LOGGER.info("Analyzing using Yarn Berry ({}) audit for {}", yarnVersion, dependency.getActualFilePath());
245 advisories = analyzePackageWithYarnBerry(dependency);
246 } else {
247 LOGGER.info("Analyzing using Yarn Classic ({}) audit for {}", yarnVersion, dependency.getActualFilePath());
248 advisories = analyzePackageWithYarnClassic(packageLock, dependency, dependencyMap);
249 }
250 try {
251 processResults(advisories, engine, dependency, dependencyMap);
252 } catch (CpeValidationException ex) {
253 throw new UnexpectedAnalysisException(ex);
254 }
255 }
256
257 private JsonObject fetchYarnAuditJson(File dependencyDirectory, boolean skipDevDependencies) throws AnalysisException {
258 final List<String> args = new ArrayList<>();
259 args.add(yarnPath);
260 args.add("audit");
261
262 args.add("--offline");
263 if (skipDevDependencies) {
264 args.add("--groups");
265 args.add("dependencies");
266 }
267 args.add("--json");
268 args.add("--verbose");
269 final ProcessBuilder builder = new ProcessBuilder(args);
270 builder.directory(dependencyDirectory);
271 LOGGER.debug("Launching: {}", args);
272
273 final String verboseJson = startAndReadStdoutToString(builder);
274 final String auditRequestJson = Arrays.stream(verboseJson.split("\n"))
275 .filter(line -> line.contains("Audit Request"))
276 .findFirst()
277 .orElseThrow(() -> new AnalysisException("No results from Yarn Classic (offline step) - possibly trying to use classic analyzer on Yarn Berry lockfile"));
278 String auditRequest;
279 try (JsonReader reader = Json.createReader(IOUtils.toInputStream(auditRequestJson, StandardCharsets.UTF_8))) {
280 final JsonObject jsonObject = reader.readObject();
281 auditRequest = jsonObject.getString("data");
282 auditRequest = auditRequest.substring(15);
283 }
284 LOGGER.debug("Audit Request: {}", auditRequest);
285
286 return Json.createReader(IOUtils.toInputStream(auditRequest, StandardCharsets.UTF_8)).readObject();
287 }
288
289 private static File getDependencyDirectory(File lockFile) {
290 final File folder = lockFile.getParentFile();
291 if (!folder.isDirectory()) {
292 throw new IllegalArgumentException(String.format("%s should have been a directory.", folder.getAbsolutePath()));
293 }
294 return folder;
295 }
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311 private List<Advisory> analyzePackageWithYarnClassic(final File lockFile, Dependency dependency,
312 MultiValuedMap<String, String> dependencyMap)
313 throws AnalysisException {
314 try {
315 final boolean skipDevDependencies = getSettings().getBoolean(Settings.KEYS.ANALYZER_NODE_AUDIT_SKIPDEV, false);
316
317 final JsonObject lockJson = fetchYarnAuditJson(getDependencyDirectory(lockFile), skipDevDependencies);
318
319 final JsonObject packageJson;
320 try (JsonReader packageReader = Json.createReader(Files.newInputStream(lockFile.getParentFile().toPath().resolve("package.json")))) {
321 packageJson = packageReader.readObject();
322 }
323
324 final JsonObject payload = NpmPayloadBuilder.build(lockJson, packageJson, dependencyMap, skipDevDependencies);
325
326
327 return getSearcher().submitPackage(payload);
328
329 } catch (URLConnectionFailureException e) {
330 this.setEnabled(false);
331 throw new AnalysisException("Failed to connect to the NPM Audit API (YarnAuditAnalyzer); the analyzer "
332 + "is being disabled and may result in false negatives.", e);
333 } catch (IOException e) {
334 LOGGER.debug("Error reading dependency or connecting to NPM Audit API", e);
335 this.setEnabled(false);
336 throw new AnalysisException("Failed to read results from the NPM Audit API (YarnAuditAnalyzer); "
337 + "the analyzer is being disabled and may result in false negatives.", e);
338 } catch (JsonException e) {
339 throw new AnalysisException(String.format("Failed to parse %s file from the NPM Audit API "
340 + "(YarnAuditAnalyzer).", lockFile.getPath()), e);
341 } catch (SearchException ex) {
342 LOGGER.error("YarnAuditAnalyzer failed on {}", dependency.getActualFilePath());
343 throw ex;
344 }
345 }
346
347 private List<JSONObject> fetchYarnAdvisories(Dependency dependency, boolean skipDevDependencies) throws AnalysisException {
348 final List<String> args = new ArrayList<>();
349
350 args.add(yarnPath);
351 args.add("npm");
352 args.add("audit");
353 if (skipDevDependencies) {
354 args.add("--environment");
355 args.add("production");
356 }
357 args.add("--all");
358 args.add("--recursive");
359 args.add("--no-deprecations");
360 args.add("--json");
361 final ProcessBuilder builder = new ProcessBuilder(args);
362 builder.directory(getDependencyDirectory(dependency.getActualFile()));
363
364 final String advisoriesJsons = startAndReadStdoutToString(builder);
365
366 LOGGER.debug("Advisories JSON: {}", advisoriesJsons);
367 final String[] advisoriesJsonArray = Stream.of(advisoriesJsons.split("\n"))
368 .filter(s -> !s.isBlank())
369 .toArray(String[]::new);
370 try {
371 final List<JSONObject> advisories = new ArrayList<>();
372 for (String advisoriesJson : advisoriesJsonArray) {
373 advisories.add(new JSONObject(advisoriesJson));
374 }
375
376 return advisories;
377 } catch (JSONException e) {
378 throw new AnalysisException("Failed to parse the response from NPM Audit API "
379 + "(YarnBerryAuditAnalyzer).", e);
380 }
381 }
382
383
384
385
386
387
388
389 private List<Advisory> analyzePackageWithYarnBerry(Dependency dependency) throws AnalysisException {
390 try {
391 final var skipDevDependencies = getSettings().getBoolean(Settings.KEYS.ANALYZER_NODE_AUDIT_SKIPDEV, false);
392 final var advisoryJsons = fetchYarnAdvisories(dependency, skipDevDependencies);
393 return parseAdvisoryJsons(advisoryJsons);
394 } catch (JSONException e) {
395 throw new AnalysisException("Failed to parse the response from NPM Audit API "
396 + "(YarnBerryAuditAnalyzer).", e);
397 } catch (SearchException ex) {
398 LOGGER.error("YarnBerryAuditAnalyzer failed on {}", dependency.getActualFilePath());
399 throw ex;
400 }
401 }
402
403 private static List<Advisory> parseAdvisoryJsons(List<JSONObject> advisoryJsons) throws JSONException {
404 final List<Advisory> advisories = new ArrayList<>();
405 for (JSONObject advisoryJson : advisoryJsons) {
406 final var advisory = new Advisory();
407 final var object = advisoryJson.getJSONObject("children");
408 final var moduleName = advisoryJson.optString("value", null);
409 final var id = object.get("ID");
410 final var url = object.optString("URL", null);
411 final var ghsaId = extractGhsaId(url);
412 final var issue = object.optString("Issue", null);
413 final var severity = object.optString("Severity", null);
414 final var vulnerableVersions = object.optString("Vulnerable Versions", null);
415 final var treeVersions = object.optJSONArray("Tree Versions");
416 final var treeVersionsLength = treeVersions == null ? 0 : treeVersions.length();
417 final var versions = new ArrayList<String>();
418 for (int i = 0; i < treeVersionsLength; i++) {
419 versions.add(treeVersions.getString(i));
420 }
421 if (versions.isEmpty()) {
422 versions.add(null);
423 }
424 for (String version : versions) {
425 advisory.setGhsaId(ghsaId);
426 advisory.setTitle(issue);
427 advisory.setOverview("URL:" + url + "ID: " + id);
428 advisory.setSeverity(severity);
429 advisory.setVulnerableVersions(vulnerableVersions);
430 advisory.setModuleName(moduleName);
431 advisory.setVersion(version);
432 advisory.setCwes(new ArrayList<>());
433 advisories.add(advisory);
434 }
435 }
436 return advisories;
437 }
438
439 private static String extractGhsaId(String url) {
440 if (url == null || url.isEmpty()) {
441 return null;
442 }
443 final int lastSlashIndex = url.lastIndexOf('/');
444 if (lastSlashIndex == -1 || lastSlashIndex == url.length() - 1) {
445 return null;
446 }
447 return url.substring(lastSlashIndex + 1);
448 }
449 }