NodePackageAnalyzer.java
/*
* This file is part of dependency-check-core.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*
* Copyright (c) 2015 Institute for Defense Analyses. All Rights Reserved.
*/
package org.owasp.dependencycheck.analyzer;
import com.github.packageurl.MalformedPackageURLException;
import com.github.packageurl.PackageURL;
import com.github.packageurl.PackageURLBuilder;
import org.owasp.dependencycheck.Engine;
import org.owasp.dependencycheck.Engine.Mode;
import org.owasp.dependencycheck.analyzer.exception.AnalysisException;
import org.owasp.dependencycheck.data.nvd.ecosystem.Ecosystem;
import org.owasp.dependencycheck.dependency.Confidence;
import org.owasp.dependencycheck.dependency.Dependency;
import org.owasp.dependencycheck.dependency.EvidenceType;
import org.owasp.dependencycheck.dependency.naming.PurlIdentifier;
import org.owasp.dependencycheck.exception.InitializationException;
import org.owasp.dependencycheck.utils.Checksum;
import org.owasp.dependencycheck.utils.FileFilterBuilder;
import org.owasp.dependencycheck.utils.InvalidSettingException;
import org.owasp.dependencycheck.utils.Settings;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import javax.annotation.concurrent.ThreadSafe;
import javax.json.Json;
import javax.json.JsonException;
import javax.json.JsonObject;
import javax.json.JsonReader;
import javax.json.JsonString;
import javax.json.JsonValue;
import java.io.File;
import java.io.FileFilter;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Paths;
import java.security.NoSuchAlgorithmException;
import java.util.Arrays;
import java.util.List;
import java.util.Map;
import java.util.Objects;
/**
* Used to analyze Node Package Manager (npm) package.json files, and collect
* information that can be used to determine the associated CPE.
*
* @author Dale Visser
*/
@ThreadSafe
public class NodePackageAnalyzer extends AbstractNpmAnalyzer {
/**
* The logger.
*/
private static final Logger LOGGER = LoggerFactory.getLogger(NodePackageAnalyzer.class);
/**
* A descriptor for the type of dependencies processed or added by this
* analyzer.
*/
public static final String DEPENDENCY_ECOSYSTEM = Ecosystem.NODEJS;
/**
* The name of the analyzer.
*/
private static final String ANALYZER_NAME = "Node.js Package Analyzer";
/**
* The phase that this analyzer is intended to run in.
*/
private static final AnalysisPhase ANALYSIS_PHASE = AnalysisPhase.INFORMATION_COLLECTION;
/**
* The file name to scan.
*/
public static final String PACKAGE_JSON = "package.json";
/**
* The file name to scan.
*/
public static final String PACKAGE_LOCK_JSON = "package-lock.json";
/**
* The file name to scan.
*/
public static final String SHRINKWRAP_JSON = "npm-shrinkwrap.json";
/**
* The name of the directory that contains node modules.
*/
public static final String NODE_MODULES_DIRNAME = "node_modules";
/**
* Filter that detects files named "package.json", "package-lock.json", or
* "npm-shrinkwrap.json".
*/
private static final FileFilter PACKAGE_JSON_FILTER = FileFilterBuilder.newInstance()
.addFilenames(PACKAGE_JSON, PACKAGE_LOCK_JSON, SHRINKWRAP_JSON).build();
/**
* Returns the FileFilter
*
* @return the FileFilter
*/
@Override
protected FileFilter getFileFilter() {
return PACKAGE_JSON_FILTER;
}
/**
* Performs validation on the configuration to ensure that the correct
* analyzers are in place.
*
* @param engine the dependency-check engine
* @throws InitializationException thrown if there is a configuration error
*/
@Override
protected void prepareFileTypeAnalyzer(Engine engine) throws InitializationException {
if (engine.getMode() != Mode.EVIDENCE_COLLECTION) {
try {
final Settings settings = engine.getSettings();
final String[] tmp = settings.getArray(Settings.KEYS.ECOSYSTEM_SKIP_CPEANALYZER);
if (tmp != null) {
final List<String> skipEcosystems = Arrays.asList(tmp);
if (skipEcosystems.contains(DEPENDENCY_ECOSYSTEM)
&& !settings.getBoolean(Settings.KEYS.ANALYZER_OSSINDEX_ENABLED)) {
if (!settings.getBoolean(Settings.KEYS.ANALYZER_NODE_AUDIT_ENABLED)) {
final String msg = "Invalid Configuration: enabling the Node Package Analyzer without "
+ "using the Node Audit Analyzer or OSS Index Analyzer is not supported.";
throw new InitializationException(msg);
} else if (!isNodeAuditEnabled(engine)) {
final String msg = "Missing package.lock or npm-shrinkwrap.lock file: Unable to scan a node "
+ "project without a package-lock.json or npm-shrinkwrap.json.";
throw new InitializationException(msg);
}
} else if (skipEcosystems.contains(DEPENDENCY_ECOSYSTEM)
&& !settings.getBoolean(Settings.KEYS.ANALYZER_NODE_AUDIT_ENABLED)) {
LOGGER.warn("Using only the OSS Index Analyzer with Node.js can result in many false positives "
+ "- please enable the Node Audit Analyzer.");
}
}
} catch (InvalidSettingException ex) {
throw new InitializationException("Unable to read configuration settings", ex);
}
}
}
/**
* Returns the name of the analyzer.
*
* @return the name of the analyzer.
*/
@Override
public String getName() {
return ANALYZER_NAME;
}
/**
* Returns the phase that the analyzer is intended to run in.
*
* @return the phase that the analyzer is intended to run in.
*/
@Override
public AnalysisPhase getAnalysisPhase() {
return ANALYSIS_PHASE;
}
/**
* Returns the key used in the properties file to reference the enabled
* property for the analyzer.
*
* @return the enabled property setting key for the analyzer
*/
@Override
protected String getAnalyzerEnabledSettingKey() {
return Settings.KEYS.ANALYZER_NODE_PACKAGE_ENABLED;
}
/**
* Determines if the Node Audit analyzer is enabled.
*
* @param engine a reference to the dependency-check engine
* @return <code>true</code> if the Node Audit Analyzer is enabled;
* otherwise <code>false</code>
*/
private boolean isNodeAuditEnabled(Engine engine) {
for (Analyzer a : engine.getAnalyzers()) {
if (a instanceof NodeAuditAnalyzer || a instanceof YarnAuditAnalyzer || a instanceof PnpmAuditAnalyzer) {
if (a.isEnabled()) {
try {
((AbstractNpmAnalyzer) a).prepareFileTypeAnalyzer(engine);
} catch (InitializationException ex) {
LOGGER.debug("Error initializing the {}", a.getName());
}
}
return a.isEnabled();
}
}
return false;
}
/**
* Checks if a package lock file or equivalent exists for the NPM project.
*
* @param dependencyFile a reference to the `package.json` file
* @return <code>true</code> if no lock file is found; otherwise
* <code>true</code>
*/
private boolean noLockFileExists(File dependencyFile) {
final File lock = new File(dependencyFile.getParentFile(), "package-lock.json");
final File shrinkwrap = new File(dependencyFile.getParentFile(), "npm-shrinkwrap.json");
final File yarnLock = new File(dependencyFile.getParentFile(), "yarn.lock");
return !(lock.isFile() || shrinkwrap.isFile() || yarnLock.isFile());
}
@Override
protected void analyzeDependency(Dependency dependency, Engine engine) throws AnalysisException {
final File dependencyFile = dependency.getActualFile();
if (!dependencyFile.isFile() || dependencyFile.length() == 0 || !shouldProcess(dependencyFile)) {
return;
}
if (isNodeAuditEnabled(engine)
&& !(PACKAGE_LOCK_JSON.equals(dependency.getFileName()) || SHRINKWRAP_JSON.equals(dependency.getFileName()))) {
engine.removeDependency(dependency);
}
if (noLockFileExists(dependency.getActualFile())) {
LOGGER.warn("No lock file exists - this will result in false negatives; please run `npm install --package-lock`");
}
final File baseDir = dependencyFile.getParentFile();
if (PACKAGE_JSON.equals(dependency.getFileName())) {
final File lockfile = new File(baseDir, PACKAGE_LOCK_JSON);
final File shrinkwrap = new File(baseDir, SHRINKWRAP_JSON);
if (shrinkwrap.exists() || lockfile.exists()) {
return;
}
} else if (PACKAGE_LOCK_JSON.equals(dependency.getFileName())) {
final File shrinkwrap = new File(baseDir, SHRINKWRAP_JSON);
if (shrinkwrap.exists()) {
return;
}
}
final File nodeModules = new File(baseDir, "node_modules");
if (!nodeModules.isDirectory()) {
LOGGER.warn("Analyzing `{}` - however, the node_modules directory does not exist. "
+ "Please run `npm install` prior to running dependency-check", dependencyFile);
return;
}
try (JsonReader jsonReader = Json.createReader(Files.newInputStream(dependencyFile.toPath()))) {
final JsonObject json = jsonReader.readObject();
final String parentName = json.getString("name", "");
final String parentVersion = json.getString("version", "");
if (parentName.isEmpty()) {
return;
}
dependency.setName(parentName);
final String parentPackage;
if (!parentVersion.isEmpty()) {
dependency.setVersion(parentVersion);
parentPackage = String.format("%s:%s", parentName, parentVersion);
} else {
parentPackage = parentName;
}
processDependencies(json, baseDir, dependencyFile, parentPackage, engine);
} catch (JsonException e) {
LOGGER.warn("Failed to parse package.json file.", e);
} catch (IOException e) {
throw new AnalysisException("Problem occurred while reading dependency file.", e);
}
}
/**
* should process the dependency ? Will return true if you need to skip it .
* (e.g. dependency can't be read, or if npm audit doesn't handle it)
*
* @param name the name of the dependency
* @param version the version of the dependency
* @param optional is the dependency optional ?
* @param fileExist is the package.json available for this file ?
* @return should you skip this dependency ?
*/
public static boolean shouldSkipDependency(String name, String version, boolean optional, boolean fileExist) {
// some package manager can handle alias, yarn for example, but npm doesn't support it
if (Objects.nonNull(version) && version.startsWith("npm:")) {
//TODO make this an error that gets logged
LOGGER.warn("dependency skipped: package.json contain an alias for {} => {} npm audit doesn't "
+ "support aliases", name, version.replace("npm:", ""));
return true;
}
if (optional && !fileExist) {
LOGGER.warn("dependency skipped: node module {} seems optional and not installed", name);
return true;
}
// this seems to produce crash sometimes, I need to tests
// using a local node_module is not supported by npm audit, it crash
if (Objects.nonNull(version) && (version.startsWith("file:") || version.matches("^[.~]{0,2}/.*"))) {
LOGGER.warn("dependency skipped: package.json contain an local node_module for {} seems to be "
+ "located {} npm audit doesn't support locally referenced modules",
name, version);
return true;
}
// Don't include package with empty name
if ("".equals(name)) {
LOGGER.debug("Empty dependency of package-lock v2+ removed");
return true;
}
return false;
}
/**
* Checks if the given dependency should be skipped.
*
* @param name the name of the dependency to test
* @param version the version of the dependency to test
* @return <code>true</code> if the dependency should be skipped; otherwise
* <code>false</code>
* @see NodePackageAnalyzer#shouldSkipDependency(java.lang.String,
* java.lang.String, boolean, boolean)
*/
public static boolean shouldSkipDependency(String name, String version) {
return shouldSkipDependency(name, version, false, true);
}
/**
* Process the dependencies in the lock file by first parsing its
* dependencies and then finding the package.json for the module and adding
* it as a dependency.
*
* @param json the data to process
* @param baseDir the base directory being scanned
* @param rootFile the root package-lock/npm-shrinkwrap being analyzed
* @param parentPackage the parent package name of the current node
* @param engine a reference to the dependency-check engine
* @throws AnalysisException thrown if there is an exception
*/
private void processDependencies(JsonObject json, File baseDir, File rootFile,
String parentPackage, Engine engine) throws AnalysisException {
final boolean skipDev = getSettings().getBoolean(Settings.KEYS.ANALYZER_NODE_PACKAGE_SKIPDEV, false);
final JsonObject deps;
final File modulesRoot = new File(rootFile.getParentFile(), "node_modules");
final int lockJsonVersion = json.containsKey("lockfileVersion") ? json.getInt("lockfileVersion") : 1;
if (lockJsonVersion >= 2) {
deps = json.getJsonObject("packages");
} else if (json.containsKey("dependencies")) {
deps = json.getJsonObject("dependencies");
} else {
deps = null;
}
if (deps != null) {
for (Map.Entry<String, JsonValue> entry : deps.entrySet()) {
final String pathName = entry.getKey();
String name = pathName;
File base;
final int indexOfNodeModule = name.lastIndexOf(NODE_MODULES_DIRNAME + "/");
if (indexOfNodeModule >= 0) {
name = name.substring(indexOfNodeModule + NODE_MODULES_DIRNAME.length() + 1);
base = Paths.get(baseDir.getPath(), pathName).toFile();
} else {
base = Paths.get(baseDir.getPath(), "node_modules", name).toFile();
if (!base.isDirectory()) {
final File test = new File(modulesRoot, name);
if (test.isDirectory()) {
base = test;
}
}
}
final String version;
boolean optional = false;
boolean isDev = false;
final File f = new File(base, PACKAGE_JSON);
JsonObject jo = null;
if (entry.getValue() instanceof JsonObject) {
jo = (JsonObject) entry.getValue();
// Ignore/skip linked entries (as they don't have "version" and
// later logic will crash)
if (jo.getBoolean("link", false)) {
LOGGER.warn("Skipping `" + name + "` because it is a link dependency");
continue;
}
version = jo.getString("version", "");
optional = jo.getBoolean("optional", false);
isDev = jo.getBoolean("dev", false);
} else {
version = ((JsonString) entry.getValue()).getString();
}
if ((isDev && skipDev) || shouldSkipDependency(name, version, optional, f.exists())) {
continue;
}
if (null != jo && jo.containsKey("dependencies")) {
final String subPackageName = String.format("%s/%s:%s", parentPackage, name, version);
processDependencies(jo, base, rootFile, subPackageName, engine);
}
String ref = "";
final int slash = parentPackage.indexOf("/");
if (slash > 0) {
ref = parentPackage.substring(slash + 1);
}
final Dependency child = new Dependency(new File(rootFile + "?" + ref + "/" + name + ":" + version), true);
child.addProjectReference(parentPackage);
child.setEcosystem(DEPENDENCY_ECOSYSTEM);
if (f.exists()) {
try {
//TODO - we should use the integrity value instead of calculating the SHA1/MD5
child.setMd5sum(Checksum.getMD5Checksum(f));
child.setSha1sum(Checksum.getSHA1Checksum(f));
child.setSha256sum(Checksum.getSHA256Checksum(f));
} catch (IOException | NoSuchAlgorithmException ex) {
LOGGER.debug("Error setting hashes:" + ex.getMessage(), ex);
}
try (JsonReader jr = Json.createReader(Files.newInputStream(f.toPath()))) {
final JsonObject childJson = jr.readObject();
gatherEvidence(childJson, child);
} catch (JsonException e) {
LOGGER.warn("Failed to parse package.json file from dependency.", e);
} catch (IOException e) {
throw new AnalysisException("Problem occurred while reading dependency file.", e);
}
} else {
LOGGER.warn("Unable to find node module: {}", f);
//TODO - we should use the integrity value instead of calculating the SHA1/MD5
child.setSha1sum(Checksum.getSHA1Checksum(String.format("%s:%s", name, version)));
child.setSha256sum(Checksum.getSHA256Checksum(String.format("%s:%s", name, version)));
child.setMd5sum(Checksum.getMD5Checksum(String.format("%s:%s", name, version)));
child.addEvidence(EvidenceType.VENDOR, rootFile.getName(), "name", name, Confidence.HIGHEST);
child.addEvidence(EvidenceType.PRODUCT, rootFile.getName(), "name", name, Confidence.HIGHEST);
child.addEvidence(EvidenceType.VERSION, rootFile.getName(), "version", version, Confidence.HIGHEST);
child.setName(name);
child.setVersion(version);
final String packagePath = String.format("%s:%s", name, version);
child.setDisplayFileName(packagePath);
child.setPackagePath(packagePath);
try {
final PackageURL purl = PackageURLBuilder.aPackageURL().withType("npm").withName(name).withVersion(version).build();
final PurlIdentifier id = new PurlIdentifier(purl, Confidence.HIGHEST);
child.addSoftwareIdentifier(id);
} catch (MalformedPackageURLException ex) {
LOGGER.debug("Unable to build package url for `" + packagePath + "`", ex);
}
}
synchronized (this) {
final Dependency existing = findDependency(engine, name, version);
if (existing != null) {
if (existing.isVirtual()) {
DependencyMergingAnalyzer.mergeDependencies(child, existing, null);
engine.removeDependency(existing);
engine.addDependency(child);
} else {
DependencyBundlingAnalyzer.mergeDependencies(existing, child, null);
}
} else {
engine.addDependency(child);
}
}
}
}
}
}