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