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 @@ -188,7 +188,7 @@ private List<Advisory> analyzePackage(final File lockFile, final File packageFil
getSettings().getBoolean(Settings.KEYS.ANALYZER_NODE_AUDIT_SKIPDEV, false));

// Submits the package payload to the nsp check service
return getSearcher().submitPackage(payload);
return getSearcher().submitPackageWithBulkFallback(payload, dependencyMap);

} catch (URLConnectionFailureException e) {
this.setEnabled(false);
Expand Down Expand Up @@ -252,7 +252,7 @@ private List<Advisory> legacyAnalysis(final File file, Dependency dependency, Mu
getSettings().getBoolean(Settings.KEYS.ANALYZER_NODE_AUDIT_SKIPDEV, false));

// Submits the package payload to the nsp check service
return getSearcher().submitPackage(payload);
return getSearcher().submitPackageWithBulkFallback(payload, dependencyMap);

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

import org.apache.commons.collections4.MultiValuedMap;
import org.apache.hc.client5.http.HttpResponseException;
import org.apache.hc.core5.http.ContentType;
import org.apache.hc.core5.http.Header;
Expand Down Expand Up @@ -63,6 +64,10 @@ public class NodeAuditSearch {
* The URL for the public Node Audit API.
*/
private final URL nodeAuditUrl;
/**
* The URL for the npm bulk advisory API.
*/
private final URL nodeAuditBulkUrl;

/**
* Whether to use the Proxy when making requests.
Expand Down Expand Up @@ -92,6 +97,7 @@ public NodeAuditSearch(Settings settings) throws MalformedURLException {
final String searchUrl = settings.getString(Settings.KEYS.ANALYZER_NODE_AUDIT_URL, DEFAULT_URL);
LOGGER.debug("Node Audit Search URL: {}", searchUrl);
this.nodeAuditUrl = new URL(searchUrl);
this.nodeAuditBulkUrl = deriveBulkUrl(searchUrl);
this.settings = settings;
if (null != settings.getString(Settings.KEYS.PROXY_SERVER)) {
useProxy = true;
Expand All @@ -111,6 +117,60 @@ public NodeAuditSearch(Settings settings) throws MalformedURLException {
}
}

/**
* Derives the npm bulk advisory API URL from the configured legacy audit URL.
*
* @param searchUrl the configured legacy audit API URL
* @return the derived bulk advisory URL, or {@code null}
* @throws MalformedURLException thrown if the derived URL is malformed
*/
private URL deriveBulkUrl(String searchUrl) throws MalformedURLException {
final String legacyPath = "/-/npm/v1/security/audits";
final String bulkPath = "/-/npm/v1/security/advisories/bulk";
if (searchUrl.endsWith(legacyPath)) {
final String bulkUrl = searchUrl.substring(0, searchUrl.length() - legacyPath.length()) + bulkPath;
LOGGER.debug("Node Audit bulk advisory URL: {}", bulkUrl);
return new URL(bulkUrl);
}
LOGGER.debug("Unable to derive npm bulk advisory URL from configured Node Audit URL: {}", searchUrl);
return null;
}

/**
* Submits the package to the npm bulk advisory API first, falling back to
* the legacy audit API if the bulk endpoint is unavailable.
*
* @param packageJson the legacy npm audit payload
* @param dependencyMap the dependencies and installed versions collected
* while building the legacy payload
* @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> submitPackageWithBulkFallback(JsonObject packageJson,
MultiValuedMap<String, String> dependencyMap) throws SearchException, IOException {
String key = null;
if (cache != null) {
key = Checksum.getSHA256Checksum(packageJson.toString());
final List<Advisory> cached = cache.get(key);
if (cached != null) {
LOGGER.debug("cache hit for node audit: " + key);
return cached;
}
}
if (nodeAuditBulkUrl != null) {
final JsonObject bulkPayload = NpmPayloadBuilder.buildBulk(dependencyMap);
if (!bulkPayload.isEmpty()) {
try {
return submitBulkPackage(bulkPayload, key, 0);
} catch (SearchException | IOException ex) {
LOGGER.debug("npm bulk advisory API failed; falling back to legacy Node Audit API", ex);
}
}
}
return submitPackage(packageJson, key, 0);
}

/**
* Submits the package.json file to the Node Audit API and returns a list of
* zero or more Advisories.
Expand Down Expand Up @@ -154,11 +214,7 @@ private List<Advisory> submitPackage(JsonObject packageJson, String key, int cou
LOGGER.trace("----------------------------------------");
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("npm-in-ci", "false"));
additionalHeaders.add(new BasicHeader("npm-scope", ""));
additionalHeaders.add(new BasicHeader("npm-session", generateRandomSession()));
final List<Header> additionalHeaders = buildAdditionalHeaders();

try {
final String response = Downloader.getInstance().postBasedFetchContent(nodeAuditUrl.toURI(),
Expand Down Expand Up @@ -208,6 +264,89 @@ private List<Advisory> submitPackage(JsonObject packageJson, String key, int cou
}
}

/**
* Submits the package data to the npm bulk advisory API.
*
* @param packageJson the bulk advisory payload
* @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 the bulk advisory API is unable to analyze the package
* @throws IOException if it's unable to connect to the bulk advisory API
*/
private List<Advisory> submitBulkPackage(JsonObject packageJson, String key, int count) throws SearchException, IOException {
if (LOGGER.isTraceEnabled()) {
LOGGER.trace("----------------------------------------");
LOGGER.trace("Node Audit Bulk Payload:");
LOGGER.trace(packageJson.toString());
LOGGER.trace("----------------------------------------");
LOGGER.trace("----------------------------------------");
}
final List<Header> additionalHeaders = buildAdditionalHeaders();

try {
final String response = Downloader.getInstance().postBasedFetchContent(nodeAuditBulkUrl.toURI(),
packageJson.toString(), ContentType.APPLICATION_JSON, additionalHeaders);
final JSONObject jsonResponse = new JSONObject(response);
final NpmAuditParser parser = new NpmAuditParser();
final List<Advisory> advisories = parser.parseBulk(jsonResponse);
if (cache != null) {
cache.put(key, advisories);
}
return advisories;
} catch (RuntimeException | URISyntaxException | TooManyRequestsException | ResourceNotFoundException ex) {
LOGGER.debug("Error connecting to npm bulk advisory API. Error: {}",
ex.getMessage());
throw new SearchException("Could not connect to npm bulk advisory API: " + ex.getMessage(), ex);
} catch (DownloadFailedException e) {
if (e.getCause() instanceof HttpResponseException) {
final HttpResponseException hre = (HttpResponseException) e.getCause();
switch (hre.getStatusCode()) {
case 503:
LOGGER.debug("npm bulk advisory API returned `{} {}` - retrying request.",
hre.getStatusCode(), hre.getReasonPhrase());
if (count < 5) {
final int next = count + 1;
try {
Thread.sleep(1500L * next);
} catch (InterruptedException ex) {
Thread.currentThread().interrupt();
throw new UnexpectedAnalysisException(ex);
}
return submitBulkPackage(packageJson, key, next);
}
throw new SearchException("Could not perform npm bulk advisory analysis - service returned a 503.", e);
case 400:
LOGGER.debug("Invalid payload submitted to npm bulk advisory API. Received response code: {} {}",
hre.getStatusCode(), hre.getReasonPhrase());
throw new SearchException("Could not perform npm bulk advisory analysis. Invalid payload submitted "
+ "to npm bulk advisory API.", e);
default:
LOGGER.debug("Could not connect to npm bulk advisory API. Received response code: {} {}",
hre.getStatusCode(), hre.getReasonPhrase());
throw new IOException("Could not connect to npm bulk advisory API", e);
}
} else {
LOGGER.debug("Could not connect to npm bulk advisory API. Received generic DownloadException", e);
throw new IOException("Could not connect to npm bulk advisory API", e);
}
}
}

/**
* Builds common npm audit request headers.
*
* @return request headers
*/
private List<Header> buildAdditionalHeaders() {
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("npm-in-ci", "false"));
additionalHeaders.add(new BasicHeader("npm-scope", ""));
additionalHeaders.add(new BasicHeader("npm-session", generateRandomSession()));
return additionalHeaders;
}

/**
* Generates a random 16 character lower-case hex string.
*
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,30 @@ public List<Advisory> parse(JSONObject jsonResponse) throws JSONException {
return advisories;
}

/**
* Parses the JSON response from the NPM bulk advisory API.
*
* @param jsonResponse the JSON node to parse
* @return a list of advisories
* @throws JSONException thrown if the JSON is not of the expected schema
*/
public List<Advisory> parseBulk(JSONObject jsonResponse) throws JSONException {
LOGGER.debug("Parsing bulk advisory JSON node");
final List<Advisory> advisories = new ArrayList<>();
final Iterator<?> keys = jsonResponse.keys();
while (keys.hasNext()) {
final String moduleName = (String) keys.next();
final JSONArray moduleAdvisories = jsonResponse.optJSONArray(moduleName);
if (moduleAdvisories == null) {
continue;
}
for (int i = 0; i < moduleAdvisories.length(); i++) {
advisories.add(parseBulkAdvisory(moduleName, moduleAdvisories.getJSONObject(i)));
}
}
return advisories;
}

/**
* Parses the advisory from Node Audit.
*
Expand Down Expand Up @@ -150,4 +174,117 @@ private Advisory parseAdvisory(JSONObject object) throws JSONException {
}
return advisory;
}

/**
* Parses an advisory from the NPM bulk advisory API.
*
* @param moduleName the name of the vulnerable module
* @param object the JSON object containing the advisory
* @return the Advisory object
*/
private Advisory parseBulkAdvisory(String moduleName, JSONObject object) {
final Advisory advisory = new Advisory();
final String url = object.optString("url", null);
String id = object.optString("github_advisory_id", null);
if (id == null || id.isBlank()) {
id = extractGhsaId(url);
}
if (id == null || id.isBlank()) {
id = object.optString("id", null);
}
advisory.setGhsaId(id);
advisory.setOverview(object.optString("overview", object.optString("title", null)));
advisory.setReferences(url == null || url.isBlank() ? null : "- " + url);
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.setVulnerableVersions(object.optString("vulnerable_versions", object.optString("range", "*")));
if (advisory.getVulnerableVersions() == null || advisory.getVulnerableVersions().isBlank()) {
advisory.setVulnerableVersions("*");
}
advisory.setPatchedVersions(object.optString("patched_versions", null));
advisory.setAccess(object.optString("access", null));
advisory.setSeverity(object.optString("severity", null));
advisory.setCwes(parseStringArray(object.opt("cwe")));
advisory.setCves(parseStringArray(object.opt("cves")));
parseCvss(object.optJSONObject("cvss"), advisory);
return advisory;
}

/**
* Parses a JSON string or array of strings.
*
* @param value the value to parse
* @return a list of strings
*/
private List<String> parseStringArray(Object value) {
final List<String> values = new ArrayList<>();
if (value instanceof JSONArray) {
final JSONArray array = (JSONArray) value;
for (int i = 0; i < array.length(); i++) {
values.add(array.optString(i));
}
} else if (value instanceof String) {
values.add((String) value);
}
return values;
}

/**
* Parses CVSSv3 details onto an advisory.
*
* @param jsonCvss the CVSS JSON object
* @param advisory the advisory to update
*/
private void parseCvss(JSONObject jsonCvss, Advisory advisory) {
if (jsonCvss == null) {
return;
}
double baseScore = -1.0;
final String score = jsonCvss.optString("score");
if (score != null) {
try {
baseScore = Float.parseFloat(score);
} catch (NumberFormatException ignored) {
LOGGER.trace("Swallowed NumberFormatException", ignored);
baseScore = -1.0f;
}
}
if (baseScore >= 0.0) {
final String vector = jsonCvss.optString("vectorString");
if (vector != null && !"null".equals(vector)) {
if (vector.startsWith("CVSS:3") && baseScore >= 0.0) {
try {
final CvssV3 cvss = CvssUtil.vectorToCvssV3(vector, baseScore);
advisory.setCvssV3(cvss);
} catch (IllegalArgumentException iae) {
LOGGER.warn("Invalid CVSS vector format encountered in NPM Audit results '{}': {} ", vector, iae.getMessage());
}
} else {
LOGGER.warn("Unsupported CVSS vector format in NPM Audit results, please file a feature "
+ "request at https://github.com/dependency-check/DependencyCheck/issues/new/choose to "
+ "support vector format '{}' ", vector);
}
}
}
}

/**
* Extracts a GHSA identifier from an advisory URL.
*
* @param url the advisory URL
* @return the GHSA identifier, or null
*/
private String extractGhsaId(String url) {
if (url == null || url.isBlank()) {
return null;
}
final int lastSlashIndex = url.lastIndexOf('/');
if (lastSlashIndex == -1 || lastSlashIndex == url.length() - 1) {
return null;
}
return url.substring(lastSlashIndex + 1);
}
}
Loading