* - security-advisories: very relevant, but only in a VulnerabilityAnalyzer (or
* mirrored VulnerabilitySource) context
- *
+ *
* - providers-lazy-url: old v1 construct for which I haven't seen any example,
* in v2 the metadata-url is used for this. seems like it's not relevant for DT
* - list: returns only package names, seems like repo.packagist.org (and .com?)
@@ -273,7 +276,7 @@ private void loadIncludedPackages(final JSONObject repoRoot, final JSONObject da
}
private MetaModel analyzeFromMetadataUrl(final MetaModel meta, final Component component,
- final String packageMetaDataPathPattern) {
+ final String packageMetaDataPathPattern) {
final String composerPackageMetadataFilename = packageMetaDataPathPattern.replaceAll("%package%",
getComposerPackageName(component));
final String url;
@@ -307,7 +310,7 @@ private MetaModel analyzeFromMetadataUrl(final MetaModel meta, final Component c
if (!responsePackages.has(expectedResponsePackage)) {
// the package no longer exists - for v2 there's no example (yet), v1 example
// https://repo.packagist.org/p/magento/adobe-ims.json
- LOGGER.debug("%s: Package no longer exists in repository %s.". formatted(component.getPurl(), this.repositoryId));
+ LOGGER.debug("%s: Package no longer exists in repository %s.".formatted(component.getPurl(), this.repositoryId));
return meta;
}
@@ -353,38 +356,40 @@ private JSONObject expandPackages(JSONObject packages) {
}
private MetaModel analyzePackageVersions(final MetaModel meta, Component component, JSONObject packageVersions) {
- final ComparableVersion latestVersion = new ComparableVersion(stripLeadingV(component.getPurl().getVersion()));
+ Version latestVersion = null;
final DateFormat dateFormat = new SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ssXXX");
LOGGER.debug("%s: analyzing package versions in %s: ".formatted(component.getPurl(), this.repositoryId));
- packageVersions.keySet().forEach(item -> {
- JSONObject packageVersion = packageVersions.getJSONObject((String) item);
+ for (final String item : packageVersions.keySet()) {
+ final JSONObject packageVersion = packageVersions.getJSONObject(item);
// Sometimes the JSON key differs from the the version inside the JSON value. The latter is leading.
- String version = packageVersion.getString("version");
- if (version.startsWith("dev-") || version.endsWith("-dev")) {
- // dev versions are excluded, since they are not pinned but a VCS-branch.
- // this case doesn't seem to happen anymore with V2, as dev (untagged) releases
- // are not part of the response anymore
- return;
- }
+ final String version = packageVersion.getString("version");
// Some (old?) repositories like composer.amasty.com/enterprise do not include a
- // 'version_normalized' field
- // TODO Should we attempt to normalize ourselves? The PHP code uses something
- // that results in 4 parts instead of 3, i.e. 2.3.8.0 instead of 2.3.8. Not sure
- // if that works with Semver4j
- String version_normalized = packageVersion.getString("version");
+ // 'version_normalized' field. versatile handles both 3- and 4-part version
+ // strings (e.g. 2.3.8 and 2.3.8.0) natively, so falling back to the raw version
+ // is safe.
+ String version_normalized = version;
if (packageVersion.has("version_normalized")) {
version_normalized = packageVersion.getString("version_normalized");
}
- ComparableVersion currentComparableVersion = new ComparableVersion(version_normalized);
- if (currentComparableVersion.compareTo(latestVersion) < 0) {
- // smaller version can be skipped
- return;
+ final Version currentVersion;
+ try {
+ currentVersion = VersionFactory.forScheme(KnownVersioningSchemes.SCHEME_COMPOSER, version_normalized);
+ } catch (InvalidVersionException e) {
+ LOGGER.debug("%s: Skipping unparseable Composer version %s in repository %s".formatted(component.getPurl(), version_normalized, this.repositoryId), e);
+ continue;
+ }
+ if (!currentVersion.isStable()) {
+ continue;
+ }
+
+ if (latestVersion != null && currentVersion.compareTo(latestVersion) < 0) {
+ continue;
}
- latestVersion.parseVersion(stripLeadingV(version_normalized));
+ latestVersion = currentVersion;
meta.setLatestVersion(version);
if (packageVersion.has("time")) {
@@ -401,16 +406,10 @@ private MetaModel analyzePackageVersions(final MetaModel meta, Component compone
// do not include the name field for a version, so print purl
LOGGER.warn("%s: Field 'time' not present in metadata in repository %s".formatted(component.getPurl(), this.repositoryId));
}
- });
+ }
return meta;
}
- private static String stripLeadingV(String s) {
- return s.startsWith("v") || s.startsWith("V")
- ? s.substring(1)
- : s;
- }
-
private static boolean isMinified(JSONObject data) {
if (data.has("minified") && "composer/2.0".equals(data.getString("minified"))) {
return true;
diff --git a/src/main/java/org/dependencytrack/tasks/repositories/NugetMetaAnalyzer.java b/src/main/java/org/dependencytrack/tasks/repositories/NugetMetaAnalyzer.java
index 20d9d73f0c..cec9676fb5 100644
--- a/src/main/java/org/dependencytrack/tasks/repositories/NugetMetaAnalyzer.java
+++ b/src/main/java/org/dependencytrack/tasks/repositories/NugetMetaAnalyzer.java
@@ -22,10 +22,13 @@
import com.github.benmanes.caffeine.cache.Cache;
import com.github.benmanes.caffeine.cache.Caffeine;
import com.github.packageurl.PackageURL;
+import io.github.nscuro.versatile.VersionFactory;
+import io.github.nscuro.versatile.spi.InvalidVersionException;
+import io.github.nscuro.versatile.spi.Version;
+import io.github.nscuro.versatile.version.KnownVersioningSchemes;
import org.apache.http.HttpStatus;
import org.apache.http.client.methods.CloseableHttpResponse;
import org.apache.http.util.EntityUtils;
-import org.apache.maven.artifact.versioning.ComparableVersion;
import org.dependencytrack.exception.MetaAnalyzerException;
import org.dependencytrack.model.Component;
import org.dependencytrack.model.RepositoryType;
@@ -103,6 +106,7 @@ public class NugetMetaAnalyzer extends AbstractMetaAnalyzer {
* specified a repo URL ending with index.json, it should be considered "fully qualified" and used as is to maximise
* compatability with non-nuget.org repos such as Artifactory. If not, preserve the previous Dependency Track
* behaviour of appending the nuget.org index to the supplied URL.
+ *
* @param baseUrl the base URL to the repository
*/
@Override
@@ -126,7 +130,7 @@ public void setRepositoryBaseUrl(String baseUrl) {
* @param registrationsBaseUrlStem Registrations URL to be set
*/
public void setRegistrationsBaseUrl(String registrationsBaseUrlStem) {
- if(registrationsBaseUrlStem == null || registrationsBaseUrlStem.isBlank()) {
+ if (registrationsBaseUrlStem == null || registrationsBaseUrlStem.isBlank()) {
return;
}
this.registrationsBaseUrl = stripTrailingSlash(registrationsBaseUrlStem) + "/%s/%s.json";
@@ -158,7 +162,7 @@ public RepositoryType supportedRepositoryType() {
*/
public MetaModel analyze(final Component component) {
- if(component == null) {
+ if (component == null) {
throw new IllegalArgumentException("Component cannot be null");
}
@@ -174,7 +178,8 @@ public MetaModel analyze(final Component component) {
/**
* Attempts to find the latest version of the supplied component and return its published date, if one exists.
* Ignores pre-release and unlisted versions.
- * @param meta {@link MetaModel} to be updated with detected version information
+ *
+ * @param meta {@link MetaModel} to be updated with detected version information
* @param component {@link Component} to be looked up in the NuGet repo
*/
private void performVersionCheck(final MetaModel meta, final Component component) {
@@ -189,7 +194,7 @@ private void performVersionCheck(final MetaModel meta, final Component component
try {
final var packageRegistrationRoot = fetchPackageRegistrationIndex(registrationsBaseUrl, component);
- if(packageRegistrationRoot == null) {
+ if (packageRegistrationRoot == null) {
return;
}
@@ -216,8 +221,9 @@ private void performVersionCheck(final MetaModel meta, final Component component
/**
* Retrieves the package registration index for the specified component
* (e.g. https://api.nuget.org/v3/registration5-gz-semver2/microsoft.data.sqlclient/index.json) and converts to JSON
+ *
* @param registrationsBaseUrl Registration base URL to look up package info
- * @param component Component for which retrieve package registration data should be retrieved
+ * @param component Component for which retrieve package registration data should be retrieved
* @return JSONObject containing package data if found or null if data not found
* @throws IOException if HTTP request errors
*/
@@ -234,10 +240,14 @@ private JSONObject fetchPackageRegistrationIndex(final String registrationsBaseU
}
}
+ private record PageWithUpperBound(JSONObject page, String upperRaw, Version upperVersion) {
+ }
+
/**
* Parses the NuGet Registrations to find latest version information. Handles both inline items and paged items.
* Sorts pages in descending order by the upper version number - if a listed, final version can be found in that
* page, it will be returned or pages will be searched in descending order until a match is found.
+ *
* @param registrationData Registrations to be searched
* @return Version metadata if a suitable version found, or null if not
* @throws IOException if network error occurs
@@ -250,23 +260,41 @@ private AbridgedNugetCatalogEntry findLatestViaRegistrations(final JSONObject re
return null;
}
- // Build a list of pages sorted by descending "upper" property.
- final List pageUpperBounds = new ArrayList<>();
+ // Pre-parse each page's upper version once, logging any failures exactly once per page.
+ final List pageUpperBounds = new ArrayList<>(pages.length());
for (int i = 0; i < pages.length(); i++) {
final JSONObject page = pages.optJSONObject(i);
- if (page != null && page.has(NUGET_KEY_UPPER)) {
- pageUpperBounds.add(page);
+ if (page == null || !page.has(NUGET_KEY_UPPER)) {
+ continue;
}
+
+ final String upperRaw = page.optString(NUGET_KEY_UPPER, "");
+ Version upperVersion = null;
+ try {
+ upperVersion = VersionFactory.forScheme(KnownVersioningSchemes.SCHEME_NUGET, upperRaw);
+ } catch (InvalidVersionException e) {
+ LOGGER.debug("Failed to parse NuGet page upper bound: " + upperRaw, e);
+ }
+
+ pageUpperBounds.add(new PageWithUpperBound(page, upperRaw, upperVersion));
}
- // Sort upper page bounds in descending order to get newest page first e.g, [ "6.1.0", "5.1.0" ]
- pageUpperBounds.sort((pageOne, pageTwo) -> {
- final ComparableVersion pageOneUpper = new ComparableVersion(pageOne.optString(NUGET_KEY_UPPER, "0"));
- final ComparableVersion pageTwoUpper = new ComparableVersion(pageTwo.optString(NUGET_KEY_UPPER, "0"));
- return pageTwoUpper.compareTo(pageOneUpper); // descending
+ // Sort by upper bound in descending order.
+ // Unparseable bounds sort last with lexicographic fallback.
+ pageUpperBounds.sort((a, b) -> {
+ if (a.upperVersion() != null && b.upperVersion() != null) {
+ return b.upperVersion().compareTo(a.upperVersion());
+ } else if (a.upperVersion() != null) {
+ return -1;
+ } else if (b.upperVersion() != null) {
+ return 1;
+ }
+
+ return b.upperRaw().compareToIgnoreCase(a.upperRaw());
});
- for (final JSONObject page : pageUpperBounds) {
+ for (final PageWithUpperBound pwub : pageUpperBounds) {
+ final JSONObject page = pwub.page();
try {
final JSONArray leaves = resolveLeaves(page);
final AbridgedNugetCatalogEntry bestOnPage = findHighestVersionFromLeaves(leaves, includePreRelease);
@@ -286,9 +314,10 @@ private AbridgedNugetCatalogEntry findLatestViaRegistrations(final JSONObject re
/**
* Parse the page JSON to find item leaves, retrieving data from the repo if needed. Returns null if neither
* inline items nor a fetchable @id exist/succeed.
+ *
* @param page Page to be parsed
* @return JSONArray containing leaf data if available, null if not
- * @throws IOException if network error occurs
+ * @throws IOException if network error occurs
* @throws MetaAnalyzerException if the repo returns an unexpected result
*/
private JSONArray resolveLeaves(final JSONObject page) throws IOException, MetaAnalyzerException {
@@ -322,7 +351,8 @@ private JSONArray resolveLeaves(final JSONObject page) throws IOException, MetaA
/**
* Scan the supplied leaves to extract the latest listed version. NuGet does not guarantee release order
* so scan the entire array although, anecdotally, the collection does generally appear to be in ascending order
- * @param leaves Items to be scanned
+ *
+ * @param leaves Items to be scanned
* @param includePreRelease include pre-release versions in latest version lookup
* @return {@link AbridgedNugetCatalogEntry containing the latest version found in the leaves collection
*/
@@ -333,7 +363,7 @@ private AbridgedNugetCatalogEntry findHighestVersionFromLeaves(final JSONArray l
}
AbridgedNugetCatalogEntry bestEntry = null;
- ComparableVersion newestVersionFound = null;
+ Version newestVersionFound = null;
for (int i = 0; i < leaves.length(); i++) {
final JSONObject leaf = leaves.optJSONObject(i);
@@ -343,11 +373,21 @@ private AbridgedNugetCatalogEntry findHighestVersionFromLeaves(final JSONArray l
entry = parseCatalogEntry(leaf.optJSONObject("catalogEntry"));
}
- if (entry == null || entry.getVersion() == null || (isPreRelease(entry.getVersion()) && !includePreRelease)) {
+ if (entry == null || entry.getVersion() == null) {
+ continue;
+ }
+
+ final Version entryVersion;
+ try {
+ entryVersion = VersionFactory.forScheme(KnownVersioningSchemes.SCHEME_NUGET, entry.getVersion());
+ } catch (InvalidVersionException e) {
+ LOGGER.debug("Skipping NuGet catalog entry with unparseable version %s".formatted(entry.getVersion()), e);
+ continue;
+ }
+ if (!entryVersion.isStable() && !includePreRelease) {
continue;
}
- final ComparableVersion entryVersion = new ComparableVersion(entry.getVersion());
if (newestVersionFound == null || entryVersion.compareTo(newestVersionFound) > 0) {
newestVersionFound = entryVersion;
bestEntry = entry;
@@ -360,6 +400,7 @@ private AbridgedNugetCatalogEntry findHighestVersionFromLeaves(final JSONArray l
/**
* Parse a single catalog entry to extract the version and published information. Could be extended to include other
* fields (such as listed) if required. Returns null immediately if the entry is unlisted.
+ *
* @param catalogEntry Catalog entry to be parsed
* @return {@link AbridgedNugetCatalogEntry} if version is valid, null if not
*/
@@ -368,7 +409,7 @@ private AbridgedNugetCatalogEntry parseCatalogEntry(final JSONObject catalogEntr
// Listed is optional so assume package is listed unless explicitly hidden
boolean listed = catalogEntry.optBoolean("listed", true);
- if(!listed) {
+ if (!listed) {
return null;
}
@@ -388,19 +429,9 @@ private AbridgedNugetCatalogEntry parseCatalogEntry(final JSONObject catalogEntr
return entry;
}
- /**
- * NuGet considers a version string with any suffix after a hyphen to be pre-release according to
- * the documentation. This method could be expanded if we need to cover other rules.
- * @param version Version string to be tested
- * @return True if version matches pre-release conventions, false otherwise
- */
- private boolean isPreRelease(final String version) {
- return version.contains("-");
- }
-
/**
* Connects to the NuGet repo, retrieves the service index and attempts to find the best RegistrationsBaseUrl
+ *
* @return RegistrationsBaseUrl if found, null otherwise
*/
private String findRegistrationsBaseUrl() {
@@ -436,6 +467,7 @@ private String findRegistrationsBaseUrl() {
* compression, SemVer 2 without compression then non-compressed, non-SemVer2.
* See vsList,
- final Cpe targetCpe, final PackageURL targetPURL, final String targetVersion, final Component component,
- final VulnerabilityAnalysisLevel vulnerabilityAnalysisLevel) {
+ protected void analyzeVersionRange(
+ QueryManager qm,
+ List vsList,
+ Cpe targetCpe,
+ PackageURL targetPURL,
+ Component component,
+ VulnerabilityAnalysisLevel vulnerabilityAnalysisLevel) {
boolean ran = false;
if (targetCpe != null) {
- analyzeCpeVersionRange(qm, vsList, targetCpe, targetVersion, component, vulnerabilityAnalysisLevel);
+ analyzeCpeVersionRange(qm, vsList, targetCpe, component, vulnerabilityAnalysisLevel);
ran = true;
}
if (targetPURL != null) {
- analyzePurlVersionRange(qm, vsList, targetPURL, targetVersion, component, vulnerabilityAnalysisLevel);
+ analyzePurlVersionRange(qm, vsList, targetPURL, component, vulnerabilityAnalysisLevel);
ran = true;
}
if (!ran) {
@@ -83,11 +87,10 @@ protected void analyzePurlVersionRange(
QueryManager qm,
List vsList,
PackageURL targetPurl,
- String targetVersion,
Component component,
VulnerabilityAnalysisLevel vulnerabilityAnalysisLevel) {
for (final VulnerableSoftware vs : vsList) {
- if (comparePurlVersions(targetPurl, vs, targetVersion)) {
+ if (matchesPurl(vs, targetPurl) && comparePurlVersions(targetPurl, vs)) {
if (vs.getVulnerabilities() != null) {
for (final Vulnerability vulnerability : vs.getVulnerabilities()) {
NotificationUtil.analyzeNotificationCriteria(qm, vulnerability, component,
@@ -103,12 +106,10 @@ private void analyzeCpeVersionRange(
QueryManager qm,
List vsList,
Cpe targetCpe,
- String targetVersion,
Component component,
VulnerabilityAnalysisLevel vulnerabilityAnalysisLevel) {
for (final VulnerableSoftware vs : vsList) {
- final Boolean isCpeMatch = maybeMatchCpe(vs, targetCpe, targetVersion);
- if ((isCpeMatch == null || isCpeMatch) && compareCpeVersions(vs, targetVersion, component)) {
+ if (matchesCpe(vs, targetCpe) && compareCpeVersions(vs, targetCpe, component)) {
if (vs.getVulnerabilities() != null) {
for (final Vulnerability vulnerability : vs.getVulnerabilities()) {
NotificationUtil.analyzeNotificationCriteria(qm, vulnerability, component, vulnerabilityAnalysisLevel);
@@ -123,16 +124,16 @@ private static String toLowerCaseNullable(final String string) {
return string == null ? null : string.toLowerCase();
}
- private Boolean maybeMatchCpe(final VulnerableSoftware vs, final Cpe targetCpe, final String targetVersion) {
+ private boolean matchesCpe(final VulnerableSoftware vs, final Cpe targetCpe) {
if (targetCpe == null || vs.getCpe23() == null) {
- return null;
+ return false;
}
final List relations = List.of(
Cpe.compareAttribute(vs.getPart(), toLowerCaseNullable(targetCpe.getPart().getAbbreviation())),
Cpe.compareAttribute(vs.getVendor(), toLowerCaseNullable(targetCpe.getVendor())),
Cpe.compareAttribute(vs.getProduct(), toLowerCaseNullable(targetCpe.getProduct())),
- Cpe.compareAttribute(vs.getVersion(), targetVersion),
+ Cpe.compareAttribute(vs.getVersion(), targetCpe.getVersion()),
Cpe.compareAttribute(vs.getUpdate(), targetCpe.getUpdate()),
Cpe.compareAttribute(vs.getEdition(), targetCpe.getEdition()),
Cpe.compareAttribute(vs.getLanguage(), targetCpe.getLanguage()),
@@ -161,7 +162,21 @@ private Boolean maybeMatchCpe(final VulnerableSoftware vs, final Cpe targetCpe,
return isMatch;
}
- private boolean comparePurlVersions(PackageURL componentPurl, VulnerableSoftware vs, String targetVersion) {
+ private boolean matchesPurl(VulnerableSoftware vs, PackageURL purl) {
+ if (purl == null) {
+ return false;
+ }
+
+ return Objects.equals(vs.getPurlType(), purl.getType())
+ && Objects.equals(vs.getPurlNamespace(), purl.getNamespace())
+ && Objects.equals(vs.getPurlName(), purl.getName());
+ }
+
+ private boolean comparePurlVersions(PackageURL componentPurl, VulnerableSoftware vs) {
+ if (componentPurl.getVersion() == null) {
+ return false;
+ }
+
final String componentDistroQualifier = PurlUtil.getDistroQualifier(componentPurl);
final String vsDistroQualifier = PurlUtil.getDistroQualifier(vs.getPurl());
@@ -199,7 +214,7 @@ private boolean comparePurlVersions(PackageURL componentPurl, VulnerableSoftware
.flatMap(KnownVersioningSchemes::fromPurl)
.orElse(KnownVersioningSchemes.SCHEME_GENERIC);
- return compareWithVers(vs, targetVersion, versioningScheme);
+ return compareWithVers(vs, componentPurl.getVersion(), versioningScheme);
}
/**
@@ -208,19 +223,19 @@ private boolean comparePurlVersions(PackageURL componentPurl, VulnerableSoftware
* versionStartIncluding.
*
* @param vs a reference to the vulnerable software to compare
- * @param targetVersion the version to compare
+ * @param targetCpe the CPE to compare against
* @return true if the target version is matched; otherwise
* false
*