View Javadoc
1   /*
2    * This file is part of dependency-check-core.
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) 2015 Institute for Defense Analyses. All Rights Reserved.
17   */
18  package org.owasp.dependencycheck.analyzer;
19  
20  import com.github.packageurl.MalformedPackageURLException;
21  import com.github.packageurl.PackageURL;
22  import com.github.packageurl.PackageURLBuilder;
23  import org.owasp.dependencycheck.Engine;
24  import org.owasp.dependencycheck.Engine.Mode;
25  import org.owasp.dependencycheck.analyzer.exception.AnalysisException;
26  import org.owasp.dependencycheck.data.nvd.ecosystem.Ecosystem;
27  import org.owasp.dependencycheck.dependency.Confidence;
28  import org.owasp.dependencycheck.dependency.Dependency;
29  import org.owasp.dependencycheck.dependency.EvidenceType;
30  import org.owasp.dependencycheck.dependency.naming.PurlIdentifier;
31  import org.owasp.dependencycheck.exception.InitializationException;
32  import org.owasp.dependencycheck.utils.Checksum;
33  import org.owasp.dependencycheck.utils.FileFilterBuilder;
34  import org.owasp.dependencycheck.utils.InvalidSettingException;
35  import org.owasp.dependencycheck.utils.Settings;
36  import org.slf4j.Logger;
37  import org.slf4j.LoggerFactory;
38  
39  import javax.annotation.concurrent.ThreadSafe;
40  import jakarta.json.Json;
41  import jakarta.json.JsonException;
42  import jakarta.json.JsonObject;
43  import jakarta.json.JsonReader;
44  import jakarta.json.JsonString;
45  import jakarta.json.JsonValue;
46  import java.io.File;
47  import java.io.FileFilter;
48  import java.io.IOException;
49  import java.nio.file.Files;
50  import java.nio.file.Paths;
51  import java.security.NoSuchAlgorithmException;
52  import java.util.Arrays;
53  import java.util.List;
54  import java.util.Map;
55  import java.util.Objects;
56  
57  import static org.owasp.dependencycheck.utils.FileUtils.existsWithContent;
58  
59  /**
60   * Used to analyze Node Package Manager (npm) package.json files, and collect
61   * information that can be used to determine the associated CPE.
62   *
63   * @author Dale Visser
64   */
65  @ThreadSafe
66  public class NodePackageAnalyzer extends AbstractNpmAnalyzer {
67  
68      /**
69       * The logger.
70       */
71      private static final Logger LOGGER = LoggerFactory.getLogger(NodePackageAnalyzer.class);
72      /**
73       * A descriptor for the type of dependencies processed or added by this
74       * analyzer.
75       */
76      public static final String DEPENDENCY_ECOSYSTEM = Ecosystem.NODEJS;
77      /**
78       * The name of the analyzer.
79       */
80      private static final String ANALYZER_NAME = "Node Package Analyzer";
81      /**
82       * The phase that this analyzer is intended to run in.
83       */
84      private static final AnalysisPhase ANALYSIS_PHASE = AnalysisPhase.INFORMATION_COLLECTION;
85      /**
86       * The file name to scan.
87       */
88      public static final String PACKAGE_JSON = "package.json";
89      /**
90       * The file name to scan.
91       */
92      public static final String PACKAGE_LOCK_JSON = "package-lock.json";
93      /**
94       * The file name to scan.
95       */
96      public static final String SHRINKWRAP_JSON = "npm-shrinkwrap.json";
97      /**
98       * The name of the directory that contains node modules.
99       */
100     public static final String NODE_MODULES_DIRNAME = "node_modules";
101     /**
102      * Filter that detects files named "package.json", "package-lock.json", or
103      * "npm-shrinkwrap.json".
104      */
105     private static final FileFilter PACKAGE_JSON_FILTER = FileFilterBuilder.newInstance()
106             .addFilenames(PACKAGE_JSON, PACKAGE_LOCK_JSON, SHRINKWRAP_JSON).build();
107 
108     /**
109      * Returns the FileFilter
110      *
111      * @return the FileFilter
112      */
113     @Override
114     protected FileFilter getFileFilter() {
115         return PACKAGE_JSON_FILTER;
116     }
117 
118     /**
119      * Performs validation on the configuration to ensure that the correct
120      * analyzers are in place.
121      *
122      * @param engine the dependency-check engine
123      * @throws InitializationException thrown if there is a configuration error
124      */
125     @Override
126     protected void prepareFileTypeAnalyzer(Engine engine) throws InitializationException {
127         if (engine.getMode() != Mode.EVIDENCE_COLLECTION) {
128             try {
129                 final Settings settings = engine.getSettings();
130                 final String[] tmp = settings.getArray(Settings.KEYS.ECOSYSTEM_SKIP_CPEANALYZER);
131                 if (tmp != null) {
132                     final List<String> skipEcosystems = Arrays.asList(tmp);
133                     if (skipEcosystems.contains(DEPENDENCY_ECOSYSTEM)
134                             && !settings.getBoolean(Settings.KEYS.ANALYZER_OSSINDEX_ENABLED)) {
135                         if (!settings.getBoolean(Settings.KEYS.ANALYZER_NODE_AUDIT_ENABLED)) {
136                             final String msg = "Invalid Configuration: enabling the Node Package Analyzer without "
137                                     + "using the Node Audit Analyzer or OSS Index Analyzer is not supported.";
138                             throw new InitializationException(msg);
139                         } else if (!isNodeAuditEnabled(engine)) {
140                             final String msg = "Missing package.lock or npm-shrinkwrap.lock file: Unable to scan a node "
141                                     + "project without a package-lock.json or npm-shrinkwrap.json.";
142                             throw new InitializationException(msg);
143                         }
144                     } else if (skipEcosystems.contains(DEPENDENCY_ECOSYSTEM)
145                             && !settings.getBoolean(Settings.KEYS.ANALYZER_NODE_AUDIT_ENABLED)) {
146                         LOGGER.warn("Using only the OSS Index Analyzer with Node.js can result in many false positives "
147                                 + "- please enable the Node Audit Analyzer.");
148                     }
149                 }
150             } catch (InvalidSettingException ex) {
151                 throw new InitializationException("Unable to read configuration settings", ex);
152             }
153         }
154     }
155 
156     /**
157      * Returns the name of the analyzer.
158      *
159      * @return the name of the analyzer.
160      */
161     @Override
162     public String getName() {
163         return ANALYZER_NAME;
164     }
165 
166     /**
167      * Returns the phase that the analyzer is intended to run in.
168      *
169      * @return the phase that the analyzer is intended to run in.
170      */
171     @Override
172     public AnalysisPhase getAnalysisPhase() {
173         return ANALYSIS_PHASE;
174     }
175 
176     /**
177      * Returns the key used in the properties file to reference the enabled
178      * property for the analyzer.
179      *
180      * @return the enabled property setting key for the analyzer
181      */
182     @Override
183     protected String getAnalyzerEnabledSettingKey() {
184         return Settings.KEYS.ANALYZER_NODE_PACKAGE_ENABLED;
185     }
186 
187     /**
188      * Determines if the Node Audit analyzer is enabled.
189      *
190      * @param engine a reference to the dependency-check engine
191      * @return <code>true</code> if the Node Audit Analyzer is enabled;
192      * otherwise <code>false</code>
193      */
194     private boolean isNodeAuditEnabled(Engine engine) {
195         for (Analyzer a : engine.getAnalyzers()) {
196             if (a instanceof NodeAuditAnalyzer || a instanceof YarnAuditAnalyzer || a instanceof PnpmAuditAnalyzer) {
197                 if (a.isEnabled()) {
198                     try {
199                         ((AbstractNpmAnalyzer) a).prepareFileTypeAnalyzer(engine);
200                     } catch (InitializationException ex) {
201                         final String message = "Error initializing the " + a.getName();
202                         LOGGER.debug(message, ex);
203                     }
204                 }
205                 return a.isEnabled();
206             }
207         }
208         return false;
209     }
210 
211     /**
212      * Checks if a package lock file or equivalent exists for the NPM project.
213      *
214      * @param dependencyFile a reference to the `package.json` file
215      * @return <code>true</code> if no lock file is found; otherwise
216      * <code>true</code>
217      */
218     private boolean noLockFileExists(File dependencyFile) {
219         final File lock = new File(dependencyFile.getParentFile(), "package-lock.json");
220         final File shrinkwrap = new File(dependencyFile.getParentFile(), "npm-shrinkwrap.json");
221         final File yarnLock = new File(dependencyFile.getParentFile(), "yarn.lock");
222         return !(lock.isFile() || shrinkwrap.isFile() || yarnLock.isFile());
223     }
224 
225     @Override
226     protected void analyzeDependency(Dependency dependency, Engine engine) throws AnalysisException {
227         final File dependencyFile = dependency.getActualFile();
228         if (!existsWithContent(dependencyFile) || !shouldProcess(dependencyFile)) {
229             return;
230         }
231         if (isNodeAuditEnabled(engine)
232                 && !(PACKAGE_LOCK_JSON.equals(dependency.getFileName()) || SHRINKWRAP_JSON.equals(dependency.getFileName()))) {
233             engine.removeDependency(dependency);
234         }
235         if (noLockFileExists(dependency.getActualFile())) {
236             LOGGER.warn("No lock file exists - this will result in false negatives; please run `npm install --package-lock`");
237         }
238         final File baseDir = dependencyFile.getParentFile();
239         if (PACKAGE_JSON.equals(dependency.getFileName())) {
240             final File lockfile = new File(baseDir, PACKAGE_LOCK_JSON);
241             final File shrinkwrap = new File(baseDir, SHRINKWRAP_JSON);
242             if (shrinkwrap.exists() || lockfile.exists()) {
243                 return;
244             }
245         } else if (PACKAGE_LOCK_JSON.equals(dependency.getFileName())) {
246             final File shrinkwrap = new File(baseDir, SHRINKWRAP_JSON);
247             if (shrinkwrap.exists()) {
248                 return;
249             }
250         }
251         final File nodeModules = new File(baseDir, "node_modules");
252         if (!nodeModules.isDirectory()) {
253             LOGGER.warn("Analyzing `{}` - however, the node_modules directory does not exist. "
254                     + "Please run `npm install` prior to running dependency-check", dependencyFile);
255             return;
256         }
257 
258         try (JsonReader jsonReader = Json.createReader(Files.newInputStream(dependencyFile.toPath()))) {
259             final JsonObject json = jsonReader.readObject();
260             final String parentName = json.getString("name", "");
261             final String parentVersion = json.getString("version", "");
262             if (parentName.isEmpty()) {
263                 return;
264             }
265             dependency.setName(parentName);
266             final String parentPackage;
267             if (!parentVersion.isEmpty()) {
268                 dependency.setVersion(parentVersion);
269                 parentPackage = String.format("%s:%s", parentName, parentVersion);
270             } else {
271                 parentPackage = parentName;
272             }
273             processDependencies(json, baseDir, dependencyFile, parentPackage, engine);
274         } catch (JsonException e) {
275             LOGGER.warn("Failed to parse package.json file.", e);
276         } catch (IOException e) {
277             throw new AnalysisException("Problem occurred while reading dependency file.", e);
278         }
279     }
280 
281     /**
282      * should process the dependency ? Will return true if you need to skip it .
283      * (e.g. dependency can't be read, or if npm audit doesn't handle it)
284      *
285      * @param name the name of the dependency
286      * @param version the version of the dependency
287      * @param optional is the dependency optional ?
288      * @param fileExist is the package.json available for this file ?
289      * @return should you skip this dependency ?
290      */
291     public static boolean shouldSkipDependency(String name, String version, boolean optional, boolean fileExist) {
292         // some package manager can handle alias, yarn for example, but npm doesn't support it
293         if (Objects.nonNull(version) && version.startsWith("npm:")) {
294             //TODO make this an error that gets logged
295             LOGGER.warn("dependency skipped: package.json contain an alias for {} => {} npm audit doesn't "
296                     + "support aliases", name, version.replace("npm:", ""));
297             return true;
298         }
299 
300         if (optional && !fileExist) {
301             LOGGER.warn("dependency skipped: node module {} seems optional and not installed", name);
302             return true;
303         }
304 
305         // this seems to produce crash sometimes, I need to tests
306         // using a local node_module is not supported by npm audit, it crash
307         if (Objects.nonNull(version) && (version.startsWith("file:") || version.matches("^[.~]{0,2}/.*"))) {
308             LOGGER.warn("dependency skipped: package.json contain an local node_module for {} seems to be "
309                             + "located {} npm audit doesn't support locally referenced modules",
310                     name, version);
311             return true;
312         }
313 
314         // Don't include package with empty name
315         if ("".equals(name)) {
316             LOGGER.debug("Empty dependency of package-lock v2+ removed");
317             return true;
318         }
319 
320         return false;
321     }
322 
323     /**
324      * Checks if the given dependency should be skipped.
325      *
326      * @param name the name of the dependency to test
327      * @param version the version of the dependency to test
328      * @return <code>true</code> if the dependency should be skipped; otherwise
329      * <code>false</code>
330      * @see NodePackageAnalyzer#shouldSkipDependency(java.lang.String,
331      * java.lang.String, boolean, boolean)
332      */
333     public static boolean shouldSkipDependency(String name, String version) {
334         return shouldSkipDependency(name, version, false, true);
335     }
336 
337     /**
338      * Process the dependencies in the lock file by first parsing its
339      * dependencies and then finding the package.json for the module and adding
340      * it as a dependency.
341      *
342      * @param json the data to process
343      * @param baseDir the base directory being scanned
344      * @param rootFile the root package-lock/npm-shrinkwrap being analyzed
345      * @param parentPackage the parent package name of the current node
346      * @param engine a reference to the dependency-check engine
347      * @throws AnalysisException thrown if there is an exception
348      */
349     private void processDependencies(JsonObject json, File baseDir, File rootFile,
350                                      String parentPackage, Engine engine) throws AnalysisException {
351         final boolean skipDev = getSettings().getBoolean(Settings.KEYS.ANALYZER_NODE_PACKAGE_SKIPDEV, false);
352         final JsonObject deps;
353         final File modulesRoot = new File(rootFile.getParentFile(), "node_modules");
354         final int lockJsonVersion = json.containsKey("lockfileVersion") ? json.getInt("lockfileVersion") : 1;
355         if (lockJsonVersion >= 2) {
356             deps = json.getJsonObject("packages");
357         } else if (json.containsKey("dependencies")) {
358             deps = json.getJsonObject("dependencies");
359         } else {
360             deps = null;
361         }
362 
363         if (deps != null) {
364             for (Map.Entry<String, JsonValue> entry : deps.entrySet()) {
365                 final String pathName = entry.getKey();
366                 String name = pathName;
367                 File base;
368 
369                 final int indexOfNodeModule = name.lastIndexOf(NODE_MODULES_DIRNAME + "/");
370                 if (indexOfNodeModule >= 0) {
371                     name = name.substring(indexOfNodeModule + NODE_MODULES_DIRNAME.length() + 1);
372                     base = Paths.get(baseDir.getPath(), pathName).toFile();
373                 } else {
374                     base = Paths.get(baseDir.getPath(), "node_modules", name).toFile();
375                     if (!base.isDirectory()) {
376                         final File test = new File(modulesRoot, name);
377                         if (test.isDirectory()) {
378                             base = test;
379                         }
380                     }
381                 }
382 
383                 final String version;
384                 boolean optional = false;
385                 boolean isDev = false;
386 
387                 final File f = new File(base, PACKAGE_JSON);
388                 JsonObject jo = null;
389 
390                 if (entry.getValue() instanceof JsonObject) {
391                     jo = (JsonObject) entry.getValue();
392 
393                     // Ignore/skip linked entries (as they don't have "version" and
394                     // later logic will crash)
395                     if (jo.getBoolean("link", false)) {
396                         LOGGER.warn("Skipping `" + name + "` because it is a link dependency");
397                         continue;
398                     }
399 
400                     version = jo.getString("version", "");
401                     optional = jo.getBoolean("optional", false);
402                     isDev = jo.getBoolean("dev", false);
403                 } else {
404                     version = ((JsonString) entry.getValue()).getString();
405                 }
406 
407                 if ((isDev && skipDev) || shouldSkipDependency(name, version, optional, f.exists())) {
408                     continue;
409                 }
410 
411                 if (null != jo && jo.containsKey("dependencies")) {
412                     final String subPackageName = String.format("%s/%s:%s", parentPackage, name, version);
413                     processDependencies(jo, base, rootFile, subPackageName, engine);
414                 }
415 
416                 String ref = "";
417                 final int slash = parentPackage.indexOf("/");
418                 if (slash > 0) {
419                     ref = parentPackage.substring(slash + 1);
420                 }
421                 final Dependency child = new Dependency(new File(rootFile + "?" + ref + "/" + name + ":" + version), true);
422                 child.addProjectReference(parentPackage);
423                 child.setEcosystem(DEPENDENCY_ECOSYSTEM);
424 
425                 if (f.exists()) {
426                     try {
427                         //TODO - we should use the integrity value instead of calculating the SHA1/MD5
428                         child.setMd5sum(Checksum.getMD5Checksum(f));
429                         child.setSha1sum(Checksum.getSHA1Checksum(f));
430                         child.setSha256sum(Checksum.getSHA256Checksum(f));
431                     } catch (IOException | NoSuchAlgorithmException ex) {
432                         LOGGER.debug("Error setting hashes:" + ex.getMessage(), ex);
433                     }
434                     try (JsonReader jr = Json.createReader(Files.newInputStream(f.toPath()))) {
435                         final JsonObject childJson = jr.readObject();
436                         gatherEvidence(childJson, child);
437                     } catch (JsonException e) {
438                         LOGGER.warn("Failed to parse package.json file from dependency.", e);
439                     } catch (IOException e) {
440                         throw new AnalysisException("Problem occurred while reading dependency file.", e);
441                     }
442                 } else {
443                     LOGGER.warn("Unable to find node module: {}", f);
444                     //TODO - we should use the integrity value instead of calculating the SHA1/MD5
445                     child.setSha1sum(Checksum.getSHA1Checksum(String.format("%s:%s", name, version)));
446                     child.setSha256sum(Checksum.getSHA256Checksum(String.format("%s:%s", name, version)));
447                     child.setMd5sum(Checksum.getMD5Checksum(String.format("%s:%s", name, version)));
448                     child.addEvidence(EvidenceType.VENDOR, rootFile.getName(), "name", name, Confidence.HIGHEST);
449                     child.addEvidence(EvidenceType.PRODUCT, rootFile.getName(), "name", name, Confidence.HIGHEST);
450                     child.addEvidence(EvidenceType.VERSION, rootFile.getName(), "version", version, Confidence.HIGHEST);
451                     child.setName(name);
452                     child.setVersion(version);
453                     final String packagePath = String.format("%s:%s", name, version);
454                     child.setDisplayFileName(packagePath);
455                     child.setPackagePath(packagePath);
456                     try {
457                         final PackageURL purl = PackageURLBuilder.aPackageURL().withType("npm").withName(name).withVersion(version).build();
458                         final PurlIdentifier id = new PurlIdentifier(purl, Confidence.HIGHEST);
459                         child.addSoftwareIdentifier(id);
460                     } catch (MalformedPackageURLException ex) {
461                         LOGGER.debug("Unable to build package url for `" + packagePath + "`", ex);
462                     }
463                 }
464                 synchronized (this) {
465                     final Dependency existing = findDependency(engine, name, version);
466                     if (existing != null) {
467                         if (existing.isVirtual()) {
468                             DependencyMergingAnalyzer.mergeDependencies(child, existing, null);
469                             engine.removeDependency(existing);
470                             engine.addDependency(child);
471                         } else {
472                             DependencyBundlingAnalyzer.mergeDependencies(existing, child, null);
473                         }
474                     } else {
475                         engine.addDependency(child);
476                     }
477                 }
478             }
479         }
480     }
481 }