Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -59,9 +59,9 @@ public class NodeAuditAnalyzer extends AbstractNpmAnalyzer {
*/
private static final Logger LOGGER = LoggerFactory.getLogger(NodeAuditAnalyzer.class);
/**
* The default URL to the NPM Audit API.
* The default URL to the NPM bulk advisory API.
*/
public static final String DEFAULT_URL = "https://registry.npmjs.org/-/npm/v1/security/audits";
public static final String DEFAULT_URL = "https://registry.npmjs.org/-/npm/v1/security/advisories/bulk";
/**
* A descriptor for the type of dependencies processed or added by this
* analyzer.
Expand Down Expand Up @@ -181,12 +181,12 @@ private List<Advisory> analyzePackage(final File lockFile, final File packageFil
// Retrieves the contents of package-lock.json from the Dependency
final JsonObject packageJson = packageReader.readObject();

// Modify the payload to meet the NPM Audit API requirements
final JsonObject payload = NpmPayloadBuilder.build(lockJson, packageJson, dependencyMap,
// Walk the lockfile to populate dependencyMap (used for the bulk advisory request body and response mapping)
NpmPayloadBuilder.build(lockJson, packageJson, dependencyMap,
getSettings().getBoolean(Settings.KEYS.ANALYZER_NODE_AUDIT_SKIPDEV, false));

// Submits the package payload to the nsp check service
return getSearcher().submitPackage(payload);
// Submits package versions to the npm bulk advisory API
return getSearcher().submitPackage(dependencyMap);
Comment on lines +184 to +189

} catch (URLConnectionFailureException e) {
this.setEnabled(false);
Expand Down Expand Up @@ -245,12 +245,11 @@ private List<Advisory> legacyAnalysis(final File file, Dependency dependency, Mu
dependency.setVersion(projectVersion);
}

// Modify the payload to meet the NPM Audit API requirements
final JsonObject payload = NpmPayloadBuilder.build(packageJson, dependencyMap,
NpmPayloadBuilder.build(packageJson, dependencyMap,
getSettings().getBoolean(Settings.KEYS.ANALYZER_NODE_AUDIT_SKIPDEV, false));

// Submits the package payload to the nsp check service
return getSearcher().submitPackage(payload);
// Submits package versions to the npm bulk advisory API
return getSearcher().submitPackage(dependencyMap);

} catch (URLConnectionFailureException e) {
this.setEnabled(false);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -320,11 +320,10 @@ private List<Advisory> analyzePackageWithYarnClassic(final File lockFile, Depend
try (JsonReader packageReader = Json.createReader(Files.newInputStream(lockFile.getParentFile().toPath().resolve("package.json")))) {
packageJson = packageReader.readObject();
}
// Modify the payload to meet the NPM Audit API requirements
final JsonObject payload = NpmPayloadBuilder.build(lockJson, packageJson, dependencyMap, skipDevDependencies);
NpmPayloadBuilder.build(lockJson, packageJson, dependencyMap, skipDevDependencies);

// Submits the package payload to the nsp check service
return getSearcher().submitPackage(payload);
// Submits package versions to the npm bulk advisory API
return getSearcher().submitPackage(dependencyMap);
Comment on lines +323 to +326

} catch (URLConnectionFailureException e) {
this.setEnabled(false);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@
import java.net.URL;
import java.security.SecureRandom;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
import javax.annotation.concurrent.ThreadSafe;

Expand All @@ -31,6 +32,7 @@
import org.apache.hc.core5.http.Header;
import org.apache.hc.core5.http.HttpHeaders;
import org.apache.hc.core5.http.message.BasicHeader;
import org.apache.commons.collections4.MultiValuedMap;
import org.json.JSONObject;
import org.owasp.dependencycheck.utils.DownloadFailedException;
import org.owasp.dependencycheck.utils.Downloader;
Expand Down Expand Up @@ -112,61 +114,65 @@ public NodeAuditSearch(Settings settings) throws MalformedURLException {
}

/**
* Submits the package.json file to the Node Audit API and returns a list of
* zero or more Advisories.
* Submits collected package versions to the NPM bulk advisory API and returns advisories.
*
* @param packageJson the package.json file retrieved from the Dependency
* @param dependencyMap module/version pairs from the lockfile walk
* @return a List of zero or more Advisory object
* @throws SearchException if Node Audit API is unable to analyze the
* package
* @throws IOException if it's unable to connect to Node Audit API
*/
public List<Advisory> submitPackage(JsonObject packageJson) throws SearchException, IOException {
public List<Advisory> submitPackage(MultiValuedMap<String, String> dependencyMap) throws SearchException, IOException {
final JsonObject bulkPayload = NpmPayloadBuilder.buildBulkPayload(dependencyMap);
if (bulkPayload.size() == 0) {
LOGGER.debug("No packages with resolvable versions for bulk advisory request; skipping HTTP call.");
return Collections.emptyList();
}
String key = null;
if (cache != null) {
key = Checksum.getSHA256Checksum(packageJson.toString());
key = Checksum.getSHA256Checksum(bulkPayload.toString());
final List<Advisory> cached = cache.get(key);
if (cached != null) {
LOGGER.debug("cache hit for node audit: " + key);
return cached;
}
}
return submitPackage(packageJson, key, 0);
return submitPackage(dependencyMap, bulkPayload, key, 0);
}

/**
* Submits the package.json file to the Node Audit API and returns a list of
* zero or more Advisories.
* Submits collected package versions to the NPM bulk advisory API and returns advisories.
*
* @param packageJson the package.json file retrieved from the Dependency
* @param dependencyMap module/version pairs (used for parsing the response)
* @param bulkPayload serialized bulk request body
* @param key the key for the cache entry
* @param count the current retry count
* @return a List of zero or more Advisory object
* @throws SearchException if Node Audit API is unable to analyze the
* package
* @throws IOException if it's unable to connect to Node Audit API
*/
private List<Advisory> submitPackage(JsonObject packageJson, String key, int count) throws SearchException, IOException {
private List<Advisory> submitPackage(MultiValuedMap<String, String> dependencyMap, JsonObject bulkPayload,
String key, int count) throws SearchException, IOException {
if (LOGGER.isTraceEnabled()) {
LOGGER.trace("----------------------------------------");
LOGGER.trace("Node Audit Payload:");
LOGGER.trace(packageJson.toString());
LOGGER.trace("----------------------------------------");
LOGGER.trace("Node Audit bulk payload:");
LOGGER.trace(bulkPayload.toString());
LOGGER.trace("----------------------------------------");
}
final List<Header> additionalHeaders = new ArrayList<>();
additionalHeaders.add(new BasicHeader(HttpHeaders.USER_AGENT, "npm/6.1.0 node/v10.5.0 linux x64"));
additionalHeaders.add(new BasicHeader(HttpHeaders.USER_AGENT, "npm/10.0.0 node/v20.0.0 linux x64"));
additionalHeaders.add(new BasicHeader("npm-in-ci", "false"));
additionalHeaders.add(new BasicHeader("npm-scope", ""));
additionalHeaders.add(new BasicHeader("npm-session", generateRandomSession()));

try {
final String response = Downloader.getInstance().postBasedFetchContent(nodeAuditUrl.toURI(),
packageJson.toString(), ContentType.APPLICATION_JSON, additionalHeaders);
bulkPayload.toString(), ContentType.APPLICATION_JSON, additionalHeaders);
final JSONObject jsonResponse = new JSONObject(response);
final NpmAuditParser parser = new NpmAuditParser();
final List<Advisory> advisories = parser.parse(jsonResponse);
if (cache != null) {
final NpmBulkAuditParser parser = new NpmBulkAuditParser();
final List<Advisory> advisories = parser.parse(jsonResponse, dependencyMap);
if (cache != null && key != null) {
cache.put(key, advisories);
}
return advisories;
Expand All @@ -189,13 +195,19 @@ private List<Advisory> submitPackage(JsonObject packageJson, String key, int cou
Thread.currentThread().interrupt();
throw new UnexpectedAnalysisException(ex);
}
return submitPackage(packageJson, key, next);
return submitPackage(dependencyMap, bulkPayload, key, next);
}
throw new SearchException("Could not perform Node Audit analysis - service returned a 503.", e);
case 400:
LOGGER.debug("Invalid payload submitted to Node Audit API. Received response code: {} {}",
hre.getStatusCode(), hre.getReasonPhrase());
throw new SearchException("Could not perform Node Audit analysis. Invalid payload submitted to Node Audit API.", e);
case 410:
LOGGER.warn("Node Audit API returned 410 Gone. The legacy audit endpoints are retired; "
+ "configure analyzer.node.audit.url to point to the bulk advisory endpoint "
+ "(/-/npm/v1/security/advisories/bulk).");
throw new SearchException("Node Audit API returned 410 Gone; the configured audit URL is no "
+ "longer supported. Use the bulk advisory endpoint.", e);
default:
LOGGER.debug("Could not connect to Node Audit API. Received response code: {} {}",
hre.getStatusCode(), hre.getReasonPhrase());
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,174 @@
/*
* 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) 2026 Ben Sommerfeld. All Rights Reserved.
*/
package org.owasp.dependencycheck.data.nodeaudit;

import io.github.jeremylong.openvulnerability.client.nvd.CvssV3;
import java.util.ArrayList;
import java.util.Iterator;
import java.util.LinkedHashSet;
import java.util.List;
import org.apache.commons.collections4.MultiValuedMap;
import org.json.JSONArray;
import org.json.JSONException;
import org.json.JSONObject;
import org.semver4j.Semver;
import org.semver4j.SemverException;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.owasp.dependencycheck.utils.CvssUtil;

/**
* Parses the JSON response from {@code POST /-/npm/v1/security/advisories/bulk} and maps
* advisories to installed versions using the dependency map from the lockfile.
*
* @see <a href="https://api-docs.npmjs.com/">npm Registry API</a>
*/
public class NpmBulkAuditParser {

/**
* The logger.
*/
private static final Logger LOGGER = LoggerFactory.getLogger(NpmBulkAuditParser.class);

/**
* Parses the bulk advisory response into a flat advisory list with concrete version hits.
*
* @param bulkResponse top-level object: package name → array of advisory objects
* @param dependencyMap module names to installed versions (from lockfile walk)
* @return advisories for (module, version) pairs whose version satisfies {@code vulnerable_versions}
* @throws JSONException if the response is malformed
*/
public List<Advisory> parse(JSONObject bulkResponse, MultiValuedMap<String, String> dependencyMap)
throws JSONException {
final List<Advisory> out = new ArrayList<>();
final Iterator<?> pkgKeys = bulkResponse.keys();
while (pkgKeys.hasNext()) {
final String moduleName = (String) pkgKeys.next();
final JSONArray advisoriesForPkg = bulkResponse.optJSONArray(moduleName);
if (advisoriesForPkg == null) {
continue;
}
for (int i = 0; i < advisoriesForPkg.length(); i++) {
final JSONObject raw = advisoriesForPkg.getJSONObject(i);
for (String installedVersion : new LinkedHashSet<>(dependencyMap.get(moduleName))) {
final String normalized = NpmPayloadBuilder.normalizeVersion(installedVersion);
if (normalized == null || normalized.isEmpty()) {
continue;
}
final String range = raw.optString("vulnerable_versions", "");
if (range.isEmpty()) {
continue;
}
if (!versionSatisfiesRange(normalized, range)) {
continue;
}
out.add(toAdvisory(moduleName, normalized, raw));
}
}
}
return out;
}
Comment on lines +56 to +85

private static boolean versionSatisfiesRange(String version, String range) {
try {
final Semver v = new Semver(version);
return v.satisfies(range);
} catch (SemverException ex) {
LOGGER.debug("Skipping semver check for {} in range {}: {}", version, range, ex.getMessage());
return false;
}
}

private Advisory toAdvisory(String moduleName, String resolvedVersion, JSONObject object) throws JSONException {
final Advisory advisory = new Advisory();
if (object.has("github_advisory_id")) {
advisory.setGhsaId(object.getString("github_advisory_id"));
} else if (object.has("id")) {
advisory.setGhsaId(String.valueOf(object.get("id")));
}
Comment on lines +97 to +103
advisory.setOverview(object.optString("overview", null));
advisory.setReferences(object.optString("references", null));
advisory.setCreated(object.optString("created", null));
advisory.setUpdated(object.optString("updated", null));
advisory.setRecommendation(object.optString("recommendation", null));
advisory.setTitle(object.optString("title", null));
advisory.setModuleName(moduleName);
advisory.setVersion(resolvedVersion);
advisory.setVulnerableVersions(object.optString("vulnerable_versions", null));
advisory.setPatchedVersions(object.optString("patched_versions", null));
advisory.setAccess(object.optString("access", null));
advisory.setSeverity(object.optString("severity", null));

final JSONArray jsonCwes = object.optJSONArray("cwe");
final List<String> stringCwes = new ArrayList<>();
if (jsonCwes != null) {
for (int j = 0; j < jsonCwes.length(); j++) {
stringCwes.add(jsonCwes.getString(j));
}
}
advisory.setCwes(stringCwes);

final JSONArray jsonCves = object.optJSONArray("cves");
final List<String> stringCves = new ArrayList<>();
if (jsonCves != null) {
for (int j = 0; j < jsonCves.length(); j++) {
stringCves.add(jsonCves.getString(j));
}
}
advisory.setCves(stringCves);

final JSONObject jsonCvss = object.optJSONObject("cvss");
if (jsonCvss != null) {
final double baseScore = readNumericField(jsonCvss, "score", -1.0);
if (baseScore >= 0.0) {
final String vector = jsonCvss.isNull("vectorString") ? null : jsonCvss.optString("vectorString");
if (vector != null && !vector.isEmpty() && !"null".equals(vector)) {
if (vector.startsWith("CVSS:3")) {
try {
final CvssV3 cvss = CvssUtil.vectorToCvssV3(vector, baseScore);
advisory.setCvssV3(cvss);
} catch (IllegalArgumentException iae) {
LOGGER.warn("Invalid CVSS vector format in NPM bulk audit '{}': {} ", vector, iae.getMessage());
}
} else {
LOGGER.warn("Unsupported CVSS vector format in NPM bulk audit results, please file a feature "
+ "request at https://github.com/dependency-check/DependencyCheck/issues/new/choose to "
+ "support vector format '{}' ", vector);
}
}
}
}
return advisory;
}

private static double readNumericField(JSONObject o, String key, double defaultValue) {
if (!o.has(key) || o.isNull(key)) {
return defaultValue;
}
final Object v = o.get(key);
if (v instanceof Number) {
return ((Number) v).doubleValue();
}
try {
return Double.parseDouble(v.toString());
} catch (NumberFormatException ex) {
LOGGER.trace("Could not parse numeric field {}: {}", key, v);
return defaultValue;
}
}
}
Loading
Loading