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 javax.json.Json;
41  import javax.json.JsonException;
42  import javax.json.JsonObject;
43  import javax.json.JsonReader;
44  import javax.json.JsonString;
45  import javax.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                         LOGGER.debug("Error initializing the {}", a.getName());
200                     }
201                 }
202                 return a.isEnabled();
203             }
204         }
205         return false;
206     }
207 
208     /**
209      * Checks if a package lock file or equivalent exists for the NPM project.
210      *
211      * @param dependencyFile a reference to the `package.json` file
212      * @return <code>true</code> if no lock file is found; otherwise
213      * <code>true</code>
214      */
215     private boolean noLockFileExists(File dependencyFile) {
216         final File lock = new File(dependencyFile.getParentFile(), "package-lock.json");
217         final File shrinkwrap = new File(dependencyFile.getParentFile(), "npm-shrinkwrap.json");
218         final File yarnLock = new File(dependencyFile.getParentFile(), "yarn.lock");
219         return !(lock.isFile() || shrinkwrap.isFile() || yarnLock.isFile());
220     }
221 
222     @Override
223     protected void analyzeDependency(Dependency dependency, Engine engine) throws AnalysisException {
224         final File dependencyFile = dependency.getActualFile();
225         if (!dependencyFile.isFile() || dependencyFile.length() == 0 || !shouldProcess(dependencyFile)) {
226             return;
227         }
228         if (isNodeAuditEnabled(engine)
229                 && !(PACKAGE_LOCK_JSON.equals(dependency.getFileName()) || SHRINKWRAP_JSON.equals(dependency.getFileName()))) {
230             engine.removeDependency(dependency);
231         }
232         if (noLockFileExists(dependency.getActualFile())) {
233             LOGGER.warn("No lock file exists - this will result in false negatives; please run `npm install --package-lock`");
234         }
235         final File baseDir = dependencyFile.getParentFile();
236         if (PACKAGE_JSON.equals(dependency.getFileName())) {
237             final File lockfile = new File(baseDir, PACKAGE_LOCK_JSON);
238             final File shrinkwrap = new File(baseDir, SHRINKWRAP_JSON);
239             if (shrinkwrap.exists() || lockfile.exists()) {
240                 return;
241             }
242         } else if (PACKAGE_LOCK_JSON.equals(dependency.getFileName())) {
243             final File shrinkwrap = new File(baseDir, SHRINKWRAP_JSON);
244             if (shrinkwrap.exists()) {
245                 return;
246             }
247         }
248         final File nodeModules = new File(baseDir, "node_modules");
249         if (!nodeModules.isDirectory()) {
250             LOGGER.warn("Analyzing `{}` - however, the node_modules directory does not exist. "
251                     + "Please run `npm install` prior to running dependency-check", dependencyFile);
252             return;
253         }
254 
255         try (JsonReader jsonReader = Json.createReader(Files.newInputStream(dependencyFile.toPath()))) {
256             final JsonObject json = jsonReader.readObject();
257             final String parentName = json.getString("name", "");
258             final String parentVersion = json.getString("version", "");
259             if (parentName.isEmpty()) {
260                 return;
261             }
262             dependency.setName(parentName);
263             final String parentPackage;
264             if (!parentVersion.isEmpty()) {
265                 dependency.setVersion(parentVersion);
266                 parentPackage = String.format("%s:%s", parentName, parentVersion);
267             } else {
268                 parentPackage = parentName;
269             }
270             processDependencies(json, baseDir, dependencyFile, parentPackage, engine);
271         } catch (JsonException e) {
272             LOGGER.warn("Failed to parse package.json file.", e);
273         } catch (IOException e) {
274             throw new AnalysisException("Problem occurred while reading dependency file.", e);
275         }
276     }
277 
278     /**
279      * should process the dependency ? Will return true if you need to skip it .
280      * (e.g. dependency can't be read, or if npm audit doesn't handle it)
281      *
282      * @param name the name of the dependency
283      * @param version the version of the dependency
284      * @param optional is the dependency optional ?
285      * @param fileExist is the package.json available for this file ?
286      * @return should you skip this dependency ?
287      */
288     public static boolean shouldSkipDependency(String name, String version, boolean optional, boolean fileExist) {
289         // some package manager can handle alias, yarn for example, but npm doesn't support it
290         if (Objects.nonNull(version) && version.startsWith("npm:")) {
291             //TODO make this an error that gets logged
292             LOGGER.warn("dependency skipped: package.json contain an alias for {} => {} npm audit doesn't "
293                     + "support aliases", name, version.replace("npm:", ""));
294             return true;
295         }
296 
297         if (optional && !fileExist) {
298             LOGGER.warn("dependency skipped: node module {} seems optional and not installed", name);
299             return true;
300         }
301 
302         // this seems to produce crash sometimes, I need to tests
303         // using a local node_module is not supported by npm audit, it crash
304         if (Objects.nonNull(version) && (version.startsWith("file:") || version.matches("^[.~]{0,2}/.*"))) {
305             LOGGER.warn("dependency skipped: package.json contain an local node_module for {} seems to be "
306                             + "located {} npm audit doesn't support locally referenced modules",
307                     name, version);
308             return true;
309         }
310 
311         // Don't include package with empty name
312         if ("".equals(name)) {
313             LOGGER.debug("Empty dependency of package-lock v2+ removed");
314             return true;
315         }
316 
317         return false;
318     }
319 
320     /**
321      * Checks if the given dependency should be skipped.
322      *
323      * @param name the name of the dependency to test
324      * @param version the version of the dependency to test
325      * @return <code>true</code> if the dependency should be skipped; otherwise
326      * <code>false</code>
327      * @see NodePackageAnalyzer#shouldSkipDependency(java.lang.String,
328      * java.lang.String, boolean, boolean)
329      */
330     public static boolean shouldSkipDependency(String name, String version) {
331         return shouldSkipDependency(name, version, false, true);
332     }
333 
334     /**
335      * Process the dependencies in the lock file by first parsing its
336      * dependencies and then finding the package.json for the module and adding
337      * it as a dependency.
338      *
339      * @param json the data to process
340      * @param baseDir the base directory being scanned
341      * @param rootFile the root package-lock/npm-shrinkwrap being analyzed
342      * @param parentPackage the parent package name of the current node
343      * @param engine a reference to the dependency-check engine
344      * @throws AnalysisException thrown if there is an exception
345      */
346     private void processDependencies(JsonObject json, File baseDir, File rootFile,
347                                      String parentPackage, Engine engine) throws AnalysisException {
348         final boolean skipDev = getSettings().getBoolean(Settings.KEYS.ANALYZER_NODE_PACKAGE_SKIPDEV, false);
349         final JsonObject deps;
350         final File modulesRoot = new File(rootFile.getParentFile(), "node_modules");
351         final int lockJsonVersion = json.containsKey("lockfileVersion") ? json.getInt("lockfileVersion") : 1;
352         if (lockJsonVersion >= 2) {
353             deps = json.getJsonObject("packages");
354         } else if (json.containsKey("dependencies")) {
355             deps = json.getJsonObject("dependencies");
356         } else {
357             deps = null;
358         }
359 
360         if (deps != null) {
361             for (Map.Entry<String, JsonValue> entry : deps.entrySet()) {
362                 final String pathName = entry.getKey();
363                 String name = pathName;
364                 File base;
365 
366                 final int indexOfNodeModule = name.lastIndexOf(NODE_MODULES_DIRNAME + "/");
367                 if (indexOfNodeModule >= 0) {
368                     name = name.substring(indexOfNodeModule + NODE_MODULES_DIRNAME.length() + 1);
369                     base = Paths.get(baseDir.getPath(), pathName).toFile();
370                 } else {
371                     base = Paths.get(baseDir.getPath(), "node_modules", name).toFile();
372                     if (!base.isDirectory()) {
373                         final File test = new File(modulesRoot, name);
374                         if (test.isDirectory()) {
375                             base = test;
376                         }
377                     }
378                 }
379 
380                 final String version;
381                 boolean optional = false;
382                 boolean isDev = false;
383 
384                 final File f = new File(base, PACKAGE_JSON);
385                 JsonObject jo = null;
386 
387                 if (entry.getValue() instanceof JsonObject) {
388                     jo = (JsonObject) entry.getValue();
389 
390                     // Ignore/skip linked entries (as they don't have "version" and
391                     // later logic will crash)
392                     if (jo.getBoolean("link", false)) {
393                         LOGGER.warn("Skipping `" + name + "` because it is a link dependency");
394                         continue;
395                     }
396 
397                     version = jo.getString("version", "");
398                     optional = jo.getBoolean("optional", false);
399                     isDev = jo.getBoolean("dev", false);
400                 } else {
401                     version = ((JsonString) entry.getValue()).getString();
402                 }
403 
404                 if ((isDev && skipDev) || shouldSkipDependency(name, version, optional, f.exists())) {
405                     continue;
406                 }
407 
408                 if (null != jo && jo.containsKey("dependencies")) {
409                     final String subPackageName = String.format("%s/%s:%s", parentPackage, name, version);
410                     processDependencies(jo, base, rootFile, subPackageName, engine);
411                 }
412 
413                 String ref = "";
414                 final int slash = parentPackage.indexOf("/");
415                 if (slash > 0) {
416                     ref = parentPackage.substring(slash + 1);
417                 }
418                 final Dependency child = new Dependency(new File(rootFile + "?" + ref + "/" + name + ":" + version), true);
419                 child.addProjectReference(parentPackage);
420                 child.setEcosystem(DEPENDENCY_ECOSYSTEM);
421 
422                 if (f.exists()) {
423                     try {
424                         //TODO - we should use the integrity value instead of calculating the SHA1/MD5
425                         child.setMd5sum(Checksum.getMD5Checksum(f));
426                         child.setSha1sum(Checksum.getSHA1Checksum(f));
427                         child.setSha256sum(Checksum.getSHA256Checksum(f));
428                     } catch (IOException | NoSuchAlgorithmException ex) {
429                         LOGGER.debug("Error setting hashes:" + ex.getMessage(), ex);
430                     }
431                     try (JsonReader jr = Json.createReader(Files.newInputStream(f.toPath()))) {
432                         final JsonObject childJson = jr.readObject();
433                         gatherEvidence(childJson, child);
434                     } catch (JsonException e) {
435                         LOGGER.warn("Failed to parse package.json file from dependency.", e);
436                     } catch (IOException e) {
437                         throw new AnalysisException("Problem occurred while reading dependency file.", e);
438                     }
439                 } else {
440                     LOGGER.warn("Unable to find node module: {}", f);
441                     //TODO - we should use the integrity value instead of calculating the SHA1/MD5
442                     child.setSha1sum(Checksum.getSHA1Checksum(String.format("%s:%s", name, version)));
443                     child.setSha256sum(Checksum.getSHA256Checksum(String.format("%s:%s", name, version)));
444                     child.setMd5sum(Checksum.getMD5Checksum(String.format("%s:%s", name, version)));
445                     child.addEvidence(EvidenceType.VENDOR, rootFile.getName(), "name", name, Confidence.HIGHEST);
446                     child.addEvidence(EvidenceType.PRODUCT, rootFile.getName(), "name", name, Confidence.HIGHEST);
447                     child.addEvidence(EvidenceType.VERSION, rootFile.getName(), "version", version, Confidence.HIGHEST);
448                     child.setName(name);
449                     child.setVersion(version);
450                     final String packagePath = String.format("%s:%s", name, version);
451                     child.setDisplayFileName(packagePath);
452                     child.setPackagePath(packagePath);
453                     try {
454                         final PackageURL purl = PackageURLBuilder.aPackageURL().withType("npm").withName(name).withVersion(version).build();
455                         final PurlIdentifier id = new PurlIdentifier(purl, Confidence.HIGHEST);
456                         child.addSoftwareIdentifier(id);
457                     } catch (MalformedPackageURLException ex) {
458                         LOGGER.debug("Unable to build package url for `" + packagePath + "`", ex);
459                     }
460                 }
461                 synchronized (this) {
462                     final Dependency existing = findDependency(engine, name, version);
463                     if (existing != null) {
464                         if (existing.isVirtual()) {
465                             DependencyMergingAnalyzer.mergeDependencies(child, existing, null);
466                             engine.removeDependency(existing);
467                             engine.addDependency(child);
468                         } else {
469                             DependencyBundlingAnalyzer.mergeDependencies(existing, child, null);
470                         }
471                     } else {
472                         engine.addDependency(child);
473                     }
474                 }
475             }
476         }
477     }
478 }