From daa81e3ee309403ebd79d4f9f70f0e9ab4884c5d Mon Sep 17 00:00:00 2001 From: Valentijn Scholten Date: Tue, 28 Jan 2025 19:11:36 +0100 Subject: [PATCH] Composer Vulnerability Mirroring Signed-off-by: Valentijn Scholten --- docs/_docs/datasources/composer.md | 16 + docs/_docs/datasources/repositories.md | 2 +- .../composer-repository-configuration.png | Bin 0 -> 91591 bytes .../event/ComposerAdvisoryMirrorEvent.java | 40 + .../event/EventSubsystemInitializer.java | 3 + .../model/ConfigPropertyConstants.java | 3 +- .../org/dependencytrack/model/Finding.java | 6 + .../org/dependencytrack/model/Repository.java | 26 + .../dependencytrack/model/Vulnerability.java | 12 + .../model/VulnerabilityAlias.java | 101 +- .../notification/NotificationConstants.java | 1 + .../composer/ComposerAdvisoryParser.java | 101 ++ .../composer/model/ComposerAdvisory.java | 146 +++ .../persistence/DefaultObjectGenerator.java | 37 +- .../persistence/QueryManager.java | 12 +- .../persistence/RepositoryQueryManager.java | 13 +- .../VulnerabilityQueryManager.java | 29 + .../resources/v1/RepositoryResource.java | 8 +- .../dependencytrack/tasks/TaskScheduler.java | 59 +- .../ComposerAdvisoryMirrorTask.java | 424 +++++++++ .../repositories/ComposerMetaAnalyzer.java | 38 +- .../tasks/repositories/IMetaAnalyzer.java | 1 - .../org/dependencytrack/util/JsonUtil.java | 6 +- .../model/VulnerabilityAliasTest.java | 6 + .../composer/ComposerAdvisoryParserTest.java | 260 ++++++ .../DefaultObjectGeneratorTest.java | 2 +- .../resources/v1/RepositoryResourceTest.java | 115 ++- .../tasks/RepoMetaAnalysisTaskTest.java | 16 +- .../ComposerAdvisoryMirrorTaskTest.java | 868 ++++++++++++++++++ .../ComposerMetaAnalyzerTest.java | 9 +- ...ttps---packages.drupal.org-advisories.json | 244 +++++ ...https---repo.packagist.org-advisories.json | 200 ++++ 32 files changed, 2696 insertions(+), 108 deletions(-) create mode 100644 docs/_docs/datasources/composer.md create mode 100755 docs/images/screenshots/composer-repository-configuration.png create mode 100644 src/main/java/org/dependencytrack/event/ComposerAdvisoryMirrorEvent.java create mode 100644 src/main/java/org/dependencytrack/parser/composer/ComposerAdvisoryParser.java create mode 100644 src/main/java/org/dependencytrack/parser/composer/model/ComposerAdvisory.java create mode 100644 src/main/java/org/dependencytrack/tasks/repositories/ComposerAdvisoryMirrorTask.java create mode 100644 src/test/java/org/dependencytrack/parser/composer/ComposerAdvisoryParserTest.java create mode 100644 src/test/java/org/dependencytrack/tasks/repositories/ComposerAdvisoryMirrorTaskTest.java create mode 100644 src/test/resources/unit/tasks/repositories/https---packages.drupal.org-advisories.json create mode 100644 src/test/resources/unit/tasks/repositories/https---repo.packagist.org-advisories.json diff --git a/docs/_docs/datasources/composer.md b/docs/_docs/datasources/composer.md new file mode 100644 index 0000000000..eb9817a237 --- /dev/null +++ b/docs/_docs/datasources/composer.md @@ -0,0 +1,16 @@ +--- +title: Composer Advisories +category: Datasources +chapter: 4 +order: 11 +--- + +[Composer Advisories](https://blog.packagist.com/discover-security-advisories-with-composers-audit-command/) (PKSA) is a database of CVEs and other vulnerabilities affecting the Composer packages. Advisories may or may not be documented in the [National Vulnerability Database]({{ site.baseurl }}{% link _docs/datasources/nvd.md %}). + +Dependency-Track integrates with Composer by mirroring advisories via each repositories [Packagist API](https://packagist.org/apidoc). +The mirroring (and alias synchronization) can be enabled/disabled [per repository]({{ site.baseurl }}{% link _docs/datasources/repositories.md %}). +The mirror is refreshed daily, or upon restart of the Dependency-Track instance. + +![Composer Advisories Configuration](../../images/screenshots/composer-repository-configuration.png) + + diff --git a/docs/_docs/datasources/repositories.md b/docs/_docs/datasources/repositories.md index f94c3d607c..0e03f22912 100644 --- a/docs/_docs/datasources/repositories.md +++ b/docs/_docs/datasources/repositories.md @@ -65,4 +65,4 @@ for information on Package URL and the various ways it is used throughout Depend For GitHub repositories (`github.com` per default), the username should be the GitHub account's username, and the password should be a [personal access token](https://docs.github.com/en/authentication/keeping-your-account-and-data-secure/creating-a-personal-access-token) -(PAT) with public access (no additional scopes). +(PAT) with public access (no additional scopes). \ No newline at end of file diff --git a/docs/images/screenshots/composer-repository-configuration.png b/docs/images/screenshots/composer-repository-configuration.png new file mode 100755 index 0000000000000000000000000000000000000000..2c065ada94c45ef65cad043405315329b29c8381 GIT binary patch literal 91591 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 aliases) { if (alias.getVulnDbId() != null && !alias.getVulnDbId().isBlank()) { map.put("vulnDbId", alias.getVulnDbId()); } + if (alias.getDrupalId() != null && !alias.getDrupalId().isBlank()) { + map.put("drupalId", alias.getDrupalId()); + } + if (alias.getComposerId() != null && !alias.getComposerId().isBlank()) { + map.put("composerId", alias.getComposerId()); + } uniqueAliases.add(map); } vulnerability.put("aliases",uniqueAliases); diff --git a/src/main/java/org/dependencytrack/model/Repository.java b/src/main/java/org/dependencytrack/model/Repository.java index 688045718f..a26a6f20d1 100644 --- a/src/main/java/org/dependencytrack/model/Repository.java +++ b/src/main/java/org/dependencytrack/model/Repository.java @@ -65,6 +65,11 @@ public class Repository implements Serializable { @JsonDeserialize(using = TrimmedStringDeserializer.class) private String identifier; + @Persistent + @Column(name = "DESCRIPTION") + @JsonDeserialize(using = TrimmedStringDeserializer.class) + private String description; + @Persistent @Column(name = "URL") @NotBlank @@ -100,6 +105,11 @@ public class Repository implements Serializable { @Column(name = "PASSWORD") private String password; + @Persistent + @Column(name = "CONFIG", jdbcType = "CLOB") + @JsonDeserialize(using = TrimmedStringDeserializer.class) + private String config; + @Persistent(customValueStrategy = "uuid") @Index(name = "REPOSITORY_UUID_IDX") // Cannot be @Unique. Microsoft SQL Server throws an exception @Column(name = "UUID", jdbcType = "VARCHAR", length = 36, allowsNull = "true") @@ -131,6 +141,14 @@ public void setIdentifier(String identifier) { this.identifier = identifier; } + public String getDescription() { + return description; + } + + public void setDescription(String description) { + this.description = description; + } + public String getUrl() { return url; } @@ -189,6 +207,14 @@ public void setPassword(String password) { this.password = password; } + public String getConfig() { + return config; + } + + public void setConfig(String config) { + this.config = config; + } + public UUID getUuid() { return uuid; } diff --git a/src/main/java/org/dependencytrack/model/Vulnerability.java b/src/main/java/org/dependencytrack/model/Vulnerability.java index 13f52cc395..821b4194b4 100644 --- a/src/main/java/org/dependencytrack/model/Vulnerability.java +++ b/src/main/java/org/dependencytrack/model/Vulnerability.java @@ -28,6 +28,8 @@ import com.fasterxml.jackson.databind.annotation.JsonSerialize; import io.swagger.v3.oas.annotations.media.ArraySchema; import io.swagger.v3.oas.annotations.media.Schema; + +import org.apache.commons.lang3.StringUtils; import org.dependencytrack.parser.common.resolver.CweResolver; import org.dependencytrack.persistence.CollectionIntegerConverter; import org.dependencytrack.resources.v1.serializers.CweDeserializer; @@ -111,6 +113,8 @@ public enum Source { OSV, // Google OSV Advisories SNYK, // Snyk TRIVY, // Trivy + COMPOSER, // Composer (Packagist) + DRUPAL, // Drupal UNKNOWN; // Unknown vulnerability sources public static boolean isKnownSource(String source) { @@ -118,12 +122,20 @@ public static boolean isKnownSource(String source) { } public static Source resolve(String id) { + if (StringUtils.isBlank(id)) { + return UNKNOWN; + } + if (id.startsWith("CVE-")){ return NVD; } else if (id.startsWith("GHSA-")){ return GITHUB; } else if (id.startsWith("OSV-")){ return OSV; + } else if (id.startsWith("SA-CORE-") || id.startsWith("SA-CONTRIB")){ + return DRUPAL; + } else if (id.startsWith("PKSA-")){ + return COMPOSER; } else if (id.startsWith("SNYK-")){ return SNYK; } diff --git a/src/main/java/org/dependencytrack/model/VulnerabilityAlias.java b/src/main/java/org/dependencytrack/model/VulnerabilityAlias.java index f734edd2f8..6260cdc309 100644 --- a/src/main/java/org/dependencytrack/model/VulnerabilityAlias.java +++ b/src/main/java/org/dependencytrack/model/VulnerabilityAlias.java @@ -34,6 +34,9 @@ import javax.jdo.annotations.Persistent; import javax.jdo.annotations.PrimaryKey; import javax.jdo.annotations.Unique; + +import org.apache.commons.lang3.StringUtils; + import java.io.Serializable; import java.util.Arrays; import java.util.Map; @@ -111,6 +114,20 @@ public class VulnerabilityAlias implements Serializable { @Pattern(regexp = RegexSequence.Definition.PRINTABLE_CHARS_PLUS, message = "The vulnDbId field may only contain printable characters") private String vulnDbId; + @Persistent + @Column(name = "DRUPAL_ID") + @Index(name = "VULNERABILITYALIAS_DRUPAL_ID_IDX") + @JsonDeserialize(using = TrimmedStringDeserializer.class) + @Pattern(regexp = RegexSequence.Definition.PRINTABLE_CHARS_PLUS, message = "The drupalId field may only contain printable characters") + private String drupalId; + + @Persistent + @Column(name = "COMPOSER_ID") + @Index(name = "VULNERABILITYALIAS_COMPOSER_ID_IDX") + @JsonDeserialize(using = TrimmedStringDeserializer.class) + @Pattern(regexp = RegexSequence.Definition.PRINTABLE_CHARS_PLUS, message = "The composerId field may only contain printable characters") + private String composerId; + @Persistent(customValueStrategy = "uuid") @Unique(name = "VULNERABILITYALIAS_UUID_IDX") @Column(name = "UUID", jdbcType = "VARCHAR", length = 36, allowsNull = "false") @@ -185,6 +202,22 @@ public void setVulnDbId(String vulnDbId) { this.vulnDbId = vulnDbId; } + public String getDrupalId() { + return drupalId; + } + + public void setDrupalId(String drupalId) { + this.drupalId = drupalId; + } + + public String getComposerId() { + return composerId; + } + + public void setComposerId(String composerId) { + this.composerId = composerId; + } + public UUID getUuid() { return uuid; } @@ -202,6 +235,8 @@ private String getBySource(final Vulnerability.Source source) { case OSV -> getOsvId(); case SNYK -> getSnykId(); case VULNDB -> getVulnDbId(); + case DRUPAL -> getDrupalId(); + case COMPOSER -> getComposerId(); default -> null; }; } @@ -224,6 +259,8 @@ public void copyFieldsFrom(VulnerabilityAlias other) { gsdId = firstNonNull(other.gsdId, gsdId); vulnDbId = firstNonNull(other.vulnDbId, vulnDbId); internalId = firstNonNull(other.internalId, internalId); + drupalId = firstNonNull(other.drupalId, drupalId); + composerId = firstNonNull(other.composerId, composerId); } private static String firstNonNull(String first, String second) { @@ -263,10 +300,71 @@ public int computeMatches(final VulnerabilityAlias other) { if (this.getVulnDbId() != null && this.getVulnDbId().equals(other.getVulnDbId())) { matches++; } + if (this.getDrupalId() != null && this.getDrupalId().equals(other.getDrupalId())) { + matches++; + } + if (this.getComposerId() != null && this.getComposerId().equals(other.getComposerId())) { + matches++; + } return matches; } + /** + * Compute how many identifiers are set for this alias record + * + * @return Number of identifiers set + */ + public int countIdentifiers() { + var count = 0; + count += this.getCveId() != null ? 1 : 0; + count += this.getGhsaId() != null ? 1 : 0; + count += this.getGsdId() != null ? 1 : 0; + count += this.getOsvId() != null ? 1 : 0; + count += this.getSnykId() != null ? 1 : 0; + count += this.getSonatypeId() != null ? 1 : 0; + count += this.getVulnDbId() != null ? 1 : 0; + count += this.getDrupalId() != null ? 1 : 0; + count += this.getComposerId() != null ? 1 : 0; + return count; + } + + public boolean setAliasFromVulnId(String vulnId) { + if (StringUtils.isBlank(vulnId)) return false; + + if (vulnId.startsWith("GHSA") && this.getGhsaId() == null) { + this.setGhsaId(vulnId); + return true; + } + if (vulnId.startsWith("CVE") && this.getCveId() == null) { + this.setCveId(vulnId); + return true; + } + if ((vulnId.startsWith("SA-CORE") || vulnId.startsWith("SA-CONTRIB")) && this.getDrupalId() == null) { + this.setDrupalId(vulnId); + return true; + } + if (vulnId.startsWith("OSSINDEX") && this.getSonatypeId() == null) { + this.setSonatypeId(vulnId); + return true; + } + if (vulnId.startsWith("SNYK") && this.getSnykId() == null) { + this.setSnykId(vulnId); + return true; + } + if (vulnId.startsWith("GSD") && this.getGsdId() == null) { + this.setGsdId(vulnId); + return true; + } + if (vulnId.startsWith("PKSA") && this.getComposerId() == null) { + this.setComposerId(vulnId); + return true; + } + + return false; + } + + @Override public String toString() { return "VulnerabilityAlias{" + @@ -279,8 +377,9 @@ public String toString() { ", snykId='" + snykId + '\'' + ", gsdId='" + gsdId + '\'' + ", vulnDbId='" + vulnDbId + '\'' + + ", composerId='" + composerId + '\'' + + ", drupalId='" + drupalId + '\'' + ", uuid=" + uuid + '}'; } - } diff --git a/src/main/java/org/dependencytrack/notification/NotificationConstants.java b/src/main/java/org/dependencytrack/notification/NotificationConstants.java index 83e78c5339..74cb6480d6 100644 --- a/src/main/java/org/dependencytrack/notification/NotificationConstants.java +++ b/src/main/java/org/dependencytrack/notification/NotificationConstants.java @@ -24,6 +24,7 @@ public static class Title { public static final String NOTIFICATION_TEST = "Notification Test"; public static final String NVD_MIRROR = "NVD Mirroring"; public static final String GITHUB_ADVISORY_MIRROR = "GitHub Advisory Mirroring"; + public static final String COMPOSER_ADVISORY_MIRROR = "Composer Advisory Mirroring"; public static final String EPSS_MIRROR = "EPSS Mirroring"; public static final String NPM_ADVISORY_MIRROR = "NPM Advisory Mirroring"; public static final String VULNDB_MIRROR = "VulnDB Mirroring"; diff --git a/src/main/java/org/dependencytrack/parser/composer/ComposerAdvisoryParser.java b/src/main/java/org/dependencytrack/parser/composer/ComposerAdvisoryParser.java new file mode 100644 index 0000000000..a6c164abb6 --- /dev/null +++ b/src/main/java/org/dependencytrack/parser/composer/ComposerAdvisoryParser.java @@ -0,0 +1,101 @@ +/* + * This file is part of Dependency-Track. + * + * 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. + * + * SPDX-License-Identifier: Apache-2.0 + * Copyright (c) OWASP Foundation. All Rights Reserved. + */ +package org.dependencytrack.parser.composer; + +import java.time.LocalDateTime; +import java.time.format.DateTimeFormatter; +import java.time.format.DateTimeParseException; +import java.util.ArrayList; +import java.util.List; + +import org.dependencytrack.parser.composer.model.ComposerAdvisory; +import org.json.JSONArray; +import org.json.JSONObject; + +import alpine.common.logging.Logger; + + +public class ComposerAdvisoryParser { + + private static final Logger LOGGER = Logger.getLogger(ComposerAdvisoryParser.class); + + public static List parseAdvisoryFeed(final JSONObject object) { + final List result = new ArrayList<>(); + final JSONObject advisories = object.optJSONObject("advisories"); + if (advisories != null) { + advisories.names().forEach(packageName -> { + final JSONArray advisory = advisories.optJSONArray((String)packageName); + if (advisory != null) { + for (int i = 0; i < advisory.length(); i++) { + final ComposerAdvisory composerAdvisory = parseAdvisory(advisory.getJSONObject(i)); + if (composerAdvisory != null) { + result.add(composerAdvisory); + } + } + + } + }); + } + return result; + } + + public static ComposerAdvisory parseAdvisory(final JSONObject object) { + final ComposerAdvisory vulnerability = new ComposerAdvisory(); + + //There's no status field in the advisory object, so we cannot check if the advisory has been withdrawn + vulnerability.setAdvisoryId(object.getString("advisoryId")); + vulnerability.setPackageName(object.optString("packageName", null)); + vulnerability.setRemoteId(object.optString("remoteId", null)); + vulnerability.setTitle(object.optString("title", null)); + vulnerability.setLink(object.optString("link", null)); + vulnerability.setCve(object.optString("cve", null)); + vulnerability.setAffectedVersionsCve(object.optString("affectedVersions", null)); + vulnerability.setSource(object.optString("source", null)); + + String reportedAtStr = object.optString("reportedAt", null); + try { + DateTimeFormatter formatter = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"); + if (reportedAtStr != null) { + LocalDateTime reportedAt = LocalDateTime.parse(reportedAtStr, formatter); + vulnerability.setReportedAt(reportedAt); + } + } catch (DateTimeParseException e) { + LOGGER.debug("Unabled to parse as LocalDateTime: " + reportedAtStr); + } + + vulnerability.setComposerRepository(object.optString("composerRepository", null)); + vulnerability.setSeverity(object.optString("severity", null)); + + //Some repositories like drupal use something weird as name that might not be unique + final JSONArray identifiers = object.optJSONArray("sources"); + if (identifiers != null) { + for (int i = 0; i < identifiers.length(); i++) { + final JSONObject identifier = identifiers.getJSONObject(i); + final String name = identifier.optString("name", null); + final String remoteId = identifier.optString("remoteId", null); + if (name != null && remoteId != null) { + vulnerability.addSource(name, remoteId); + } + } + } + + return vulnerability; + } + +} diff --git a/src/main/java/org/dependencytrack/parser/composer/model/ComposerAdvisory.java b/src/main/java/org/dependencytrack/parser/composer/model/ComposerAdvisory.java new file mode 100644 index 0000000000..3a05cb6331 --- /dev/null +++ b/src/main/java/org/dependencytrack/parser/composer/model/ComposerAdvisory.java @@ -0,0 +1,146 @@ +/* + * This file is part of Dependency-Track. + * + * 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. + * + * SPDX-License-Identifier: Apache-2.0 + * Copyright (c) OWASP Foundation. All Rights Reserved. + */ +package org.dependencytrack.parser.composer.model; + +import java.time.LocalDateTime; +import java.util.HashMap; +import java.util.Map; + +public class ComposerAdvisory { + + private String advisoryId; + private String packageName; + private String remoteId; + private String title; + private String link; + private String cve; + private String affectedVersions; + private String source; + private LocalDateTime reportedAt; + private String composerRepository; + private String severity; + private Map sources; + + public String getAdvisoryId() { + return advisoryId; + } + + public void setAdvisoryId(String advisoryId) { + this.advisoryId = advisoryId; + } + + public String getPackageName() { + return packageName; + } + + public void setPackageName(String packageName) { + this.packageName = packageName; + } + + public String getRemoteId() { + return remoteId; + } + + public void setRemoteId(String remoteId) { + this.remoteId = remoteId; + } + + public String getTitle() { + return title; + } + + public void setTitle(String title) { + this.title = title; + } + + public String getLink() { + return link; + } + + public void setLink(String link) { + this.link = link; + } + + public String getCve() { + return cve; + } + + public void setCve(String cve) { + this.cve = cve; + } + + public String getAffectedVersions() { + return affectedVersions; + } + + public void setAffectedVersionsCve(String affectedVersions) { + this.affectedVersions = affectedVersions; + } + + public String getSource() { + return source; + } + + public void setSource(String source) { + this.source = source; + } + + public LocalDateTime getReportedAt() { + return reportedAt; + } + + public void setReportedAt(LocalDateTime reportedAt) { + this.reportedAt = reportedAt; + } + + public String getComposerRepository() { + return composerRepository; + } + + public void setComposerRepository(String composerRepository) { + this.composerRepository = composerRepository; + } + + public String getSeverity() { + return severity; + } + + public void setSeverity(String severity) { + this.severity = severity; + } + + public Map getSources() { + if (sources == null) { + return new HashMap<>(); + } + return sources; + } + + public void addSource(String name, String remoteId) { + if (this.sources == null) { + this.sources = new HashMap<>(); + } + this.sources.put(name.toLowerCase(), remoteId); + } + + public String getPackageEcosystem() { + return "composer"; + } + +} diff --git a/src/main/java/org/dependencytrack/persistence/DefaultObjectGenerator.java b/src/main/java/org/dependencytrack/persistence/DefaultObjectGenerator.java index 1cc6729b57..b6e5b0bc2c 100644 --- a/src/main/java/org/dependencytrack/persistence/DefaultObjectGenerator.java +++ b/src/main/java/org/dependencytrack/persistence/DefaultObjectGenerator.java @@ -214,24 +214,25 @@ private List getBadgesPermissions(final List fullList) { public void loadDefaultRepositories() { try (QueryManager qm = new QueryManager()) { LOGGER.info("Synchronizing default repositories to datastore"); - qm.createRepository(RepositoryType.CPAN, "cpan-public-registry", "https://fastapi.metacpan.org/v1/", true, false, false, null, null); - qm.createRepository(RepositoryType.GEM, "rubygems.org", "https://rubygems.org/", true, false, false, null, null); - qm.createRepository(RepositoryType.HEX, "hex.pm", "https://hex.pm/", true, false, false, null, null); - qm.createRepository(RepositoryType.HACKAGE, "hackage.haskell.org", "https://hackage.haskell.org/", true, false, false, null, null); - qm.createRepository(RepositoryType.MAVEN, "central", "https://repo1.maven.org/maven2/", true, false, false, null, null); - qm.createRepository(RepositoryType.MAVEN, "atlassian-public", "https://packages.atlassian.com/content/repositories/atlassian-public/", true, false, false, null, null); - qm.createRepository(RepositoryType.MAVEN, "jboss-releases", "https://repository.jboss.org/nexus/content/repositories/releases/", true, false, false, null, null); - qm.createRepository(RepositoryType.MAVEN, "clojars", "https://repo.clojars.org/", true, false, false, null, null); - qm.createRepository(RepositoryType.MAVEN, "google-android", "https://maven.google.com/", true, false, false, null, null); - qm.createRepository(RepositoryType.NIXPKGS, "nixpkgs-unstable", "https://channels.nixos.org/nixpkgs-unstable/packages.json.br", true, false, false, null, null); - qm.createRepository(RepositoryType.NPM, "npm-public-registry", "https://registry.npmjs.org/", true, false, false, null, null); - qm.createRepository(RepositoryType.PYPI, "pypi.org", "https://pypi.org/", true, false, false, null, null); - qm.createRepository(RepositoryType.NUGET, "nuget-gallery", "https://api.nuget.org/", true, false, false, null, null); - qm.createRepository(RepositoryType.COMPOSER, "packagist", "https://repo.packagist.org/", true, false, false, null, null); - qm.createRepository(RepositoryType.CARGO, "crates.io", "https://crates.io", true, false, false, null, null); - qm.createRepository(RepositoryType.GO_MODULES, "proxy.golang.org", "https://proxy.golang.org", true, false, false, null, null); - qm.createRepository(RepositoryType.GITHUB, "github.com", "https://github.com", true, false, false, null, null); - } + qm.createRepository(RepositoryType.CPAN, "cpan-public-registry", null, "https://fastapi.metacpan.org/v1/", true, false, false, null, null, null); + qm.createRepository(RepositoryType.GEM, "rubygems.org", null, "https://rubygems.org/", true, false, false, null, null, null); + qm.createRepository(RepositoryType.HEX, "hex.pm", null, "https://hex.pm/", true, false, false, null, null, null); + qm.createRepository(RepositoryType.HACKAGE, "hackage.haskell.org", null, "https://hackage.haskell.org/", true, false, false, null, null, null); + qm.createRepository(RepositoryType.MAVEN, "central", null, "https://repo1.maven.org/maven2/", true, false, false, null, null, null); + qm.createRepository(RepositoryType.MAVEN, "atlassian-public", null, "https://packages.atlassian.com/content/repositories/atlassian-public/", true, false, false, null, null, null); + qm.createRepository(RepositoryType.MAVEN, "jboss-releases", null, "https://repository.jboss.org/nexus/content/repositories/releases/", true, false, false, null, null, null); + qm.createRepository(RepositoryType.MAVEN, "clojars", null, "https://repo.clojars.org/", true, false, false, null, null, null); + qm.createRepository(RepositoryType.MAVEN, "google-android", null, "https://maven.google.com/", true, false, false, null, null, null); + qm.createRepository(RepositoryType.NIXPKGS, "nixpkgs-unstable", null, "https://channels.nixos.org/nixpkgs-unstable/packages.json.br", true, false, false, null, null, null); + qm.createRepository(RepositoryType.NPM, "npm-public-registry", null, "https://registry.npmjs.org/", true, false, false, null, null, null); + qm.createRepository(RepositoryType.PYPI, "pypi.org", null, "https://pypi.org/", true, false, false, null, null, null); + qm.createRepository(RepositoryType.NUGET, "nuget-gallery", null, "https://api.nuget.org/", true, false, false, null, null, null); + qm.createRepository(RepositoryType.COMPOSER, "packagist", null, "https://repo.packagist.org/", true, false, false, null, null, null); + qm.createRepository(RepositoryType.COMPOSER, "drupal8", null, "https://packages.drupal.org/8", false, false, false, null, null, null); + qm.createRepository(RepositoryType.CARGO, "crates.io", null, "https://crates.io", true, false, false, null, null, null); + qm.createRepository(RepositoryType.GO_MODULES, "proxy.golang.org", null, "https://proxy.golang.org", true, false, false, null, null, null); + qm.createRepository(RepositoryType.GITHUB, "github.com", null, "https://github.com", true, false, false, null, null, null); + } } /** diff --git a/src/main/java/org/dependencytrack/persistence/QueryManager.java b/src/main/java/org/dependencytrack/persistence/QueryManager.java index eeb5637615..a1924b8bb4 100644 --- a/src/main/java/org/dependencytrack/persistence/QueryManager.java +++ b/src/main/java/org/dependencytrack/persistence/QueryManager.java @@ -1198,6 +1198,10 @@ public PaginatedResult getRepositories() { return getRepositoryQueryManager().getRepositories(); } + public Repository getRepository(String identifier) { + return getRepositoryQueryManager().getRepository(identifier); + } + public List getAllRepositories() { return getRepositoryQueryManager().getAllRepositories(); } @@ -1214,12 +1218,12 @@ public boolean repositoryExist(RepositoryType type, String identifier) { return getRepositoryQueryManager().repositoryExist(type, identifier); } - public Repository createRepository(RepositoryType type, String identifier, String url, boolean enabled, boolean internal, boolean isAuthenticationRequired, String username, String password) { - return getRepositoryQueryManager().createRepository(type, identifier, url, enabled, internal, isAuthenticationRequired, username, password); + public Repository createRepository(RepositoryType type, String identifier, String description, String url, boolean enabled, boolean internal, boolean isAuthenticationRequired, String username, String password, String config) { + return getRepositoryQueryManager().createRepository(type, identifier, description, url, enabled, internal, isAuthenticationRequired, username, password, config); } - public Repository updateRepository(UUID uuid, String identifier, String url, boolean internal, boolean authenticationRequired, String username, String password, boolean enabled) { - return getRepositoryQueryManager().updateRepository(uuid, identifier, url, internal, authenticationRequired, username, password, enabled); + public Repository updateRepository(UUID uuid, String identifier, String description, String url, boolean internal, boolean authenticationRequired, String username, String password, boolean enabled, String config) { + return getRepositoryQueryManager().updateRepository(uuid, identifier, description, url, internal, authenticationRequired, username, password, enabled, config); } public RepositoryMetaComponent getRepositoryMetaComponent(RepositoryType repositoryType, String namespace, String name) { diff --git a/src/main/java/org/dependencytrack/persistence/RepositoryQueryManager.java b/src/main/java/org/dependencytrack/persistence/RepositoryQueryManager.java index 59094731f3..27615b91a5 100644 --- a/src/main/java/org/dependencytrack/persistence/RepositoryQueryManager.java +++ b/src/main/java/org/dependencytrack/persistence/RepositoryQueryManager.java @@ -60,7 +60,6 @@ public class RepositoryQueryManager extends QueryManager implements IQueryManage super(pm, request); } - /** * Returns a list of all repositories. * @@ -133,10 +132,11 @@ public boolean repositoryExist(RepositoryType type, String identifier) { } /** - * Creates a new Repository. + * Creates a new Repository. Unless there alreaady is an existing Repository with the same type and identifier. * * @param type the type of repository * @param identifier a unique (to the type) identifier for the repo + * @param description description of the repository * @param url the URL to the repository * @param enabled if the repo is enabled or not * @param internal if the repo is internal or not @@ -145,7 +145,7 @@ public boolean repositoryExist(RepositoryType type, String identifier) { * @param password the password to access the (authenticated) repository with * @return the created Repository */ - public Repository createRepository(RepositoryType type, String identifier, String url, boolean enabled, boolean internal, boolean isAuthenticationRequired, String username, String password) { + public Repository createRepository(RepositoryType type, String identifier, String description, String url, boolean enabled, boolean internal, boolean isAuthenticationRequired, String username, String password, String config) { if (repositoryExist(type, identifier)) { return null; } @@ -161,6 +161,7 @@ public Repository createRepository(RepositoryType type, String identifier, Strin final Repository repo = new Repository(); repo.setType(type); repo.setIdentifier(identifier); + repo.setDescription(description); repo.setUrl(url); repo.setResolutionOrder(order + 1); repo.setEnabled(enabled); @@ -176,6 +177,7 @@ public Repository createRepository(RepositoryType type, String identifier, Strin LOGGER.error("An error occurred while saving password in encrypted state"); } } + repo.setConfig(config); return persist(repo); } @@ -184,6 +186,7 @@ public Repository createRepository(RepositoryType type, String identifier, Strin * * @param uuid the uuid of the repository to update * @param identifier the identifier of the repository + * @param description description of the repository * @param url a url of the repository * @param internal specifies if the repository is internal * @param authenticationRequired if the repository needs authentication or not @@ -192,9 +195,10 @@ public Repository createRepository(RepositoryType type, String identifier, Strin * @param enabled specifies if the repository is enabled * @return the updated Repository */ - public Repository updateRepository(UUID uuid, String identifier, String url, boolean internal, boolean authenticationRequired, String username, String password, boolean enabled) { + public Repository updateRepository(UUID uuid, String identifier, String description, String url, boolean internal, boolean authenticationRequired, String username, String password, boolean enabled, String config) { final Repository repository = getObjectByUuid(Repository.class, uuid); repository.setIdentifier(identifier); + repository.setDescription(description); repository.setUrl(url); repository.setInternal(internal); repository.setAuthenticationRequired(authenticationRequired); @@ -207,6 +211,7 @@ public Repository updateRepository(UUID uuid, String identifier, String url, boo } repository.setEnabled(enabled); + repository.setConfig(config); return persist(repository); } diff --git a/src/main/java/org/dependencytrack/persistence/VulnerabilityQueryManager.java b/src/main/java/org/dependencytrack/persistence/VulnerabilityQueryManager.java index d9f0dc1668..e1203796b5 100644 --- a/src/main/java/org/dependencytrack/persistence/VulnerabilityQueryManager.java +++ b/src/main/java/org/dependencytrack/persistence/VulnerabilityQueryManager.java @@ -626,6 +626,25 @@ public synchronized VulnerabilityAlias synchronizeVulnerabilityAlias(final Vulne mustMatchAnyFilter += "vulnDbId != null"; params.put("vulnDbId", alias.getVulnDbId()); } + if (alias.getDrupalId() != null) { + if (filter.length() > 0) { + filter += " && "; + mustMatchAnyFilter += " || "; + } + filter += "(drupalId == :drupalId || drupalId == null)"; + mustMatchAnyFilter += "drupalId != null"; + params.put("drupalId", alias.getDrupalId()); + } + if (alias.getComposerId() != null) { + if (filter.length() > 0) { + filter += " && "; + mustMatchAnyFilter += " || "; + } + filter += "(composerId == :composerId || composerId == null)"; + mustMatchAnyFilter += "composerId != null"; + params.put("composerId", alias.getComposerId()); + } + if (alias.getInternalId() != null) { if (filter.length() > 0) { filter += " && "; @@ -676,6 +695,10 @@ public List getVulnerabilityAliases(Vulnerability vulnerabil query = pm.newQuery(VulnerabilityAlias.class, "snykId == :snykId"); } else if (Vulnerability.Source.VULNDB.name().equals(vulnerability.getSource())) { query = pm.newQuery(VulnerabilityAlias.class, "vulnDbId == :vulnDb"); + } else if (Vulnerability.Source.DRUPAL.name().equals(vulnerability.getSource())) { + query = pm.newQuery(VulnerabilityAlias.class, "drupalId == :drupalId"); + } else if (Vulnerability.Source.COMPOSER.name().equals(vulnerability.getSource())) { + query = pm.newQuery(VulnerabilityAlias.class, "composerId == :composerId"); } else { query = pm.newQuery(VulnerabilityAlias.class, "internalId == :internalId"); } @@ -711,6 +734,8 @@ public Map> getVulnerabilityAliases(fi case OSV -> "\"OSV_ID\" = :" + vulnIdParamName; case SNYK -> "\"SNYK_ID\" = :" + vulnIdParamName; case VULNDB -> "\"VULNDB_ID\" = :" + vulnIdParamName; + case DRUPAL -> "\"DRUPAL_ID\" = :" + vulnIdParamName; + case COMPOSER -> "\"COMPOSER_ID\" = :" + vulnIdParamName; default -> null; }; if (filter == null) { @@ -731,6 +756,8 @@ public Map> getVulnerabilityAliases(fi , "OSV_ID" , "SNYK_ID" , "VULNDB_ID" + , "DRUPAL_ID" + , "COMPOSER_ID" FROM "VULNERABILITYALIAS" WHERE %s """.formatted(vulnIdAndSource.hashCode(), filter)); @@ -758,6 +785,8 @@ public Map> getVulnerabilityAliases(fi alias.setOsvId((String) row[5]); alias.setSnykId((String) row[6]); alias.setVulnDbId((String) row[7]); + alias.setDrupalId((String) row[8]); + alias.setComposerId((String) row[9]); return Map.entry(vulnIdAndSource, alias); }) .collect(Collectors.groupingBy( diff --git a/src/main/java/org/dependencytrack/resources/v1/RepositoryResource.java b/src/main/java/org/dependencytrack/resources/v1/RepositoryResource.java index bc7d1077e2..28dfa4f8c4 100644 --- a/src/main/java/org/dependencytrack/resources/v1/RepositoryResource.java +++ b/src/main/java/org/dependencytrack/resources/v1/RepositoryResource.java @@ -194,11 +194,13 @@ public Response createRepository(Repository jsonRepository) { final Repository repository = qm.createRepository( jsonRepository.getType(), StringUtils.trimToNull(jsonRepository.getIdentifier()), + StringUtils.trimToNull(jsonRepository.getDescription()), StringUtils.trimToNull(jsonRepository.getUrl()), jsonRepository.isEnabled(), jsonRepository.isInternal(), jsonRepository.isAuthenticationRequired(), - jsonRepository.getUsername(), jsonRepository.getPassword()); + jsonRepository.getUsername(), jsonRepository.getPassword(), + jsonRepository.getConfig()); return Response.status(Response.Status.CREATED).entity(repository).build(); } else { @@ -240,8 +242,8 @@ public Response updateRepository(Repository jsonRepository) { ? DataEncryption.encryptAsString(jsonRepository.getPassword()) : repository.getPassword(); - repository = qm.updateRepository(jsonRepository.getUuid(), repository.getIdentifier(), url, - jsonRepository.isInternal(), jsonRepository.isAuthenticationRequired(), jsonRepository.getUsername(), updatedPassword, jsonRepository.isEnabled()); + repository = qm.updateRepository(jsonRepository.getUuid(), repository.getIdentifier(), jsonRepository.getDescription(), url, + jsonRepository.isInternal(), jsonRepository.isAuthenticationRequired(), jsonRepository.getUsername(), updatedPassword, jsonRepository.isEnabled(), jsonRepository.getConfig()); return Response.ok(repository).build(); } catch (Exception e) { return Response.status(Response.Status.INTERNAL_SERVER_ERROR).entity("The specified repository password could not be encrypted.").build(); diff --git a/src/main/java/org/dependencytrack/tasks/TaskScheduler.java b/src/main/java/org/dependencytrack/tasks/TaskScheduler.java index 68d66b633c..0991a0d717 100644 --- a/src/main/java/org/dependencytrack/tasks/TaskScheduler.java +++ b/src/main/java/org/dependencytrack/tasks/TaskScheduler.java @@ -25,6 +25,7 @@ import alpine.model.IConfigProperty.PropertyType; import alpine.server.tasks.AlpineTaskScheduler; import org.dependencytrack.event.ClearComponentAnalysisCacheEvent; +import org.dependencytrack.event.ComposerAdvisoryMirrorEvent; import org.dependencytrack.event.DefectDojoUploadEventAbstract; import org.dependencytrack.event.FortifySscUploadEventAbstract; import org.dependencytrack.event.GitHubAdvisoryMirrorEvent; @@ -50,6 +51,7 @@ import static org.dependencytrack.model.ConfigPropertyConstants.KENNA_ENABLED; import static org.dependencytrack.model.ConfigPropertyConstants.KENNA_SYNC_CADENCE; import static org.dependencytrack.model.ConfigPropertyConstants.TASK_SCHEDULER_COMPONENT_ANALYSIS_CACHE_CLEAR_CADENCE; +import static org.dependencytrack.model.ConfigPropertyConstants.TASK_SCHEDULER_COMPOSER_MIRROR_CADENCE; import static org.dependencytrack.model.ConfigPropertyConstants.TASK_SCHEDULER_GHSA_MIRROR_CADENCE; import static org.dependencytrack.model.ConfigPropertyConstants.TASK_SCHEDULER_INTERNAL_COMPONENT_IDENTIFICATION_CADENCE; import static org.dependencytrack.model.ConfigPropertyConstants.TASK_SCHEDULER_LDAP_SYNC_CADENCE; @@ -71,44 +73,47 @@ public final class TaskScheduler extends AlpineTaskScheduler { // Holds an instance of TaskScheduler private static final TaskScheduler INSTANCE = new TaskScheduler(); + /** + * Private constructor. + */ + private TaskScheduler() { + try (QueryManager qm = new QueryManager()) { + // Creates a new event that executes every 6 hours (21600000) by default after an initial 10 second (10000) delay + scheduleEvent(new LdapSyncEvent(), 10000, getCadenceConfigPropertyValueInMilliseconds(qm, TASK_SCHEDULER_LDAP_SYNC_CADENCE)); - /** - * Private constructor. - */ - private TaskScheduler() { - try (QueryManager qm = new QueryManager()) { - // Creates a new event that executes every 6 hours (21600000) by default after an initial 10 second (10000) delay - scheduleEvent(new LdapSyncEvent(), 10000, getCadenceConfigPropertyValueInMilliseconds(qm, TASK_SCHEDULER_LDAP_SYNC_CADENCE)); + // Creates a new event that executes every 24 hours (86400000) by default after an initial 10 second (10000) delay + scheduleEvent(new GitHubAdvisoryMirrorEvent(), 10000, getCadenceConfigPropertyValueInMilliseconds(qm, TASK_SCHEDULER_GHSA_MIRROR_CADENCE)); + + // Creates a new event that executes every 24 hours (86400000) by default after an initial 10 second (10000) delay + scheduleEvent(new OsvMirrorEvent(), 10000, getCadenceConfigPropertyValueInMilliseconds(qm, TASK_SCHEDULER_OSV_MIRROR_CADENCE)); - // Creates a new event that executes every 24 hours (86400000) by default after an initial 10 second (10000) delay - scheduleEvent(new GitHubAdvisoryMirrorEvent(), 10000, getCadenceConfigPropertyValueInMilliseconds(qm, TASK_SCHEDULER_GHSA_MIRROR_CADENCE)); + // Creates a new event that executes every 24 hours (86400000) by default after an initial 1 minute (60000) delay + scheduleEvent(new NistMirrorEvent(), 60000, getCadenceConfigPropertyValueInMilliseconds(qm, TASK_SCHEDULER_NIST_MIRROR_CADENCE)); - // Creates a new event that executes every 24 hours (86400000) by default after an initial 10 second (10000) delay - scheduleEvent(new OsvMirrorEvent(), 10000, getCadenceConfigPropertyValueInMilliseconds(qm, TASK_SCHEDULER_OSV_MIRROR_CADENCE)); + // Creates a new event that executes every 24 hours (86400000) by default after an initial 1 minute (60000) delay + scheduleEvent(new VulnDbSyncEvent(), 60000, getCadenceConfigPropertyValueInMilliseconds(qm, TASK_SCHEDULER_VULNDB_MIRROR_CADENCE)); - // Creates a new event that executes every 24 hours (86400000) by default after an initial 1 minute (60000) delay - scheduleEvent(new NistMirrorEvent(), 60000, getCadenceConfigPropertyValueInMilliseconds(qm, TASK_SCHEDULER_NIST_MIRROR_CADENCE)); + // Creates a new event that executes every 1 hour (3600000) by default after an initial 10 second (10000) delay + scheduleEvent(new PortfolioMetricsUpdateEvent(), 10000, getCadenceConfigPropertyValueInMilliseconds(qm, TASK_SCHEDULER_PORTFOLIO_METRICS_UPDATE_CADENCE)); - // Creates a new event that executes every 24 hours (86400000) by default after an initial 1 minute (60000) delay - scheduleEvent(new VulnDbSyncEvent(), 60000, getCadenceConfigPropertyValueInMilliseconds(qm, TASK_SCHEDULER_VULNDB_MIRROR_CADENCE)); + // Creates a new event that executes every 1 hour (3600000) by default after an initial 10 second (10000) delay + scheduleEvent(new VulnerabilityMetricsUpdateEvent(), 10000, getCadenceConfigPropertyValueInMilliseconds(qm, TASK_SCHEDULER_VULNERABILITY_METRICS_UPDATE_CADENCE)); - // Creates a new event that executes every 1 hour (3600000) by default after an initial 10 second (10000) delay - scheduleEvent(new PortfolioMetricsUpdateEvent(), 10000, getCadenceConfigPropertyValueInMilliseconds(qm, TASK_SCHEDULER_PORTFOLIO_METRICS_UPDATE_CADENCE)); + // Creates a new event that executes every 24 hours (86400000) by default after an initial 6 hour (21600000) delay + scheduleEvent(new PortfolioVulnerabilityAnalysisEvent(), 21600000, getCadenceConfigPropertyValueInMilliseconds(qm, TASK_SCHEDULER_PORTFOLIO_VULNERABILITY_ANALYSIS_CADENCE)); - // Creates a new event that executes every 1 hour (3600000) by default after an initial 10 second (10000) delay - scheduleEvent(new VulnerabilityMetricsUpdateEvent(), 10000, getCadenceConfigPropertyValueInMilliseconds(qm, TASK_SCHEDULER_VULNERABILITY_METRICS_UPDATE_CADENCE)); + // Creates a new event that executes every 24 hours (86400000) by default after an initial 1 hour (3600000) delay + scheduleEvent(new RepositoryMetaEvent(), 3600000, getCadenceConfigPropertyValueInMilliseconds(qm, TASK_SCHEDULER_REPOSITORY_METADATA_FETCH_CADENCE)); - // Creates a new event that executes every 24 hours (86400000) by default after an initial 6 hour (21600000) delay - scheduleEvent(new PortfolioVulnerabilityAnalysisEvent(), 21600000, getCadenceConfigPropertyValueInMilliseconds(qm, TASK_SCHEDULER_PORTFOLIO_VULNERABILITY_ANALYSIS_CADENCE)); + // Creates a new event that executes every 6 hours (21600000) by default after an initial 1 hour (3600000) delay + scheduleEvent(new InternalComponentIdentificationEvent(), 3600000, getCadenceConfigPropertyValueInMilliseconds(qm, TASK_SCHEDULER_INTERNAL_COMPONENT_IDENTIFICATION_CADENCE)); - // Creates a new event that executes every 24 hours (86400000) by default after an initial 1 hour (3600000) delay - scheduleEvent(new RepositoryMetaEvent(), 3600000, getCadenceConfigPropertyValueInMilliseconds(qm, TASK_SCHEDULER_REPOSITORY_METADATA_FETCH_CADENCE)); + // Creates a new event that executes every 72 hours (259200000) by default after an initial 10 second (10000) delay + scheduleEvent(new ClearComponentAnalysisCacheEvent(), 10000, getCadenceConfigPropertyValueInMilliseconds(qm, TASK_SCHEDULER_COMPONENT_ANALYSIS_CACHE_CLEAR_CADENCE)); - // Creates a new event that executes every 6 hours (21600000) by default after an initial 1 hour (3600000) delay - scheduleEvent(new InternalComponentIdentificationEvent(), 3600000, getCadenceConfigPropertyValueInMilliseconds(qm, TASK_SCHEDULER_INTERNAL_COMPONENT_IDENTIFICATION_CADENCE)); + // Creates a new event that executes every 24 hours (86400000) by default after an initial 10 second (10000) delay + scheduleEvent(new ComposerAdvisoryMirrorEvent(), 10000, getCadenceConfigPropertyValueInMilliseconds(qm, TASK_SCHEDULER_COMPOSER_MIRROR_CADENCE)); - // Creates a new event that executes every 72 hours (259200000) by default after an initial 10 second (10000) delay - scheduleEvent(new ClearComponentAnalysisCacheEvent(), 10000, getCadenceConfigPropertyValueInMilliseconds(qm, TASK_SCHEDULER_COMPONENT_ANALYSIS_CACHE_CLEAR_CADENCE)); } // Configurable tasks diff --git a/src/main/java/org/dependencytrack/tasks/repositories/ComposerAdvisoryMirrorTask.java b/src/main/java/org/dependencytrack/tasks/repositories/ComposerAdvisoryMirrorTask.java new file mode 100644 index 0000000000..74357769bd --- /dev/null +++ b/src/main/java/org/dependencytrack/tasks/repositories/ComposerAdvisoryMirrorTask.java @@ -0,0 +1,424 @@ +/* + * This file is part of Dependency-Track. + * + * 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. + * + * SPDX-License-Identifier: Apache-2.0 + * Copyright (c) OWASP Foundation. All Rights Reserved. + */ +package org.dependencytrack.tasks.repositories; + +import java.time.ZoneOffset; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Date; +import java.util.EnumSet; +import java.util.List; +import java.util.Map.Entry; + +import org.apache.commons.lang3.StringUtils; +import org.dependencytrack.event.ComposerAdvisoryMirrorEvent; +import org.dependencytrack.event.IndexEvent; +import org.dependencytrack.model.Repository; +import org.dependencytrack.model.RepositoryType; +import org.dependencytrack.model.Severity; +import org.dependencytrack.model.Vulnerability; +import org.dependencytrack.model.Vulnerability.Source; +import org.dependencytrack.model.VulnerabilityAlias; +import org.dependencytrack.model.VulnerableSoftware; +import org.dependencytrack.notification.NotificationConstants; +import org.dependencytrack.notification.NotificationGroup; +import org.dependencytrack.notification.NotificationScope; +import org.dependencytrack.parser.composer.ComposerAdvisoryParser; +import org.dependencytrack.parser.composer.model.ComposerAdvisory; +import org.dependencytrack.persistence.QueryManager; +import org.json.JSONObject; + +import com.github.packageurl.MalformedPackageURLException; +import com.github.packageurl.PackageURL; +import com.github.packageurl.PackageURLBuilder; + +import alpine.common.logging.Logger; +import alpine.event.framework.Event; +import alpine.event.framework.LoggableSubscriber; +import alpine.notification.Notification; +import alpine.notification.NotificationLevel; + +public class ComposerAdvisoryMirrorTask implements LoggableSubscriber { + + private static final Logger LOGGER = Logger.getLogger(ComposerAdvisoryMirrorTask.class); + + private boolean mirroredWithoutErrors = true; + + /** + * {@inheritDoc} + */ + public void inform(final Event e) { + if (e instanceof ComposerAdvisoryMirrorEvent) { + final long start = System.currentTimeMillis(); + LOGGER.info("Starting Composer Advisory mirroring task"); + + try (final var qm = new QueryManager()) { + for (final Repository repository : qm.getAllRepositoriesOrdered(RepositoryType.COMPOSER)) { + // Should we try catch all exceptions to make sure notification is sent? + mirroredWithoutErrors &= mirrorAdvisories(qm, repository); + } + } + + final long end = System.currentTimeMillis(); + LOGGER.info("Composer Advisory mirroring complete"); + LOGGER.info("Time spent (total): " + (end - start) + "ms"); + + if (mirroredWithoutErrors) { + Notification.dispatch(new Notification() + .scope(NotificationScope.SYSTEM) + .group(NotificationGroup.DATASOURCE_MIRRORING) + .title(NotificationConstants.Title.COMPOSER_ADVISORY_MIRROR) + .content("Mirroring of Composer Advisories completed successfully") + .level(NotificationLevel.INFORMATIONAL)); + } else { + Notification.dispatch(new Notification() + .scope(NotificationScope.SYSTEM) + .group(NotificationGroup.DATASOURCE_MIRRORING) + .title(NotificationConstants.Title.COMPOSER_ADVISORY_MIRROR) + .content( + "An error occurred mirroring the contents of Composer Advisories. Check log for details.") + .level(NotificationLevel.ERROR)); + } + } + Event.dispatch(new IndexEvent(IndexEvent.Action.COMMIT, Vulnerability.class)); + } + + protected boolean mirrorAdvisories(QueryManager qm, Repository repository) { + if (!repository.isEnabled()) { + return true; + } + boolean isAdvisoryMirroringEnabled = false; + boolean isAliasSyncEnabled = false; + + if (repository.getConfig() != null) { + final JSONObject config = new JSONObject(repository.getConfig()); + isAdvisoryMirroringEnabled = config + .optBoolean("advisoryMirroringEnabled", false); + isAliasSyncEnabled = config + .optBoolean("advisoryAliasSyncEnabled", true); + if (!isAdvisoryMirroringEnabled) { + LOGGER.info( + "Advisory mirroring is disabled for repository " + repository.getUrl()); + } + } + + boolean result = true; + // Vulnerability mirroring builds on the Composer meta analyzer + // To avoid duplicating lots of code or having to extract alle common parts and + // error handling, we just create an analyzer and let it do the work + ComposerMetaAnalyzer composerMetaAnalyzer = new ComposerMetaAnalyzer(); + composerMetaAnalyzer.setRepositoryId(repository.getIdentifier()); + composerMetaAnalyzer.setRepositoryBaseUrl(repository.getUrl()); + composerMetaAnalyzer.setRepositoryUsernameAndPassword(repository.getUsername(), repository.getPassword()); + + LOGGER.info("Mirorring Composer Advisories from " + repository.getUrl()); + JSONObject jsonAdvisories = composerMetaAnalyzer.retrieveAdvisories(); + + if (jsonAdvisories == null) { + return false; + } + + final List composerAdvisories = ComposerAdvisoryParser.parseAdvisoryFeed(jsonAdvisories); + for (final ComposerAdvisory advisory : composerAdvisories) { + result &= processAdvisory(qm, advisory, isAliasSyncEnabled); + } + + return result; + } + + /** + * Synchronizes the Composer Advisories to the database. + * @param qm + * @param advisories the advisories to synchronize + * @param syncAliases + */ + boolean processAdvisory(QueryManager qm, final ComposerAdvisory advisory, boolean syncAliases) { + LOGGER.debug("Synchronizing Composer advisory: " + advisory.getAdvisoryId()); + + final Vulnerability mappedVulnerability = mapComposerAdvisoryToVulnerability(advisory, syncAliases); + final List vsListOld = qm.detach(qm.getVulnerableSoftwareByVulnId( + mappedVulnerability.getSource(), mappedVulnerability.getVulnId())); + + final Vulnerability existingVulnerability = qm.getVulnerabilityByVulnId(mappedVulnerability.getSource(), + mappedVulnerability.getVulnId()); + + final Vulnerability.Source vulnerabilitySource = Vulnerability.Source + .valueOf(mappedVulnerability.getSource()); + + Vulnerability synchronizedVulnerability = existingVulnerability; + if (shouldUpdateExistingVulnerability(existingVulnerability, vulnerabilitySource)) { + synchronizedVulnerability = qm.synchronizeVulnerability(mappedVulnerability, false); + if (synchronizedVulnerability == null) + return true; + } + + if (syncAliases && mappedVulnerability.getAliases() != null && mappedVulnerability.getAliases().size() > 0) { + for (VulnerabilityAlias alias : mappedVulnerability.getAliases()) { + qm.synchronizeVulnerabilityAlias(alias); + } + } + + LOGGER.debug("Updating vulnerable software for advisory: " + advisory.getAdvisoryId()); + List vsList = mapVulnerabilityToVulnerableSoftware(qm, advisory); + qm.persist(vsList); + final Vulnerability finalSynchronizedVulnerability = synchronizedVulnerability; + final Vulnerability.Source attributionSource = vulnerabilitySource == Vulnerability.Source.DRUPAL? Vulnerability.Source.DRUPAL : Vulnerability.Source.COMPOSER; + vsList.forEach(vs -> qm.updateAffectedVersionAttribution(finalSynchronizedVulnerability, vs, + attributionSource)); + vsList = qm.reconcileVulnerableSoftware(synchronizedVulnerability, vsListOld, vsList, + attributionSource); + synchronizedVulnerability.setVulnerableSoftware(vsList); + qm.persist(synchronizedVulnerability); + return true; + } + + private boolean shouldUpdateExistingVulnerability(Vulnerability existingVulnerability, + Vulnerability.Source vulnerabilitySource) { + /* + * Compose Advisories can have their own Id (PKSA-xxxx-yyy) or an Id from an + * authoritive source (CVE-xxxx-yyy, GHSA-xxxx-yyy, ...) + * I haven't seen any Composer Advisory with a CVE or GHSA id, but it is + * possible. + * Make sure that we don't overwrite data of the authoritative source + * Similar to what is done for Osv Mirroring + * Please note that Drupal is also considered authoritative source, but provided + * by Composer here in this task + */ + return (EnumSet.of(Vulnerability.Source.COMPOSER, Vulnerability.Source.DRUPAL).contains(vulnerabilitySource)) + || (existingVulnerability == null); + } + + public static String extractVulnId(ComposerAdvisory composerAdvisory) { + // Currently seen DRUPAL and COMPOSER, but for composer we need to look for other fields + Source sourceFromId = Vulnerability.Source.resolve(composerAdvisory.getAdvisoryId()); + if (sourceFromId != null && !EnumSet.of(Vulnerability.Source.UNKNOWN, Vulnerability.Source.COMPOSER).contains(sourceFromId)) { + return composerAdvisory.getAdvisoryId(); + } + + // Currently only used for GHSA and pointers to Friends Of PHP advisories, which is not a valid source + Source sourceFromRemoteId = Vulnerability.Source.resolve(composerAdvisory.getRemoteId()); + if (sourceFromRemoteId != null && sourceFromRemoteId != Vulnerability.Source.UNKNOWN) { + return composerAdvisory.getRemoteId(); + } + + // Some Advisories from Friends Of PHP have a GHSA, which is leading + for (String possibleAlias : composerAdvisory.getSources().values()) { + Source sourceFromPossibleAlias = Vulnerability.Source.resolve(possibleAlias); + if (sourceFromPossibleAlias != null && sourceFromPossibleAlias != Vulnerability.Source.UNKNOWN) { + return possibleAlias; + } + } + + // Use CVE, but ensure it's valid. You never know with these Composer repositories + Source sourceFromCve = Vulnerability.Source.resolve(composerAdvisory.getCve()); + if (sourceFromCve != null && sourceFromCve == Vulnerability.Source.NVD) { + return composerAdvisory.getCve(); + } + + // Wordt case will result in a PKSA as id, similar to OSV. + return composerAdvisory.getAdvisoryId(); + } + + protected Vulnerability mapComposerAdvisoryToVulnerability(final ComposerAdvisory composerAdvisory, final boolean syncAliases) { + final Vulnerability vuln = new Vulnerability(); + + vuln.setVulnId(extractVulnId(composerAdvisory)); + vuln.setSource(Vulnerability.Source.resolve(vuln.getVulnId())); + + String description = composerAdvisory.getTitle() + " in " + composerAdvisory.getPackageName() + " " + + composerAdvisory.getAffectedVersions() + "\n\n"; + List references = new ArrayList<>(); + references.add(composerAdvisory.getLink()); + for (Entry source : composerAdvisory.getSources().entrySet()) { + if (source.getKey().equalsIgnoreCase("github")) { + references.add("https://github.com/advisories/" + source.getValue()); + } else if (source.getKey().equalsIgnoreCase("friendsofphp/security-advisories")) { + references.add("https://github.com/FriendsOfPHP/security-advisories/blob/master/" + source.getValue()); + } else { + description += "\nUnmapped source: " + source.getKey() + " : " + source.getValue() + "\n"; + } + } + references.add(composerAdvisory.getComposerRepository()); + + if (!references.isEmpty()) { + final StringBuilder sb = new StringBuilder(); + for (String ref : references) { + // Convert reference to Markdown format; + sb.append("* [").append(ref).append("](").append(ref).append(")\n"); + } + vuln.setReferences(sb.toString()); + } + + vuln.setDescription(description); + vuln.setTitle(StringUtils.abbreviate(composerAdvisory.getTitle(), "...", 255)); + if (composerAdvisory.getReportedAt() != null) { + vuln.setPublished(Date.from(composerAdvisory.getReportedAt().toInstant(ZoneOffset.UTC))); + // Should we leave Updated null? + vuln.setUpdated(Date.from(composerAdvisory.getReportedAt().toInstant(ZoneOffset.UTC))); + } + + if (composerAdvisory.getAffectedVersions() != null + && composerAdvisory.getAffectedVersions().length() > 255) { + // https://github.com/DependencyTrack/dependency-track/issues/4512 + LOGGER.warn("Affected versions for " + composerAdvisory.getAdvisoryId() + + " is too long. Truncating to 255 characters."); + vuln.setVulnerableVersions(StringUtils.abbreviate(composerAdvisory.getAffectedVersions(), "...", 255)); + } else { + vuln.setVulnerableVersions(composerAdvisory.getAffectedVersions()); + } + + if (composerAdvisory.getSeverity() != null) { + if (composerAdvisory.getSeverity().equalsIgnoreCase("CRITICAL")) { + vuln.setSeverity(Severity.CRITICAL); + } else if (composerAdvisory.getSeverity().equalsIgnoreCase("HIGH")) { + vuln.setSeverity(Severity.HIGH); + } else if (composerAdvisory.getSeverity().equalsIgnoreCase("MEDIUM")) { + vuln.setSeverity(Severity.MEDIUM); + } else if (composerAdvisory.getSeverity().equalsIgnoreCase("LOW")) { + vuln.setSeverity(Severity.LOW); + } else { + vuln.setSeverity(Severity.UNASSIGNED); + } + } else { + vuln.setSeverity(Severity.UNASSIGNED); + } + + if (syncAliases) { + VulnerabilityAlias alias = new VulnerabilityAlias(); + alias.setAliasFromVulnId(vuln.getVulnId()); + alias.setAliasFromVulnId(composerAdvisory.getCve()); + alias.setAliasFromVulnId(composerAdvisory.getRemoteId()); + for (String possibleAlias : composerAdvisory.getSources().values()) { + alias.setAliasFromVulnId(possibleAlias); + } + + if (alias.countIdentifiers() > 1) { + vuln.setAliases(List.of(alias)); + } + } + + return vuln; + } + + /** + * Helper method that maps an GitHub Vulnerability object to a Dependency-Track + * VulnerableSoftware object. + * + * @param qm a QueryManager + * @param vuln the GitHub Vulnerability to map + * @return a Dependency-Track VulnerableSoftware object + */ + protected List mapVulnerabilityToVulnerableSoftware(final QueryManager qm, + final ComposerAdvisory advisory) { + final List vsList = new ArrayList<>(); + try { + final PackageURL purl = generatePurlFromComposerAdvisory(advisory); + if (purl == null) + return null; + + if (advisory.getAffectedVersions() != null) { + // regex splitters copied from Composer Version Parser + LOGGER.trace("Parsing version ranges for " + advisory.getPackageEcosystem() + " : " + + advisory.getPackageName() + " : " + advisory.getAffectedVersions()); + String[] ranges = Arrays.stream(advisory.getAffectedVersions().split("\\s*\\|\\|?\\s*")) + .map(String::trim).toArray(String[]::new); + + for (String range : ranges) { + String versionStartIncluding = null; + String versionStartExcluding = null; + String versionEndIncluding = null; + String versionEndExcluding = null; + // Split by both ',' and ' ' + String[] parts = Arrays.stream(range.split("(?< ,]) *(?=")) { + versionStartIncluding = part.replace(">=", "").trim(); + } else if (part.startsWith(">")) { + versionStartExcluding = part.replace(">", "").trim(); + } else if (part.startsWith("<=")) { + versionEndIncluding = part.replace("<=", "").trim(); + } else if (part.startsWith("<")) { + versionEndExcluding = part.replace("<", "").trim(); + } else if (part.startsWith("=")) { + versionStartIncluding = part.replace("=", "").trim(); + versionEndIncluding = part.replace("=", "").trim(); + } else if (part.trim().equals("*")) { + // Drupal sometimes uses * to indicate all versions are vulnerable for abandoned plugins + // Since we don't have a "deprecated" or "endoflife" or "unsupported" or "abandoned" flag, we do this: + versionEndExcluding = "999.999.999"; + } else { + // No operator, so it's a single version. Or garbage. But since none of the parts are checked for formatting, we don't check neither + // Drupal uses this, for example "8.1.0" + versionStartIncluding = part; + versionEndIncluding = part; + } + } + VulnerableSoftware vs = qm.getVulnerableSoftwareByPurl(purl.getType(), purl.getNamespace(), purl.getName(), purl.getVersion(), + versionEndExcluding, versionEndIncluding, versionStartExcluding, versionStartIncluding); + if (vs != null) { + if (!vsList.contains(vs)) { + vsList.add(vs); + continue; + } + } + + vs = new VulnerableSoftware(); + vs.setVulnerable(true); + vs.setPurlType(purl.getType()); + vs.setPurlNamespace(purl.getNamespace()); + vs.setPurlName(purl.getName()); + vs.setPurl(purl.canonicalize()); + vs.setVersionStartIncluding(versionStartIncluding); + vs.setVersionStartExcluding(versionStartExcluding); + vs.setVersionEndIncluding(versionEndIncluding); + vs.setVersionEndExcluding(versionEndExcluding); + + vsList.add(vs); + } + } + LOGGER.trace("Resulting VulnerableSoftware: " + vsList); + return vsList; + } catch (MalformedPackageURLException e) { + LOGGER.warn("Unable to create purl from Composer Vulnerability. Skipping " + advisory.getPackageEcosystem() + + " : " + advisory.getPackageName() + " for: " + advisory.getAdvisoryId()); + } + return null; + } + + private PackageURL generatePurlFromComposerAdvisory(final ComposerAdvisory vuln) + throws MalformedPackageURLException { + final String[] parts = vuln.getPackageName().split("/"); + final String namespace = String.join("/", Arrays.copyOfRange(parts, 0, parts.length - 1)); + return PackageURLBuilder.aPackageURL().withType(vuln.getPackageEcosystem()).withNamespace(namespace) + .withName(parts[parts.length - 1]).build(); + } + + protected void handleRequestException(final Logger logger, final Exception e) { + logger.error("Request failure", e); + Notification.dispatch(new Notification() + .scope(NotificationScope.SYSTEM) + .group(NotificationGroup.ANALYZER) + .title(NotificationConstants.Title.ANALYZER_ERROR) + .content( + "An error occurred while communicating with a vulnerability intelligence source. Check log for details. " + + e.getMessage()) + .level(NotificationLevel.ERROR)); + } +} diff --git a/src/main/java/org/dependencytrack/tasks/repositories/ComposerMetaAnalyzer.java b/src/main/java/org/dependencytrack/tasks/repositories/ComposerMetaAnalyzer.java index 7f47c0c35e..e1da36b54b 100644 --- a/src/main/java/org/dependencytrack/tasks/repositories/ComposerMetaAnalyzer.java +++ b/src/main/java/org/dependencytrack/tasks/repositories/ComposerMetaAnalyzer.java @@ -181,7 +181,7 @@ public MetaModel analyze(final Component component) { return analyzeFromMetadataUrl(meta, component, PACKAGE_META_DATA_PATH_PATTERN_V1); } - private JSONObject getRepoRoot() { + public JSONObject getRepoRoot() { // Code mimicksed from // https://github.com/composer/composer/blob/main/src/Composer/Repository/ComposerRepository.php // Retrieve packages.json file, which must be present even for V1 repositories @@ -203,6 +203,7 @@ private JSONObject getRepoRootFromUrl(String packageJsonUrl) { if (JsonUtil.isBlankJson(packageJsonString)) { LOGGER.warn("%s: Empty packages.json from %s".formatted(this.repositoryId, packageJsonUrl)); } else { + LOGGER.debug("packages.json retrieved from " + packageJsonUrl); repoRoot = new JSONObject(packageJsonString); } } @@ -402,6 +403,41 @@ private MetaModel analyzePackageVersions(final MetaModel meta, Component compone return meta; } + + public JSONObject retrieveAdvisories() { + JSONObject repoRoot = getRepoRoot(); + if (repoRoot == null) { + return null; + } + + if (repoRoot.optJSONObject("security-advisories") == null) { + LOGGER.info("No security advisory Api Url found in repository " + baseUrl); + return null; + } + + String advisoryUrl = repoRoot.getJSONObject("security-advisories").optString("api-url"); + if (advisoryUrl == null || advisoryUrl.isEmpty()) { + LOGGER.info("No security advisory Api Url found in repository " + baseUrl); + return null; + } + LOGGER.debug("Retrieving Composert Security Advisories from " + advisoryUrl); + // No incremental updates yet + String advisoryJsonUrl = UriBuilder.fromUri(advisoryUrl).queryParam("updatedSince", 100).build().toString(); + try (CloseableHttpResponse response = processHttpRequest(advisoryJsonUrl)) { + if (response.getStatusLine().getStatusCode() != HttpStatus.SC_OK) { + LOGGER.error("An error was encountered retrieving advisories with HTTP Status : " + response.getStatusLine().getStatusCode() + " " + response.getStatusLine().getReasonPhrase()); + return null; + } else { + String responseString = EntityUtils.toString(response.getEntity()); + // LOGGER.debug("response from composer repository: \n" + responseString); + return new JSONObject(responseString); + } + } catch (IOException ex) { + LOGGER.error("Exception while executing Http client request", ex); + } + return null; + } + private static String stripLeadingV(String s) { return s.startsWith("v") || s.startsWith("V") ? s.substring(1) diff --git a/src/main/java/org/dependencytrack/tasks/repositories/IMetaAnalyzer.java b/src/main/java/org/dependencytrack/tasks/repositories/IMetaAnalyzer.java index cd6c2a80c1..a1680a25d6 100644 --- a/src/main/java/org/dependencytrack/tasks/repositories/IMetaAnalyzer.java +++ b/src/main/java/org/dependencytrack/tasks/repositories/IMetaAnalyzer.java @@ -165,7 +165,6 @@ public void setRepositoryBaseUrl(String baseUrl) { @Override public void setRepositoryUsernameAndPassword(String username, String password) { - } @Override diff --git a/src/main/java/org/dependencytrack/util/JsonUtil.java b/src/main/java/org/dependencytrack/util/JsonUtil.java index 6a6e688edd..f23dfb5b27 100644 --- a/src/main/java/org/dependencytrack/util/JsonUtil.java +++ b/src/main/java/org/dependencytrack/util/JsonUtil.java @@ -23,10 +23,13 @@ import java.math.BigInteger; import java.time.ZonedDateTime; import java.time.format.DateTimeParseException; + import org.apache.commons.lang3.StringUtils; -public final class JsonUtil { +import alpine.common.logging.Logger; +public final class JsonUtil { + private static final Logger LOGGER = Logger.getLogger(JsonUtil.class); /** * Private constructor. */ @@ -67,6 +70,7 @@ public static ZonedDateTime jsonStringToTimestamp(final String s) { try { return ZonedDateTime.parse(s); } catch (DateTimeParseException e) { + LOGGER.trace("Unabled to parse ZonedDateTime: " + s); return null; } } diff --git a/src/test/java/org/dependencytrack/model/VulnerabilityAliasTest.java b/src/test/java/org/dependencytrack/model/VulnerabilityAliasTest.java index 69d07ebb04..2631b516bd 100644 --- a/src/test/java/org/dependencytrack/model/VulnerabilityAliasTest.java +++ b/src/test/java/org/dependencytrack/model/VulnerabilityAliasTest.java @@ -15,6 +15,8 @@ public void testCopyFieldsFrom() { alias.setSnykId("someSnykId"); alias.setGsdId("someGsdId"); alias.setVulnDbId("someVulnDbId"); + alias.setDrupalId("someDrupalId"); + alias.setComposerId("someComposerId"); alias.setInternalId("someInternalId"); var other = new VulnerabilityAlias(); @@ -24,6 +26,8 @@ public void testCopyFieldsFrom() { other.setOsvId("anotherOsvId"); other.setSnykId("anotherSnykId"); other.setGsdId("anotherGsdId"); + other.setDrupalId("someDrupalId"); + other.setComposerId("someComposerId"); other.setInternalId("anotherInternalId"); other.setVulnDbId(null); @@ -36,6 +40,8 @@ public void testCopyFieldsFrom() { Assert.assertEquals(other.getSnykId(), alias.getSnykId()); Assert.assertEquals(other.getGsdId(), alias.getGsdId()); Assert.assertEquals(other.getInternalId(), alias.getInternalId()); + Assert.assertEquals(other.getDrupalId(), alias.getDrupalId()); + Assert.assertEquals(other.getComposerId(), alias.getComposerId()); // null does not overwrite existing value Assert.assertEquals(alias.getVulnDbId(), alias.getVulnDbId()); diff --git a/src/test/java/org/dependencytrack/parser/composer/ComposerAdvisoryParserTest.java b/src/test/java/org/dependencytrack/parser/composer/ComposerAdvisoryParserTest.java new file mode 100644 index 0000000000..e57fd6c591 --- /dev/null +++ b/src/test/java/org/dependencytrack/parser/composer/ComposerAdvisoryParserTest.java @@ -0,0 +1,260 @@ +package org.dependencytrack.parser.composer; + +import java.io.IOException; + +import org.dependencytrack.parser.composer.model.ComposerAdvisory; +import org.json.JSONObject; +import org.junit.Assert; +import org.junit.Test; + +public class ComposerAdvisoryParserTest { + + public final static JSONObject VULN_DRUPAL = new JSONObject(""" + { + "advisoryId": "SA-CORE-2018-003", + "packageName": "drupal/core", + "title": "Drupal core - Moderately critical - Cross Site Scripting - SA-CORE-2018-003", + "link": "https://www.drupal.org/sa-core-2018-003", + "cve": "CVE-2018-9861", + "affectedVersions": "\u003E= 8.0.0 \u003C8.4.7 || \u003E=8.5.0 \u003C8.5.2", + "reportedAt": "2018-04-18 15:34:09", + "composerRepository": "https://packagist.org/", + "sources": [ + { + "name": "Drupal core - Moderately critical - Cross Site Scripting - SA-CORE-2018-003", + "remoteId": "SA-CORE-2018-003" + } + ] + } + """); + + public final static JSONObject VULN_GHSA = new JSONObject(""" + { + "advisoryId": "PKSA-228k-hrjg-43zp", + "packageName": "magento/community-edition", + "remoteId": "GHSA-297f-r9w7-w492", + "title": "Magento Improper input validation vulnerability", + "link": "https://github.com/advisories/GHSA-297f-r9w7-w492", + "cve": "CVE-2022-42344", + "affectedVersions": "=2.4.4|\u003E=2.4.0,\u003C2.4.3-p3|\u003C2.3.7-p4", + "source": "GitHub", + "reportedAt": "2022-10-20 19:00:29", + "composerRepository": "https://packagist.org", + "severity": "high", + "sources": [ + { + "name": "GitHub", + "remoteId": "GHSA-297f-r9w7-w492" + } + ] + }, + """); + + public final static JSONObject VULN_FOP_NO_CVE = new JSONObject( + """ + { + "advisoryId": "PKSA-n8hw-tywm-xrh7", + "packageName": "drupal/core", + "remoteId": "drupal/core/2019-12-18-1.yaml", + "title": "Drupal core - Moderately critical - Denial of Service - SA-CORE-2019-009", + "link": "https://www.drupal.org/sa-core-2019-009", + "cve": null, + "affectedVersions": "\u003E=8.0.0,\u003C8.1.0|\u003E=8.1.0,\u003C8.2.0|\u003E=8.2.0,\u003C8.3.0|\u003E=8.3.0,\u003C8.4.0|\u003E=8.4.0,\u003C8.5.0|\u003E=8.5.0,\u003C8.6.0|\u003E=8.6.0,\u003C8.7.0|\u003E=8.7.0,\u003C8.7.11|\u003E=8.8.0,\u003C8.8.1", + "source": "FriendsOfPHP/security-advisories", + "reportedAt": "2019-12-18 00:00:00", + "composerRepository": "https://packagist.org", + "severity": "critical", + "sources": [ + { + "name": "FriendsOfPHP/security-advisories", + "remoteId": "drupal/core/2019-12-18-1.yaml" + }, + { + "name": "GitHub", + "remoteId": "GHSA-7v68-3pr5-h3cr" + } + ] + } + """); + + public final static JSONObject VULN_FOP = new JSONObject( + """ + { + "advisoryId": "PKSA-p9s6-dthp-ws2d", + "packageName": "simplesamlphp/saml2", + "remoteId": "simplesamlphp/saml2/CVE-2016-9814.yaml", + "title": "Incorrect signature verification", + "link": "https://simplesamlphp.org/security/201612-01", + "cve": "CVE-2016-9814", + "affectedVersions": "\u003C1.8.1|\u003E=1.9.0,\u003C1.9.1|\u003E=1.10,\u003C1.10.3|\u003E=2.0,\u003C2.3.3", + "source": "FriendsOfPHP/security-advisories", + "reportedAt": "2016-11-29 13:12:44", + "composerRepository": "https://packagist.org", + "severity": "critical", + "sources": [ + { + "name": "GitHub", + "remoteId": "GHSA-r8v4-7vwj-983x" + }, + { + "name": "FriendsOfPHP/security-advisories", + "remoteId": "simplesamlphp/saml2/CVE-2016-9814.yaml" + } + ] + }, + """); + + // Theoretical case to prepare for other repositories + public final static JSONObject VULN_FOP_CVE = new JSONObject( + """ + { + "advisoryId": "PKSA-p9s6-dthp-ws2d", + "packageName": "simplesamlphp/saml2", + "remoteId": "simplesamlphp/saml2/CVE-2016-9814.yaml", + "title": "Incorrect signature verification", + "link": "https://simplesamlphp.org/security/201612-01", + "cve": "CVE-2016-9814", + "affectedVersions": "\u003C1.8.1|\u003E=1.9.0,\u003C1.9.1|\u003E=1.10,\u003C1.10.3|\u003E=2.0,\u003C2.3.3", + "source": "FriendsOfPHP/security-advisories", + "reportedAt": "2016-11-29 13:12:44", + "composerRepository": "https://packagist.org", + "severity": "critical", + "sources": [ + { + "name": "FriendsOfPHP/security-advisories", + "remoteId": "simplesamlphp/saml2/CVE-2016-9814.yaml" + } + ] + }, + """); + + + // Hypothetical vulnerability to future proof our parser + public final static JSONObject VULN_COMPOSER = new JSONObject( + """ + { + "advisoryId": "PKSA-m9t7-ggb8-abcd", + "packageName": "social/media", + "remoteId": null, + "title": "File REST resource does not properly validate", + "link": "https://www.somesource.org/vulnerability/1234", + "cve": null, + "affectedVersions": "\u003E=8.0,\u003C8.1.0|\u003E=8.1.0,\u003C8.2.0|\u003E=8.2.0,\u003C8.3.0|\u003E=8.3.0,\u003C8.3.4", + "source": "somesource", + "reportedAt": "2017-06-21 18:13:27", + "composerRepository": "https://packagist.org", + "severity": "medium", + "sources": [] + } + """); + + public final static JSONObject VULN_DRUPAL_INVALID_TIME = new JSONObject( + """ + { + "advisoryId": "PKSA-n8hw-tywm-xrh7", + "packageName": "drupal/core", + "remoteId": "drupal/core/2019-12-18-1.yaml", + "title": "Drupal core - Moderately critical - Denial of Service - SA-CORE-2019-009", + "link": "https://www.drupal.org/sa-core-2019-009", + "cve": null, + "affectedVersions": "\u003E=8.0.0,\u003C8.1.0|\u003E=8.1.0,\u003C8.2.0|\u003E=8.2.0,\u003C8.3.0|\u003E=8.3.0,\u003C8.4.0|\u003E=8.4.0,\u003C8.5.0|\u003E=8.5.0,\u003C8.6.0|\u003E=8.6.0,\u003C8.7.0|\u003E=8.7.0,\u003C8.7.11|\u003E=8.8.0,\u003C8.8.1", + "source": "FriendsOfPHP/security-advisories", + "reportedAt": "2019-122222-18 00:00:00", + "composerRepository": "https://packagist.org", + "severity": "critical", + "sources": [ + { + "name": "FriendsOfPHP/security-advisories", + "remoteId": "drupal/core/2019-12-18-1.yaml" + }, + { + "name": "GitHub", + "remoteId": "GHSA-7v68-3pr5-h3cr" + } + ] + } + """); + + public final static JSONObject VULN_WILDCARD_ALL = new JSONObject(""" + { + "advisoryId": "PKSA-n8hw-tywm-xrh7", + "packageName": "drupal/core", + "remoteId": "drupal/core/2019-12-18-1.yaml", + "title": "Drupal core - Moderately critical - Denial of Service - SA-CORE-2019-009", + "link": "https://www.drupal.org/sa-core-2019-009", + "cve": null, + "affectedVersions": "*", + "source": "FriendsOfPHP/security-advisories", + "reportedAt": "2019-122222-18 00:00:00", + "composerRepository": "https://packagist.org", + "severity": "critical", + "sources": [ + { + "name": "FriendsOfPHP/security-advisories", + "remoteId": "drupal/core/2019-12-18-1.yaml" + }, + { + "name": "GitHub", + "remoteId": "GHSA-7v68-3pr5-h3cr" + } + ] + } + """); + + + public final static JSONObject VULN_EXACT_VERSION = new JSONObject(""" + { + "advisoryId": "PKSA-n8hw-tywm-xrh7", + "packageName": "drupal/core", + "remoteId": "drupal/core/2019-12-18-1.yaml", + "title": "Drupal core - Moderately critical - Denial of Service - SA-CORE-2019-009", + "link": "https://www.drupal.org/sa-core-2019-009", + "cve": null, + "affectedVersions": "8.1.0", + "source": "FriendsOfPHP/security-advisories", + "reportedAt": "2019-122222-18 00:00:00", + "composerRepository": "https://packagist.org", + "severity": "critical", + "sources": [ + { + "name": "FriendsOfPHP/security-advisories", + "remoteId": "drupal/core/2019-12-18-1.yaml" + }, + { + "name": "GitHub", + "remoteId": "GHSA-7v68-3pr5-h3cr" + } + ] + } + """); + + @Test + public void testDateTime() { + ComposerAdvisory vuln = ComposerAdvisoryParser.parseAdvisory(VULN_DRUPAL_INVALID_TIME); + Assert.assertNull(vuln.getReportedAt()); + } + + @Test + public void testSources() { + ComposerAdvisory vuln = ComposerAdvisoryParser.parseAdvisory(VULN_FOP); + Assert.assertEquals(2, vuln.getSources().size()); + Assert.assertTrue(vuln.getSources().containsKey("github")); + Assert.assertEquals("GHSA-r8v4-7vwj-983x", vuln.getSources().get("github")); + Assert.assertTrue(vuln.getSources().containsKey("friendsofphp/security-advisories")); + Assert.assertEquals("simplesamlphp/saml2/CVE-2016-9814.yaml", vuln.getSources().get("friendsofphp/security-advisories")); + } + + @Test + public void testParseNoErrors() throws IOException { + ComposerAdvisoryParser.parseAdvisory(VULN_DRUPAL); + ComposerAdvisoryParser.parseAdvisory(VULN_DRUPAL_INVALID_TIME); + ComposerAdvisoryParser.parseAdvisory(VULN_GHSA); + ComposerAdvisoryParser.parseAdvisory(VULN_FOP); + ComposerAdvisoryParser.parseAdvisory(VULN_FOP_CVE); + ComposerAdvisoryParser.parseAdvisory(VULN_FOP_NO_CVE); + ComposerAdvisoryParser.parseAdvisory(VULN_COMPOSER); + ComposerAdvisoryParser.parseAdvisory(VULN_WILDCARD_ALL); + ComposerAdvisoryParser.parseAdvisory(VULN_EXACT_VERSION); + } + +} diff --git a/src/test/java/org/dependencytrack/persistence/DefaultObjectGeneratorTest.java b/src/test/java/org/dependencytrack/persistence/DefaultObjectGeneratorTest.java index 75463de7f8..7766508d2b 100644 --- a/src/test/java/org/dependencytrack/persistence/DefaultObjectGeneratorTest.java +++ b/src/test/java/org/dependencytrack/persistence/DefaultObjectGeneratorTest.java @@ -102,7 +102,7 @@ public void testLoadDefaultRepositories() throws Exception { Method method = generator.getClass().getDeclaredMethod("loadDefaultRepositories"); method.setAccessible(true); method.invoke(generator); - Assert.assertEquals(17, qm.getAllRepositories().size()); + Assert.assertEquals(18, qm.getAllRepositories().size()); } @Test diff --git a/src/test/java/org/dependencytrack/resources/v1/RepositoryResourceTest.java b/src/test/java/org/dependencytrack/resources/v1/RepositoryResourceTest.java index bf62d0bc44..71500ff8b9 100644 --- a/src/test/java/org/dependencytrack/resources/v1/RepositoryResourceTest.java +++ b/src/test/java/org/dependencytrack/resources/v1/RepositoryResourceTest.java @@ -28,6 +28,7 @@ import org.dependencytrack.persistence.DefaultObjectGenerator; import org.dependencytrack.persistence.QueryManager; import org.glassfish.jersey.server.ResourceConfig; +import org.json.JSONObject; import org.junit.Assert; import org.junit.Before; import org.junit.ClassRule; @@ -62,16 +63,18 @@ public void getRepositoriesTest() { .header(X_API_KEY, apiKey) .get(Response.class); Assert.assertEquals(200, response.getStatus(), 0); - Assert.assertEquals(String.valueOf(17), response.getHeaderString(TOTAL_COUNT_HEADER)); + Assert.assertEquals(String.valueOf(18), response.getHeaderString(TOTAL_COUNT_HEADER)); JsonArray json = parseJsonArray(response); Assert.assertNotNull(json); - Assert.assertEquals(17, json.size()); + Assert.assertEquals(18, json.size()); for (int i = 0; i < json.size(); i++) { Assert.assertNotNull(json.getJsonObject(i).getString("type")); Assert.assertNotNull(json.getJsonObject(i).getString("identifier")); Assert.assertNotNull(json.getJsonObject(i).getString("url")); Assert.assertTrue(json.getJsonObject(i).getInt("resolutionOrder") > 0); - Assert.assertTrue(json.getJsonObject(i).getBoolean("enabled")); + if (!json.getJsonObject(i).getString("identifier").equals("drupal8")) { + Assert.assertTrue(json.getJsonObject(i).getBoolean("enabled")); + } } } @@ -171,7 +174,6 @@ public void getRepositoryMetaUntrackedComponentTest() { Assert.assertEquals("The repository metadata for the specified component cannot be found.", body); } - @Test public void createRepositoryTest() { Repository repository = new Repository(); @@ -187,20 +189,19 @@ public void createRepositoryTest() { .put(Entity.entity(repository, MediaType.APPLICATION_JSON)); Assert.assertEquals(201, response.getStatus()); - response = jersey.target(V1_REPOSITORY).request().header(X_API_KEY, apiKey).get(Response.class); Assert.assertEquals(200, response.getStatus(), 0); - Assert.assertEquals(String.valueOf(18), response.getHeaderString(TOTAL_COUNT_HEADER)); + Assert.assertEquals(String.valueOf(19), response.getHeaderString(TOTAL_COUNT_HEADER)); JsonArray json = parseJsonArray(response); Assert.assertNotNull(json); - Assert.assertEquals(18, json.size()); - Assert.assertEquals("MAVEN", json.getJsonObject(13).getString("type")); - Assert.assertEquals("test", json.getJsonObject(13).getString("identifier")); - Assert.assertEquals("www.foobar.com", json.getJsonObject(13).getString("url")); - Assert.assertTrue(json.getJsonObject(13).getInt("resolutionOrder") > 0); - Assert.assertTrue(json.getJsonObject(13).getBoolean("authenticationRequired")); - Assert.assertEquals("testuser", json.getJsonObject(13).getString("username")); - Assert.assertTrue(json.getJsonObject(13).getBoolean("enabled")); + Assert.assertEquals(19, json.size()); + Assert.assertEquals("MAVEN", json.getJsonObject(14).getString("type")); + Assert.assertEquals("test", json.getJsonObject(14).getString("identifier")); + Assert.assertEquals("www.foobar.com", json.getJsonObject(14).getString("url")); + Assert.assertTrue(json.getJsonObject(14).getInt("resolutionOrder") > 0); + Assert.assertTrue(json.getJsonObject(14).getBoolean("authenticationRequired")); + Assert.assertEquals("testuser", json.getJsonObject(14).getString("username")); + Assert.assertTrue(json.getJsonObject(14).getBoolean("enabled")); } @Test @@ -219,21 +220,20 @@ public void createNonInternalRepositoryTest() { .put(Entity.entity(repository, MediaType.APPLICATION_JSON)); Assert.assertEquals(201, response.getStatus()); - response = jersey.target(V1_REPOSITORY).request().header(X_API_KEY, apiKey).get(Response.class); Assert.assertEquals(200, response.getStatus(), 0); - Assert.assertEquals(String.valueOf(18), response.getHeaderString(TOTAL_COUNT_HEADER)); + Assert.assertEquals(String.valueOf(19), response.getHeaderString(TOTAL_COUNT_HEADER)); JsonArray json = parseJsonArray(response); Assert.assertNotNull(json); - Assert.assertEquals(18, json.size()); - Assert.assertEquals("MAVEN", json.getJsonObject(13).getString("type")); - Assert.assertEquals("test", json.getJsonObject(13).getString("identifier")); - Assert.assertEquals("www.foobar.com", json.getJsonObject(13).getString("url")); - Assert.assertTrue(json.getJsonObject(13).getInt("resolutionOrder") > 0); - Assert.assertTrue(json.getJsonObject(13).getBoolean("authenticationRequired")); - Assert.assertFalse(json.getJsonObject(13).getBoolean("internal")); - Assert.assertEquals("testuser", json.getJsonObject(13).getString("username")); - Assert.assertTrue(json.getJsonObject(13).getBoolean("enabled")); + Assert.assertEquals(19, json.size()); + Assert.assertEquals("MAVEN", json.getJsonObject(14).getString("type")); + Assert.assertEquals("test", json.getJsonObject(14).getString("identifier")); + Assert.assertEquals("www.foobar.com", json.getJsonObject(14).getString("url")); + Assert.assertTrue(json.getJsonObject(14).getInt("resolutionOrder") > 0); + Assert.assertTrue(json.getJsonObject(14).getBoolean("authenticationRequired")); + Assert.assertFalse(json.getJsonObject(14).getBoolean("internal")); + Assert.assertEquals("testuser", json.getJsonObject(14).getString("username")); + Assert.assertTrue(json.getJsonObject(14).getBoolean("enabled")); } @Test @@ -243,6 +243,7 @@ public void createRepositoryAuthFalseTest() { repository.setEnabled(true); repository.setInternal(true); repository.setIdentifier("test"); + repository.setDescription("description"); repository.setUrl("www.foobar.com"); repository.setType(RepositoryType.MAVEN); Response response = jersey.target(V1_REPOSITORY).request().header(X_API_KEY, apiKey) @@ -252,16 +253,17 @@ public void createRepositoryAuthFalseTest() { response = jersey.target(V1_REPOSITORY).request().header(X_API_KEY, apiKey).get(Response.class); Assert.assertEquals(200, response.getStatus(), 0); - Assert.assertEquals(String.valueOf(18), response.getHeaderString(TOTAL_COUNT_HEADER)); + Assert.assertEquals(String.valueOf(19), response.getHeaderString(TOTAL_COUNT_HEADER)); JsonArray json = parseJsonArray(response); Assert.assertNotNull(json); - Assert.assertEquals(18, json.size()); - Assert.assertEquals("MAVEN", json.getJsonObject(13).getString("type")); - Assert.assertEquals("test", json.getJsonObject(13).getString("identifier")); - Assert.assertEquals("www.foobar.com", json.getJsonObject(13).getString("url")); - Assert.assertTrue(json.getJsonObject(13).getInt("resolutionOrder") > 0); - Assert.assertFalse(json.getJsonObject(13).getBoolean("authenticationRequired")); - Assert.assertTrue(json.getJsonObject(13).getBoolean("enabled")); + Assert.assertEquals(19, json.size()); + Assert.assertEquals("MAVEN", json.getJsonObject(14).getString("type")); + Assert.assertEquals("test", json.getJsonObject(14).getString("identifier")); + Assert.assertEquals("description", json.getJsonObject(14).getString("description")); + Assert.assertEquals("www.foobar.com", json.getJsonObject(14).getString("url")); + Assert.assertTrue(json.getJsonObject(14).getInt("resolutionOrder") > 0); + Assert.assertFalse(json.getJsonObject(14).getBoolean("authenticationRequired")); + Assert.assertTrue(json.getJsonObject(14).getBoolean("enabled")); } @@ -274,6 +276,7 @@ public void updateRepositoryTest() throws Exception { repository.setPassword("testPassword"); repository.setInternal(true); repository.setIdentifier("test"); + repository.setDescription("description"); repository.setUrl("www.foobar.com"); repository.setType(RepositoryType.MAVEN); Response response = jersey.target(V1_REPOSITORY).request().header(X_API_KEY, apiKey) @@ -284,6 +287,7 @@ public void updateRepositoryTest() throws Exception { for (Repository repository1 : repositoryList) { if (repository1.getIdentifier().equals("test")) { repository1.setAuthenticationRequired(false); + repository1.setDescription("new description"); response = jersey.target(V1_REPOSITORY).request().header(X_API_KEY, apiKey) .post(Entity.entity(repository1, MediaType.APPLICATION_JSON)); Assert.assertEquals(200, response.getStatus()); @@ -294,10 +298,55 @@ public void updateRepositoryTest() throws Exception { for (Repository repository1 : repositoryList) { if (repository1.getIdentifier().equals("test")) { Assert.assertEquals(false, repository1.isAuthenticationRequired()); + Assert.assertEquals("new description", repository1.getDescription()); + break; + } + } + } + + } + + @Test + public void updateRepositoryTestAdvisoryMirroring() throws Exception { + Repository repository = new Repository(); + repository.setAuthenticationRequired(true); + repository.setEnabled(true); + repository.setUsername("testuser"); + repository.setPassword("testPassword"); + repository.setInternal(true); + repository.setIdentifier("composer_repo"); + repository.setUrl("www.foobar.com"); + repository.setType(RepositoryType.COMPOSER); + Response response = jersey.target(V1_REPOSITORY).request().header(X_API_KEY, apiKey) + .put(Entity.entity(repository, MediaType.APPLICATION_JSON)); + Assert.assertEquals(201, response.getStatus()); + + try (QueryManager qm = new QueryManager()) { + List repositoryList = qm.getRepositories(RepositoryType.COMPOSER).getList(Repository.class); + for (Repository repository1 : repositoryList) { + if (repository1.getIdentifier().equals("composer_repo")) { + Assert.assertNull(repository1.getConfig()); + repository1.setConfig("{\"advisoryMirroringEnabled\": true, \"advisoryAliasSyncEnabled\": true}"); + response = jersey.target(V1_REPOSITORY).request().header(X_API_KEY, apiKey) + .post(Entity.entity(repository1, MediaType.APPLICATION_JSON)); + Assert.assertEquals(200, response.getStatus()); + break; + } + } + + repositoryList = qm.getRepositories(RepositoryType.COMPOSER).getList(Repository.class); + for (Repository repository1 : repositoryList) { + if (repository1.getIdentifier().equals("composer_repo")) { + Assert.assertNotNull(repository1.getConfig()); + JSONObject jsonConfig = new JSONObject(repository1.getConfig()); + Assert.assertTrue(jsonConfig.optBoolean("advisoryMirroringEnabled")); + Assert.assertTrue(jsonConfig.optBoolean("advisoryAliasSyncEnabled")); break; } } } } + + } diff --git a/src/test/java/org/dependencytrack/tasks/RepoMetaAnalysisTaskTest.java b/src/test/java/org/dependencytrack/tasks/RepoMetaAnalysisTaskTest.java index 02455e160b..995b48e023 100644 --- a/src/test/java/org/dependencytrack/tasks/RepoMetaAnalysisTaskTest.java +++ b/src/test/java/org/dependencytrack/tasks/RepoMetaAnalysisTaskTest.java @@ -70,7 +70,7 @@ public void informTestNullPassword() throws Exception { 20210213164433 - + """.getBytes(), new ContentTypeHeader(MediaType.APPLICATION_JSON)) ) .withHeader("X-CheckSum-MD5", "md5hash") @@ -85,7 +85,7 @@ public void informTestNullPassword() throws Exception { component.setName("junit"); component.setPurl(new PackageURL("pkg:maven/junit/junit@4.12")); qm.createComponent(component, false); - qm.createRepository(RepositoryType.MAVEN, "test", wireMockRule.baseUrl(), true, false, true, "testuser", null); + qm.createRepository(RepositoryType.MAVEN, "test", null, wireMockRule.baseUrl(), true, false, true, "testuser", null, null); new RepositoryMetaAnalyzerTask().inform(new RepositoryMetaEvent(List.of(component))); RepositoryMetaComponent metaComponent = qm.getRepositoryMetaComponent(RepositoryType.MAVEN, "junit", "junit"); qm.getPersistenceManager().refresh(metaComponent); @@ -116,7 +116,7 @@ public void informTestNullUserName() throws Exception { 20210213164433 - + """.getBytes(), new ContentTypeHeader(MediaType.APPLICATION_JSON)) ) .withHeader("X-CheckSum-MD5", "md5hash") @@ -131,7 +131,7 @@ public void informTestNullUserName() throws Exception { component.setName("test1"); component.setPurl(new PackageURL("pkg:maven/test1/test1@1.2.0")); qm.createComponent(component, false); - qm.createRepository(RepositoryType.MAVEN, "test", wireMockRule.baseUrl(), true, false, true, null, "testPassword"); + qm.createRepository(RepositoryType.MAVEN, "test", null, wireMockRule.baseUrl(), true, false, true, null, "testPassword", null); new RepositoryMetaAnalyzerTask().inform(new RepositoryMetaEvent(List.of(component))); RepositoryMetaComponent metaComponent = qm.getRepositoryMetaComponent(RepositoryType.MAVEN, "test1", "test1"); qm.getPersistenceManager().refresh(metaComponent); @@ -162,7 +162,7 @@ public void informTestNullUserNameAndPassword() throws Exception { 20210213164433 - + """.getBytes(), new ContentTypeHeader(MediaType.APPLICATION_JSON)) ) .withHeader("X-CheckSum-MD5", "md5hash") @@ -177,7 +177,7 @@ public void informTestNullUserNameAndPassword() throws Exception { component.setName("junit"); component.setPurl(new PackageURL("pkg:maven/test2/test2@4.12")); qm.createComponent(component, false); - qm.createRepository(RepositoryType.MAVEN, "test", wireMockRule.baseUrl(), true, false, false, null, null); + qm.createRepository(RepositoryType.MAVEN, "test", null, wireMockRule.baseUrl(), true, false, false, null, null, null); new RepositoryMetaAnalyzerTask().inform(new RepositoryMetaEvent(List.of(component))); RepositoryMetaComponent metaComponent = qm.getRepositoryMetaComponent(RepositoryType.MAVEN, "test2", "test2"); qm.getPersistenceManager().refresh(metaComponent); @@ -208,7 +208,7 @@ public void informTestUserNameAndPassword() throws Exception { 20210213164433 - + """.getBytes(), new ContentTypeHeader(MediaType.APPLICATION_JSON)) ) .withHeader("X-CheckSum-MD5", "md5hash") @@ -223,7 +223,7 @@ public void informTestUserNameAndPassword() throws Exception { component.setName("test3"); component.setPurl(new PackageURL("pkg:maven/test3/test3@4.12")); qm.createComponent(component, false); - qm.createRepository(RepositoryType.MAVEN, "test", wireMockRule.baseUrl(), true, false, true, "testUser", "testPassword"); + qm.createRepository(RepositoryType.MAVEN, "test", null, wireMockRule.baseUrl(), true, false, true, "testUser", "testPassword", null); new RepositoryMetaAnalyzerTask().inform(new RepositoryMetaEvent(List.of(component))); RepositoryMetaComponent metaComponent = qm.getRepositoryMetaComponent(RepositoryType.MAVEN, "test3", "test3"); qm.getPersistenceManager().refresh(metaComponent); diff --git a/src/test/java/org/dependencytrack/tasks/repositories/ComposerAdvisoryMirrorTaskTest.java b/src/test/java/org/dependencytrack/tasks/repositories/ComposerAdvisoryMirrorTaskTest.java new file mode 100644 index 0000000000..dd1b250946 --- /dev/null +++ b/src/test/java/org/dependencytrack/tasks/repositories/ComposerAdvisoryMirrorTaskTest.java @@ -0,0 +1,868 @@ +/* + * Copyright 2022 OWASP. + * + * 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 + * + * https://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. + */ +package org.dependencytrack.tasks.repositories; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.dependencytrack.model.ConfigPropertyConstants.VULNERABILITY_SOURCE_GITHUB_ADVISORIES_ENABLED; +import static org.dependencytrack.model.ConfigPropertyConstants.VULNERABILITY_SOURCE_NVD_ENABLED; +import static org.junit.Assert.assertNull; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.mockserver.model.HttpRequest.request; +import static org.mockserver.model.HttpResponse.response; + +import java.io.File; +import java.io.IOException; +import java.time.LocalDateTime; +import java.time.ZoneOffset; +import java.util.List; +import java.util.function.Consumer; + +import org.apache.commons.lang3.StringUtils; +import org.apache.http.HttpHeaders; +import org.dependencytrack.PersistenceCapableTest; +import org.dependencytrack.model.AffectedVersionAttribution; +import org.dependencytrack.model.Repository; +import org.dependencytrack.model.RepositoryType; +import org.dependencytrack.model.Severity; +import org.dependencytrack.model.Vulnerability; +import org.dependencytrack.model.VulnerabilityAlias; +import org.dependencytrack.model.VulnerableSoftware; +import org.dependencytrack.parser.composer.ComposerAdvisoryParser; +import org.dependencytrack.parser.composer.ComposerAdvisoryParserTest; +import org.dependencytrack.parser.composer.model.ComposerAdvisory; +import org.json.JSONObject; +import org.junit.AfterClass; +import org.junit.Assert; +import org.junit.Before; +import org.junit.BeforeClass; +import org.junit.Test; +import org.mockserver.client.MockServerClient; +import org.mockserver.integration.ClientAndServer; + +import com.github.packageurl.PackageURL; + +import alpine.model.IConfigProperty; + + +public class ComposerAdvisoryMirrorTaskTest extends PersistenceCapableTest { + + private static ClientAndServer mockServer; + + private static final String CONFIG_MIRROR_ENABLED_WITH_ALIAS = "{\"advisoryMirroringEnabled\": true, \"advisoryAliasSyncEnabled\": true}"; + + @Before + public void setUp() { + qm.createConfigProperty(VULNERABILITY_SOURCE_NVD_ENABLED.getGroupName(), + VULNERABILITY_SOURCE_NVD_ENABLED.getPropertyName(), + "true", + IConfigProperty.PropertyType.BOOLEAN, + ""); + qm.createConfigProperty(VULNERABILITY_SOURCE_GITHUB_ADVISORIES_ENABLED.getGroupName(), + VULNERABILITY_SOURCE_GITHUB_ADVISORIES_ENABLED.getPropertyName(), + "true", + IConfigProperty.PropertyType.BOOLEAN, + ""); + + mockServer.reset(); + } + + @BeforeClass + public static void beforeClass() { + mockServer = ClientAndServer.startClientAndServer(1080); + } + + @AfterClass + public static void afterClass() { + mockServer.stop(); + } + + @Test + public void testTruncateSummaryAndAffectedVersions() { + String longTitle = "In uvc_scan_chain_forward of uvc_driver.c, there is a possible linked list corruption due to an unusual root cause. This could lead to local escalation of privilege in the kernel with no additional execution privileges needed. User interaction is not needed for exploitation."; + ComposerAdvisoryMirrorTask task = new ComposerAdvisoryMirrorTask(); + ComposerAdvisory composerAdvisory = ComposerAdvisoryParser + .parseAdvisory(ComposerAdvisoryParserTest.VULN_FOP); + composerAdvisory.setTitle(longTitle); + Vulnerability vuln = task.mapComposerAdvisoryToVulnerability(composerAdvisory, true); + Assert.assertEquals(vuln.getTitle(), StringUtils.abbreviate(longTitle, "...", 255)); + + String longAffected = "\\u003E=8.0.0,\\u003C8.1.0|\\u003E=8.1.0,\\u003C8.2.0|\\u003E=8.2.0,\\u003C8.3.0|\\u003E=8.3.0,\\u003C8.4.0|\\u003E=8.4.0,\\u003C8.5.0|\\u003E=8.5.0,\\u003C8.6.0|\\u003E=8.6.0,\\u003C8.7.0|\\u003E=8.7.0,\\u003C8.8.0|\\u003E=8.8.0,\\u003C8.9.0|\\u003E=8.9.0,\\u003C9.0.0|\\u003E=9.0.0,\\u003C9.1.0|\\u003E=9.1.0,\\u003C9.2.0|\\u003E=9.2.0,\\u003C9.3.0|\\u003E=9.3.0,\\u003C9.4.0|\\u003E=9.4.0,\\u003C9.5.0|\\u003E=9.5.0,\\u003C10.0.0|\\u003E=10.0.0,\\u003C10.1.0|\\u003E=10.1.0,\\u003C10.1.8|\\u003E=10.2.0,\\u003C10.2.2"; + composerAdvisory = ComposerAdvisoryParser.parseAdvisory(ComposerAdvisoryParserTest.VULN_FOP); + composerAdvisory.setAffectedVersionsCve(longAffected); + vuln = task.mapComposerAdvisoryToVulnerability(composerAdvisory, true); + Assert.assertEquals(vuln.getVulnerableVersions(), StringUtils.abbreviate(longAffected, "...", 255)); + } + + @Test + public void testextractVulnIdDrupal() { + Vulnerability.Source source1 = Vulnerability.Source.resolve(ComposerAdvisoryMirrorTask + .extractVulnId(ComposerAdvisoryParser.parseAdvisory(ComposerAdvisoryParserTest.VULN_DRUPAL))); + Assert.assertEquals(Vulnerability.Source.DRUPAL, source1); + } + + @Test + public void testextractVulnIdFriendsOfPhp() { + Vulnerability.Source source2 = Vulnerability.Source.resolve(ComposerAdvisoryMirrorTask + .extractVulnId(ComposerAdvisoryParser.parseAdvisory(ComposerAdvisoryParserTest.VULN_FOP))); + Assert.assertEquals(Vulnerability.Source.GITHUB, source2); + } + + @Test + public void testextractVulnIdGHSA() { + Vulnerability.Source source3 = Vulnerability.Source.resolve(ComposerAdvisoryMirrorTask + .extractVulnId(ComposerAdvisoryParser.parseAdvisory(ComposerAdvisoryParserTest.VULN_GHSA))); + Assert.assertEquals(Vulnerability.Source.GITHUB, source3); + } + + @Test + public void testextractVulnIdFriendsOfPhpCVE() { + Vulnerability.Source source4 = Vulnerability.Source.resolve(ComposerAdvisoryMirrorTask + .extractVulnId(ComposerAdvisoryParser.parseAdvisory(ComposerAdvisoryParserTest.VULN_FOP_CVE))); + Assert.assertEquals(Vulnerability.Source.NVD, source4); + } + + @Test + public void testextractVulnIdFriendsOfPhpNoCVE() { + Vulnerability.Source source4 = Vulnerability.Source.resolve(ComposerAdvisoryMirrorTask + .extractVulnId(ComposerAdvisoryParser.parseAdvisory(ComposerAdvisoryParserTest.VULN_FOP_NO_CVE))); + Assert.assertEquals(Vulnerability.Source.GITHUB, source4); + } + + @Test + public void testextractVulnIdComposer() { + Vulnerability.Source source5 = Vulnerability.Source.resolve(ComposerAdvisoryMirrorTask + .extractVulnId(ComposerAdvisoryParser.parseAdvisory(ComposerAdvisoryParserTest.VULN_COMPOSER))); + Assert.assertEquals(Vulnerability.Source.COMPOSER, source5); + } + + @Test + public void testDrupalAffectedVersionMapping() throws IOException { + ComposerAdvisory vuln = ComposerAdvisoryParser + .parseAdvisory(ComposerAdvisoryParserTest.VULN_DRUPAL); + ComposerAdvisoryMirrorTask task = new ComposerAdvisoryMirrorTask(); + List mapVulnerabilityToVulnerableSoftware = task.mapVulnerabilityToVulnerableSoftware(qm, + vuln); + Assert.assertEquals(2, mapVulnerabilityToVulnerableSoftware.size()); + assertThat(mapVulnerabilityToVulnerableSoftware).satisfiesExactlyInAnyOrder( + range -> { + assertThat(range.getVersionStartIncluding()).isEqualTo("8.0.0"); + assertThat(range.getVersionEndExcluding()).isEqualTo("8.4.7"); + }, + range -> { + assertThat(range.getVersionStartIncluding()).isEqualTo("8.5.0"); + assertThat(range.getVersionEndExcluding()).isEqualTo("8.5.2"); + }); + } + + @Test + public void testPackagistAffectedVersionMapping() throws IOException { + ComposerAdvisory vuln = ComposerAdvisoryParser + .parseAdvisory(ComposerAdvisoryParserTest.VULN_FOP); + ComposerAdvisoryMirrorTask task = new ComposerAdvisoryMirrorTask(); + List mapVulnerabilityToVulnerableSoftware = task.mapVulnerabilityToVulnerableSoftware(qm, + vuln); + Assert.assertEquals(4, mapVulnerabilityToVulnerableSoftware.size()); +// "affectedVersions": "\u003C1.8.1|\u003E=1.9.0,\u003C1.9.1|\u003E=1.10,\u003C1.10.3|\u003E=2.0,\u003C2.3.3", + assertThat(mapVulnerabilityToVulnerableSoftware).satisfiesExactlyInAnyOrder( + range -> { + assertThat(range.getVersionStartIncluding()).isNull(); + assertThat(range.getVersionStartExcluding()).isNull(); + assertThat(range.getVersionEndIncluding()).isNull(); + assertThat(range.getVersionEndExcluding()).isEqualTo("1.8.1"); + }, + range -> { + assertThat(range.getVersionStartIncluding()).isEqualTo("1.9.0"); + assertThat(range.getVersionStartExcluding()).isNull(); + assertThat(range.getVersionEndIncluding()).isNull(); + assertThat(range.getVersionEndExcluding()).isEqualTo("1.9.1"); + }, + range -> { + assertThat(range.getVersionStartIncluding()).isEqualTo("1.10"); + assertThat(range.getVersionStartExcluding()).isNull(); + assertThat(range.getVersionEndIncluding()).isNull(); + assertThat(range.getVersionEndExcluding()).isEqualTo("1.10.3"); + }, + range -> { + assertThat(range.getVersionStartIncluding()).isEqualTo("2.0"); + assertThat(range.getVersionStartExcluding()).isNull(); + assertThat(range.getVersionEndIncluding()).isNull(); + assertThat(range.getVersionEndExcluding()).isEqualTo("2.3.3"); + }); + } + + @Test + public void testDrupalWildcardAffectedVersionMapping() throws IOException { + ComposerAdvisory vuln = ComposerAdvisoryParser + .parseAdvisory(ComposerAdvisoryParserTest.VULN_WILDCARD_ALL); + ComposerAdvisoryMirrorTask task = new ComposerAdvisoryMirrorTask(); + List mapVulnerabilityToVulnerableSoftware = task.mapVulnerabilityToVulnerableSoftware(qm, + vuln); + Assert.assertEquals(1, mapVulnerabilityToVulnerableSoftware.size()); + assertThat(mapVulnerabilityToVulnerableSoftware).satisfiesExactlyInAnyOrder( + range -> { + assertThat(range.getVersionStartIncluding()).isNull(); + assertThat(range.getVersionStartExcluding()).isNull(); + assertThat(range.getVersionEndIncluding()).isNull(); + assertThat(range.getVersionEndExcluding()).isEqualTo("999.999.999"); + }); + } + + @Test + public void testDrupalExactVersionMapping() throws IOException { + ComposerAdvisory vuln = ComposerAdvisoryParser + .parseAdvisory(ComposerAdvisoryParserTest.VULN_EXACT_VERSION); + ComposerAdvisoryMirrorTask task = new ComposerAdvisoryMirrorTask(); + List mapVulnerabilityToVulnerableSoftware = task.mapVulnerabilityToVulnerableSoftware(qm, + vuln); + Assert.assertEquals(1, mapVulnerabilityToVulnerableSoftware.size()); + assertThat(mapVulnerabilityToVulnerableSoftware).satisfiesExactlyInAnyOrder( + range -> { + assertThat(range.getVersionStartIncluding()).isEqualTo("8.1.0"); + assertThat(range.getVersionStartExcluding()).isNull(); + assertThat(range.getVersionEndIncluding()).isEqualTo("8.1.0"); + assertThat(range.getVersionEndExcluding()).isNull(); + }); + } + + @Test + public void testDrupalAdvisory() throws Exception { + doDrupalAdvisory(true); + } + + @Test + public void testDrupalAdvisorySkipAliases() throws Exception { + doDrupalAdvisory(false); + } + + public void doDrupalAdvisory(boolean aliasSync) throws Exception { + ComposerAdvisory advisory = ComposerAdvisoryParser.parseAdvisory(ComposerAdvisoryParserTest.VULN_DRUPAL); + Assert.assertNotNull(advisory); + + ComposerAdvisoryMirrorTask task = new ComposerAdvisoryMirrorTask(); + + task.processAdvisory(qm, advisory, aliasSync); + + final Consumer assertVulnerability = (vulnerability) -> { + Assert.assertNotNull(vulnerability); + Assert.assertEquals("SA-CORE-2018-003", vulnerability.getVulnId()); + Assert.assertEquals("DRUPAL", vulnerability.getSource()); + Assert.assertEquals(">= 8.0.0 <8.4.7 || >=8.5.0 <8.5.2", vulnerability.getVulnerableVersions()); + Assert.assertFalse(StringUtils.isEmpty(vulnerability.getTitle())); + Assert.assertFalse(StringUtils.isEmpty(vulnerability.getDescription())); + Assert.assertEquals(Severity.UNASSIGNED, vulnerability.getSeverity()); + Assert.assertNull(vulnerability.getCreated()); + Assert.assertNotNull(vulnerability.getPublished()); + Assert.assertEquals(LocalDateTime.of(2018, 4, 18, 15, 34, 9).toInstant(ZoneOffset.UTC), + vulnerability.getPublished().toInstant()); + Assert.assertNotNull(vulnerability.getUpdated()); + Assert.assertEquals(LocalDateTime.of(2018, 4, 18, 15, 34, 9).toInstant(ZoneOffset.UTC), + vulnerability.getUpdated().toInstant()); + }; + + Vulnerability vulnerability = qm.getVulnerabilityByVulnId("DRUPAL", "SA-CORE-2018-003", true); + assertVulnerability.accept(vulnerability); + + List vulnerableSoftware = qm.getAllVulnerableSoftwareByPurl( + new PackageURL("pkg:composer/drupal/core")); + Assert.assertEquals(2, vulnerableSoftware.size()); + Assert.assertEquals("8.0.0", vulnerableSoftware.get(0).getVersionStartIncluding()); + Assert.assertEquals("8.4.7", vulnerableSoftware.get(0).getVersionEndExcluding()); + Assert.assertEquals("8.5.0", vulnerableSoftware.get(1).getVersionStartIncluding()); + Assert.assertEquals("8.5.2", vulnerableSoftware.get(1).getVersionEndExcluding()); + + final List aliases = qm.getVulnerabilityAliases(vulnerability); + Assert.assertEquals(aliasSync ? 1 : 0, aliases.size()); + if (aliasSync) { + assertThat(aliases).satisfiesExactly( + alias -> { + assertNull(alias.getComposerId()); + assertEquals("CVE-2018-9861", alias.getCveId()); + assertNull(alias.getGhsaId()); + assertEquals("SA-CORE-2018-003", alias.getDrupalId()); + assertNull(alias.getGsdId()); + assertNull(alias.getInternalId()); + assertNull(alias.getOsvId()); + assertNull(alias.getSnykId()); + assertNull(alias.getSonatypeId()); + assertNull(alias.getVulnDbId()); + }); + } + } + + @Test + public void testFop() throws Exception { + doFop(true); + } + + @Test + public void testFopSkipAliases() throws Exception { + doFop(false); + } + + public void doFop(boolean aliasSync) throws Exception { + ComposerAdvisory advisory = ComposerAdvisoryParser.parseAdvisory(ComposerAdvisoryParserTest.VULN_FOP); + Assert.assertNotNull(advisory); + + ComposerAdvisoryMirrorTask task = new ComposerAdvisoryMirrorTask(); + + task.processAdvisory(qm, advisory, aliasSync); + + final Consumer assertVulnerability = (vulnerability) -> { + Assert.assertNotNull(vulnerability); + Assert.assertEquals("GHSA-r8v4-7vwj-983x", vulnerability.getVulnId()); + Assert.assertEquals("GITHUB", vulnerability.getSource()); + Assert.assertEquals("<1.8.1|>=1.9.0,<1.9.1|>=1.10,<1.10.3|>=2.0,<2.3.3", + vulnerability.getVulnerableVersions()); + Assert.assertFalse(StringUtils.isEmpty(vulnerability.getTitle())); + Assert.assertFalse(StringUtils.isEmpty(vulnerability.getDescription())); + Assert.assertEquals(Severity.CRITICAL, vulnerability.getSeverity()); + Assert.assertNull(vulnerability.getCreated()); + Assert.assertNotNull(vulnerability.getPublished()); + Assert.assertEquals(LocalDateTime.of(2016, 11, 29, 13, 12, 44).toInstant(ZoneOffset.UTC), + vulnerability.getPublished().toInstant()); + Assert.assertNotNull(vulnerability.getUpdated()); + Assert.assertEquals(LocalDateTime.of(2016, 11, 29, 13, 12, 44).toInstant(ZoneOffset.UTC), + vulnerability.getUpdated().toInstant()); + }; + + Vulnerability vulnerability = qm.getVulnerabilityByVulnId(Vulnerability.Source.GITHUB, "GHSA-r8v4-7vwj-983x", true); + assertVulnerability.accept(vulnerability); + + List vulnerableSoftware = qm.getAllVulnerableSoftwareByPurl( + new PackageURL("pkg:composer/simplesamlphp/saml2")); + + Assert.assertEquals(4, vulnerableSoftware.size()); + Assert.assertEquals("1.8.1", vulnerableSoftware.get(0).getVersionEndExcluding()); + Assert.assertEquals("1.9.0", vulnerableSoftware.get(1).getVersionStartIncluding()); + Assert.assertEquals("1.9.1", vulnerableSoftware.get(1).getVersionEndExcluding()); + //Is this ok, or should it become 1.10.0? + Assert.assertEquals("1.10", vulnerableSoftware.get(2).getVersionStartIncluding()); + Assert.assertEquals("1.10.3", vulnerableSoftware.get(2).getVersionEndExcluding()); + Assert.assertEquals("2.0", vulnerableSoftware.get(3).getVersionStartIncluding()); + Assert.assertEquals("2.3.3", vulnerableSoftware.get(3).getVersionEndExcluding()); + + final List aliases = qm.getVulnerabilityAliases(vulnerability); + Assert.assertEquals(aliasSync ? 1 : 0, aliases.size()); + if (aliasSync) { + assertThat(aliases).satisfiesExactly( + alias -> { + assertNull(alias.getComposerId()); + assertEquals("CVE-2016-9814", alias.getCveId()); + assertEquals("GHSA-r8v4-7vwj-983x", alias.getGhsaId()); + assertNull(alias.getDrupalId()); + assertNull(alias.getGsdId()); + assertNull(alias.getInternalId()); + assertNull(alias.getOsvId()); + assertNull(alias.getSnykId()); + assertNull(alias.getSonatypeId()); + assertNull(alias.getVulnDbId()); + }); + } + } + + @Test + public void testFopCve() throws Exception { + doFop(true); + } + + @Test + public void testFopCveSkipAliases() throws Exception { + doFop(false); + } + + public void doFopCve(boolean aliasSync) throws Exception { + ComposerAdvisory advisory = ComposerAdvisoryParser.parseAdvisory(ComposerAdvisoryParserTest.VULN_FOP_CVE); + Assert.assertNotNull(advisory); + + ComposerAdvisoryMirrorTask task = new ComposerAdvisoryMirrorTask(); + + task.processAdvisory(qm, advisory, aliasSync); + + final Consumer assertVulnerability = (vulnerability) -> { + Assert.assertNotNull(vulnerability); + Assert.assertEquals("CVE-2016-9814", vulnerability.getVulnId()); + Assert.assertEquals("NVD", vulnerability.getSource()); + Assert.assertEquals("<1.8.1|>=1.9.0,<1.9.1|>=1.10,<1.10.3|>=2.0,<2.3.3", + vulnerability.getVulnerableVersions()); + Assert.assertFalse(StringUtils.isEmpty(vulnerability.getTitle())); + Assert.assertFalse(StringUtils.isEmpty(vulnerability.getDescription())); + Assert.assertEquals(Severity.CRITICAL, vulnerability.getSeverity()); + Assert.assertNull(vulnerability.getCreated()); + Assert.assertNotNull(vulnerability.getPublished()); + Assert.assertEquals(LocalDateTime.of(2016, 11, 29, 13, 12, 44).toInstant(ZoneOffset.UTC), + vulnerability.getPublished().toInstant()); + Assert.assertNotNull(vulnerability.getUpdated()); + Assert.assertEquals(LocalDateTime.of(2016, 11, 29, 13, 12, 44).toInstant(ZoneOffset.UTC), + vulnerability.getUpdated().toInstant()); + }; + + Vulnerability vulnerability = qm.getVulnerabilityByVulnId(Vulnerability.Source.NVD, "CVE-2016-9814", true); + assertVulnerability.accept(vulnerability); + + List vulnerableSoftware = qm.getAllVulnerableSoftwareByPurl( + new PackageURL("pkg:composer/simplesamlphp/saml2")); + + Assert.assertEquals(4, vulnerableSoftware.size()); + Assert.assertEquals("1.8.1", vulnerableSoftware.get(0).getVersionEndExcluding()); + Assert.assertEquals("1.9.0", vulnerableSoftware.get(1).getVersionStartIncluding()); + Assert.assertEquals("1.9.1", vulnerableSoftware.get(1).getVersionEndExcluding()); + //Is this ok, or should it become 1.10.0? + Assert.assertEquals("1.10", vulnerableSoftware.get(2).getVersionStartIncluding()); + Assert.assertEquals("1.10.3", vulnerableSoftware.get(2).getVersionEndExcluding()); + Assert.assertEquals("2.0", vulnerableSoftware.get(3).getVersionStartIncluding()); + Assert.assertEquals("2.3.3", vulnerableSoftware.get(3).getVersionEndExcluding()); + + final List aliases = qm.getVulnerabilityAliases(vulnerability); + Assert.assertEquals(aliasSync ? 1 : 0, aliases.size()); + if (aliasSync) { + assertThat(aliases).satisfiesExactly( + alias -> { + assertNull(alias.getComposerId()); + assertEquals("CVE-2016-9814", alias.getCveId()); + assertNull(alias.getDrupalId()); + assertNull(alias.getGsdId()); + assertNull(alias.getInternalId()); + assertNull(alias.getOsvId()); + assertNull(alias.getSnykId()); + assertNull(alias.getSonatypeId()); + assertNull(alias.getVulnDbId()); + }); + } + } + + + @Test + public void testFopNoCve() throws Exception { + doFopNoCve(true); + } + + @Test + public void testFopNoCveSkipAliases() throws Exception { + doFopNoCve(false); + } + + public void doFopNoCve(boolean aliasSync) throws Exception { + ComposerAdvisory advisory = ComposerAdvisoryParser.parseAdvisory(ComposerAdvisoryParserTest.VULN_FOP_NO_CVE); + Assert.assertNotNull(advisory); + + ComposerAdvisoryMirrorTask task = new ComposerAdvisoryMirrorTask(); + + task.processAdvisory(qm, advisory, aliasSync); + + final Consumer assertVulnerability = (vulnerability) -> { + Assert.assertNotNull(vulnerability); + Assert.assertEquals("GHSA-7v68-3pr5-h3cr", vulnerability.getVulnId()); + Assert.assertEquals("GITHUB", vulnerability.getSource()); + Assert.assertEquals(">=8.0.0,<8.1.0|>=8.1.0,<8.2.0|>=8.2.0,<8.3.0|>=8.3.0,<8.4.0|>=8.4.0,<8.5.0|>=8.5.0,<8.6.0|>=8.6.0,<8.7.0|>=8.7.0,<8.7.11|>=8.8.0,<8.8.1", + vulnerability.getVulnerableVersions()); + Assert.assertFalse(StringUtils.isEmpty(vulnerability.getTitle())); + Assert.assertFalse(StringUtils.isEmpty(vulnerability.getDescription())); + Assert.assertEquals(Severity.CRITICAL, vulnerability.getSeverity()); + Assert.assertNull(vulnerability.getCreated()); + Assert.assertNotNull(vulnerability.getPublished()); + Assert.assertEquals(LocalDateTime.of(2019, 12, 18, 0, 0, 0).toInstant(ZoneOffset.UTC), + vulnerability.getPublished().toInstant()); + Assert.assertNotNull(vulnerability.getUpdated()); + Assert.assertEquals(LocalDateTime.of(2019, 12, 18, 0, 0, 0).toInstant(ZoneOffset.UTC), + vulnerability.getUpdated().toInstant()); + }; + + Vulnerability vulnerability = qm.getVulnerabilityByVulnId(Vulnerability.Source.GITHUB, "GHSA-7v68-3pr5-h3cr", true); + assertVulnerability.accept(vulnerability); + + List vulnerableSoftware = qm.getAllVulnerableSoftwareByPurl( + new PackageURL("pkg:composer/drupal/core")); + + Assert.assertEquals(9, vulnerableSoftware.size()); + Assert.assertEquals("8.0.0", vulnerableSoftware.get(0).getVersionStartIncluding()); + Assert.assertEquals("8.1.0", vulnerableSoftware.get(0).getVersionEndExcluding()); + Assert.assertEquals("8.1.0", vulnerableSoftware.get(1).getVersionStartIncluding()); + Assert.assertEquals("8.2.0", vulnerableSoftware.get(1).getVersionEndExcluding()); + //Is this ok, or should it become 1.10.0? + Assert.assertEquals("8.2.0", vulnerableSoftware.get(2).getVersionStartIncluding()); + Assert.assertEquals("8.3.0", vulnerableSoftware.get(2).getVersionEndExcluding()); + Assert.assertEquals("8.3.0", vulnerableSoftware.get(3).getVersionStartIncluding()); + Assert.assertEquals("8.4.0", vulnerableSoftware.get(3).getVersionEndExcluding()); + + Assert.assertEquals("8.4.0", vulnerableSoftware.get(4).getVersionStartIncluding()); + Assert.assertEquals("8.5.0", vulnerableSoftware.get(4).getVersionEndExcluding()); + Assert.assertEquals("8.5.0", vulnerableSoftware.get(5).getVersionStartIncluding()); + Assert.assertEquals("8.6.0", vulnerableSoftware.get(5).getVersionEndExcluding()); + Assert.assertEquals("8.6.0", vulnerableSoftware.get(6).getVersionStartIncluding()); + Assert.assertEquals("8.7.0", vulnerableSoftware.get(6).getVersionEndExcluding()); + Assert.assertEquals("8.7.0", vulnerableSoftware.get(7).getVersionStartIncluding()); + Assert.assertEquals("8.7.11", vulnerableSoftware.get(7).getVersionEndExcluding()); + Assert.assertEquals("8.8.0", vulnerableSoftware.get(8).getVersionStartIncluding()); + Assert.assertEquals("8.8.1", vulnerableSoftware.get(8).getVersionEndExcluding()); + + final List aliases = qm.getVulnerabilityAliases(vulnerability); + Assert.assertEquals(aliasSync ? 0 : 0, aliases.size()); + } + + @Test + public void testGHSAAdvisory() throws Exception { + doGHSAAdvisory(true); + } + + @Test + public void testGHSAAdvisorySkipAliases() throws Exception { + doGHSAAdvisory(false); + } + + public void doGHSAAdvisory(boolean aliasSync) throws Exception { + ComposerAdvisory advisory = ComposerAdvisoryParser.parseAdvisory(ComposerAdvisoryParserTest.VULN_GHSA); + Assert.assertNotNull(advisory); + + ComposerAdvisoryMirrorTask task = new ComposerAdvisoryMirrorTask(); + + task.processAdvisory(qm, advisory, aliasSync); + + final Consumer assertVulnerability = (vulnerability) -> { + Assert.assertNotNull(vulnerability); + Assert.assertEquals("GHSA-297f-r9w7-w492", vulnerability.getVulnId()); + Assert.assertEquals("GITHUB", vulnerability.getSource()); + Assert.assertEquals("=2.4.4|>=2.4.0,<2.4.3-p3|<2.3.7-p4", + vulnerability.getVulnerableVersions()); + Assert.assertFalse(StringUtils.isEmpty(vulnerability.getTitle())); + Assert.assertFalse(StringUtils.isEmpty(vulnerability.getDescription())); + Assert.assertEquals(Severity.HIGH, vulnerability.getSeverity()); + Assert.assertNull(vulnerability.getCreated()); + Assert.assertNotNull(vulnerability.getPublished()); + Assert.assertEquals(LocalDateTime.of(2022, 10, 20, 19, 00, 29).toInstant(ZoneOffset.UTC), + vulnerability.getPublished().toInstant()); + Assert.assertNotNull(vulnerability.getUpdated()); + Assert.assertEquals(LocalDateTime.of(2022, 10, 20, 19, 00, 29).toInstant(ZoneOffset.UTC), + vulnerability.getUpdated().toInstant()); + }; + + Vulnerability vulnerability = qm.getVulnerabilityByVulnId(Vulnerability.Source.GITHUB, "GHSA-297f-r9w7-w492", true); + assertVulnerability.accept(vulnerability); + + List vulnerableSoftware = qm.getAllVulnerableSoftwareByPurl( + new PackageURL("pkg:composer/magento/community-edition")); + Assert.assertEquals(3, vulnerableSoftware.size()); + Assert.assertEquals("2.4.4", vulnerableSoftware.get(0).getVersionStartIncluding()); + Assert.assertEquals("2.4.4", vulnerableSoftware.get(0).getVersionEndIncluding()); + Assert.assertEquals("2.4.0", vulnerableSoftware.get(1).getVersionStartIncluding()); + Assert.assertEquals("2.4.3-p3", vulnerableSoftware.get(1).getVersionEndExcluding()); + Assert.assertEquals("2.3.7-p4", vulnerableSoftware.get(2).getVersionEndExcluding()); + + final List aliases = qm.getVulnerabilityAliases(vulnerability); + Assert.assertEquals(aliasSync ? 1 : 0, aliases.size()); + if (aliasSync) { + assertThat(aliases).satisfiesExactly( + alias -> { + assertNull(alias.getComposerId()); + assertThat(alias.getCveId().equals("CVE-2022-42344")); + assertThat(alias.getGhsaId().equals("GHSA-r8v4-7vwj-983x")); + assertNull(alias.getDrupalId()); + assertNull(alias.getGsdId()); + assertNull(alias.getInternalId()); + assertNull(alias.getOsvId()); + assertNull(alias.getSnykId()); + assertNull(alias.getSonatypeId()); + assertNull(alias.getVulnDbId()); + }); + } + } + + private Repository setupPackagistAdvisoryMock() throws Exception { + final File packagistRepoRootFile = ComposerMetaAnalyzerTest.getRepoResourceFile("repo.packagist.org", "packages"); + final File advisoryFile = ComposerMetaAnalyzerTest.getRepoResourceFile("repo.packagist.org", "advisories"); + + @SuppressWarnings("resource") + MockServerClient mockClient = new MockServerClient("localhost", mockServer.getPort()); + String mockUrl = String.format("http://localhost:%d", mockServer.getPort()); + mockClient.when( + request() + .withMethod("GET") + .withPath("/packages.json")) + .respond( + response() + .withStatusCode(200) + .withHeader(HttpHeaders.CONTENT_TYPE, + "application/json") + .withBody(getRepoRootForMock(packagistRepoRootFile, mockUrl))); + + mockClient.when( + request() + .withMethod("GET") + .withPath("/api/security-advisories") + .withQueryStringParameter("updatedSince", "100") + ) + .respond( + response() + .withStatusCode(200) + .withHeader(HttpHeaders.CONTENT_TYPE, + "application/json") + .withBody(new String(ComposerMetaAnalyzerTest.getTestData(advisoryFile)))); + + return qm.createRepository(RepositoryType.COMPOSER, "packagist", null, mockUrl, true, false, false, null, null, CONFIG_MIRROR_ENABLED_WITH_ALIAS); + } + + @Test + public void testPackagistAdvisories() throws Exception { + ComposerAdvisoryMirrorTask task = new ComposerAdvisoryMirrorTask(); + + Repository repo = setupPackagistAdvisoryMock(); + + Assert.assertTrue(task.mirrorAdvisories(qm, repo)); + Assert.assertEquals(10, qm.getVulnerabilities().getTotal()); + + //Vulnerabilities should not have PKSA ids if other IDs are present + Assert.assertNull(qm.getVulnerabilityByVulnId(Vulnerability.Source.COMPOSER, "PKSA-q4rt-5vfc-wksb", true)); + Vulnerability vulnerability1 = qm.getVulnerabilityByVulnId(Vulnerability.Source.GITHUB, "GHSA-2697-96mv-3gfm", true); + + Assert.assertNotNull(vulnerability1); + Assert.assertEquals("GHSA-2697-96mv-3gfm", vulnerability1.getVulnId()); + Assert.assertEquals("CVE-2024-50701", vulnerability1.getAliases().get(0).getCveId()); + Assert.assertNull(vulnerability1.getAliases().get(0).getComposerId()); + + Assert.assertEquals("<3.1.3.1", vulnerability1.getVulnerableVersions()); + Assert.assertEquals(1, vulnerability1.getVulnerableSoftware().size()); + Assert.assertNull(vulnerability1.getVulnerableSoftware().get(0).getVersionStartIncluding()); + Assert.assertNull(vulnerability1.getVulnerableSoftware().get(0).getVersionStartExcluding()); + Assert.assertNull(vulnerability1.getVulnerableSoftware().get(0).getVersionEndIncluding()); + Assert.assertEquals("3.1.3.1", vulnerability1.getVulnerableSoftware().get(0).getVersionEndExcluding()); + + } + + @Test + public void testPackagistAdvisoriesExistingGHSA() throws Exception { + ComposerAdvisoryMirrorTask task = new ComposerAdvisoryMirrorTask(); + + var vs1 = new VulnerableSoftware(); + vs1.setPurlType("composer"); + vs1.setPurlNamespace("tltneon"); + vs1.setPurlName("lgsl"); + vs1.setVersionStartIncluding("2.13.0"); + vs1.setVersionEndIncluding("2.13.2.0"); + vs1.setVulnerable(true); + vs1 = qm.persist(vs1); + + var vs2 = new VulnerableSoftware(); + vs2.setPurlType("composer"); + vs2.setPurlNamespace("tltneon"); + vs2.setPurlName("lgsl"); + vs2.setVersionEndExcluding("7.0.0"); + vs2.setVulnerable(true); + vs2 = qm.persist(vs2); + + var existingVuln = new Vulnerability(); + existingVuln.setVulnId("GHSA-xx95-62h6-h7v3"); + existingVuln.setTitle("TITLE THAT SHOULD NOT GET OVERWRITTEN"); + existingVuln.setSource(Vulnerability.Source.GITHUB); + existingVuln.setVulnerableSoftware(List.of(vs1, vs2)); + existingVuln = qm.createVulnerability(existingVuln, false); + qm.updateAffectedVersionAttribution(existingVuln, vs1, Vulnerability.Source.GITHUB); + qm.updateAffectedVersionAttribution(existingVuln, vs2, Vulnerability.Source.GITHUB); + + Repository repo = setupPackagistAdvisoryMock(); + + Assert.assertTrue(task.mirrorAdvisories(qm, repo)); + Assert.assertEquals(10, qm.getVulnerabilities().getTotal()); + + Vulnerability vulnerability1 = qm.getVulnerabilityByVulnId(Vulnerability.Source.GITHUB, "GHSA-xx95-62h6-h7v3", true); + + Assert.assertNotNull(vulnerability1); + + Assert.assertEquals(existingVuln.getTitle(), vulnerability1.getTitle()); + + final List vsList = vulnerability1.getVulnerableSoftware(); + assertThat(vsList).satisfiesExactlyInAnyOrder( + // The version range that was reported by another source must be retained. + // There must be no attribution to OSV for this range. + vs -> { + assertThat(vs.getPurlType()).isEqualTo("composer"); + assertThat(vs.getPurlNamespace()).isEqualTo("tltneon"); + assertThat(vs.getPurlName()).isEqualTo("lgsl"); + assertThat(vs.getPurlVersion()).isNull(); + assertThat(vs.getVersion()).isNull(); + assertThat(vs.getVersionStartIncluding()).isEqualTo("2.13.0"); + assertThat(vs.getVersionStartExcluding()).isNull(); + assertThat(vs.getVersionEndIncluding()).isEqualTo("2.13.2.0"); + assertThat(vs.getVersionEndExcluding()).isNull(); + + final List attributions = qm.getAffectedVersionAttributions(vulnerability1, vs); + assertThat(attributions).satisfiesExactlyInAnyOrder( + attr -> assertThat(attr.getSource()).isEqualTo(Vulnerability.Source.GITHUB) + ); + }, + // The version range reported by both OSV and another source + // must have attributions for both sources. + vs -> { + assertThat(vs.getPurlType()).isEqualTo("composer"); + assertThat(vs.getPurlNamespace()).isEqualTo("tltneon"); + assertThat(vs.getPurlName()).isEqualTo("lgsl"); + assertThat(vs.getPurlVersion()).isNull(); + assertThat(vs.getVersion()).isNull(); + assertThat(vs.getVersionStartIncluding()).isNull(); + assertThat(vs.getVersionStartExcluding()).isNull(); + assertThat(vs.getVersionEndIncluding()).isNull(); + assertThat(vs.getVersionEndExcluding()).isEqualTo("7.0.0"); + + final List attributions = qm.getAffectedVersionAttributions(vulnerability1, vs); + assertThat(attributions).satisfiesExactlyInAnyOrder( + attr -> assertThat(attr.getSource()).isEqualTo(Vulnerability.Source.COMPOSER), + attr -> assertThat(attr.getSource()).isEqualTo(Vulnerability.Source.GITHUB) + ); + }, + // The version range newly reported by COMPOSER must be attributed to only COMPOSER. + vs -> { + assertThat(vs.getPurlType()).isEqualTo("composer"); + assertThat(vs.getPurlNamespace()).isEqualTo("tltneon"); + assertThat(vs.getPurlName()).isEqualTo("lgsl"); + assertThat(vs.getPurlVersion()).isNull(); + assertThat(vs.getVersion()).isNull(); + assertThat(vs.getVersionStartIncluding()).isEqualTo("4.3.0"); + assertThat(vs.getVersionStartExcluding()).isNull(); + assertThat(vs.getVersionEndIncluding()).isEqualTo("4.4.5"); + assertThat(vs.getVersionEndExcluding()).isNull(); + + final List attributions = qm.getAffectedVersionAttributions(vulnerability1, vs); + assertThat(attributions).satisfiesExactly( + attr -> assertThat(attr.getSource()).isEqualTo(Vulnerability.Source.COMPOSER) + ); + } + ); + + } + + @Test + public void testPackagistAdvisoriesNonExistingGHSA() throws Exception { + ComposerAdvisoryMirrorTask task = new ComposerAdvisoryMirrorTask(); + + Repository repo = setupPackagistAdvisoryMock(); + + Assert.assertTrue(task.mirrorAdvisories(qm, repo)); + Assert.assertEquals(10, qm.getVulnerabilities().getTotal()); + + Vulnerability vulnerability1 = qm.getVulnerabilityByVulnId(Vulnerability.Source.GITHUB, "GHSA-xx95-62h6-h7v3", true); + + Assert.assertNotNull(vulnerability1); + + Assert.assertEquals("lgsl Stored Cross-Site Scripting vulnerability", vulnerability1.getTitle()); + + final List vsList = vulnerability1.getVulnerableSoftware(); + assertThat(vsList).satisfiesExactlyInAnyOrder( + vs -> { + assertThat(vs.getPurlType()).isEqualTo("composer"); + assertThat(vs.getPurlNamespace()).isEqualTo("tltneon"); + assertThat(vs.getPurlName()).isEqualTo("lgsl"); + assertThat(vs.getPurlVersion()).isNull(); + assertThat(vs.getVersion()).isNull(); + assertThat(vs.getVersionStartIncluding()).isNull(); + assertThat(vs.getVersionStartExcluding()).isNull(); + assertThat(vs.getVersionEndIncluding()).isNull(); + assertThat(vs.getVersionEndExcluding()).isEqualTo("7.0.0"); + + final List attributions = qm.getAffectedVersionAttributions(vulnerability1, vs); + assertThat(attributions).satisfiesExactlyInAnyOrder( + attr -> assertThat(attr.getSource()).isEqualTo(Vulnerability.Source.COMPOSER) + ); + }, + vs -> { + assertThat(vs.getPurlType()).isEqualTo("composer"); + assertThat(vs.getPurlNamespace()).isEqualTo("tltneon"); + assertThat(vs.getPurlName()).isEqualTo("lgsl"); + assertThat(vs.getPurlVersion()).isNull(); + assertThat(vs.getVersion()).isNull(); + assertThat(vs.getVersionStartIncluding()).isEqualTo("4.3.0"); + assertThat(vs.getVersionStartExcluding()).isNull(); + assertThat(vs.getVersionEndIncluding()).isEqualTo("4.4.5"); + assertThat(vs.getVersionEndExcluding()).isNull(); + + final List attributions = qm.getAffectedVersionAttributions(vulnerability1, vs); + assertThat(attributions).satisfiesExactly( + attr -> assertThat(attr.getSource()).isEqualTo(Vulnerability.Source.COMPOSER) + ); + } + + ); + + } + + + private Repository setupDrupalAdvisoryMock() throws Exception { + final File packagistRepoRootFile = ComposerMetaAnalyzerTest.getRepoResourceFile("packages.drupal.org", "packages"); + final File advisoryFile = ComposerMetaAnalyzerTest.getRepoResourceFile("packages.drupal.org", "advisories"); + + @SuppressWarnings("resource") + MockServerClient mockClient = new MockServerClient("localhost", mockServer.getPort()); + String mockUrl = String.format("http://localhost:%d", mockServer.getPort()); + mockClient.when( + request() + .withMethod("GET") + .withPath("/packages.json")) + .respond( + response() + .withStatusCode(200) + .withHeader(HttpHeaders.CONTENT_TYPE, + "application/json") + .withBody(getRepoRootForMock(packagistRepoRootFile, mockUrl))); + + mockClient.when( + request() + .withMethod("GET") + .withPath("/api/security-advisories") + .withQueryStringParameter("updatedSince", "100") + ) + .respond( + response() + .withStatusCode(200) + .withHeader(HttpHeaders.CONTENT_TYPE, + "application/json") + .withBody(new String(ComposerMetaAnalyzerTest.getTestData(advisoryFile)))); + + return qm.createRepository(RepositoryType.COMPOSER, "drupal8", null, mockUrl, true, false, false, null, null, CONFIG_MIRROR_ENABLED_WITH_ALIAS); + } + + + @Test + public void testDrupalAdvisories() throws Exception { + ComposerAdvisoryMirrorTask task = new ComposerAdvisoryMirrorTask(); + + Repository repo = setupDrupalAdvisoryMock(); + + Assert.assertTrue(task.mirrorAdvisories(qm, repo)); + Assert.assertEquals(14, qm.getVulnerabilities().getTotal()); + + Vulnerability vulnerability1 = qm.getVulnerabilityByVulnId(Vulnerability.Source.DRUPAL, "SA-CORE-2018-002", true); + + Assert.assertNotNull(vulnerability1); + Assert.assertEquals("SA-CORE-2018-002", vulnerability1.getVulnId()); + Assert.assertEquals("SA-CORE-2018-002", vulnerability1.getAliases().get(0).getDrupalId()); + Assert.assertEquals("CVE-2018-7600", vulnerability1.getAliases().get(0).getCveId()); + Assert.assertNull(vulnerability1.getAliases().get(0).getComposerId()); + + Assert.assertEquals(">=7.0 <7.58", vulnerability1.getVulnerableVersions()); + Assert.assertEquals(1, vulnerability1.getVulnerableSoftware().size()); + Assert.assertEquals("7.0", vulnerability1.getVulnerableSoftware().get(0).getVersionStartIncluding()); + Assert.assertNull(vulnerability1.getVulnerableSoftware().get(0).getVersionStartExcluding()); + Assert.assertNull(vulnerability1.getVulnerableSoftware().get(0).getVersionEndIncluding()); + Assert.assertEquals("7.58", vulnerability1.getVulnerableSoftware().get(0).getVersionEndExcluding()); + } + + private String getRepoRootForMock(File file, String mockUrl) throws Exception { + String data = new String(ComposerMetaAnalyzerTest.getTestData(file)); + JSONObject json = new JSONObject(data); + + json.getJSONObject("security-advisories").put("api-url", mockUrl + "/api/security-advisories"); + return json.toString(); + } + + +} diff --git a/src/test/java/org/dependencytrack/tasks/repositories/ComposerMetaAnalyzerTest.java b/src/test/java/org/dependencytrack/tasks/repositories/ComposerMetaAnalyzerTest.java index b071dd5896..930b88b66f 100644 --- a/src/test/java/org/dependencytrack/tasks/repositories/ComposerMetaAnalyzerTest.java +++ b/src/test/java/org/dependencytrack/tasks/repositories/ComposerMetaAnalyzerTest.java @@ -774,7 +774,7 @@ public void testAnalyzerGetsUnexpectedResponseContent404() throws Exception { Assert.assertNull(metaModel.getLatestVersion()); } - private static File getRepoResourceFile(String repo, String filename) throws Exception { + public static File getRepoResourceFile(String repo, String filename) throws Exception { String filenameResource = String.format( "unit/tasks/repositories/https---%s-%s.json", repo, @@ -782,7 +782,7 @@ private static File getRepoResourceFile(String repo, String filename) throws Exc return getFileResource(filenameResource); } - private static File getPackageResourceFile(String repo, String namespace, String name) throws Exception { + public static File getPackageResourceFile(String repo, String namespace, String name) throws Exception { String filename = String.format( "unit/tasks/repositories/https---%s-%s-%s.json", repo, @@ -791,18 +791,19 @@ private static File getPackageResourceFile(String repo, String namespace, String return getFileResource(filename); } - private static File getFileResource(String filename) throws Exception { + public static File getFileResource(String filename) throws Exception { return new File( Thread.currentThread().getContextClassLoader() .getResource(filename) .toURI()); } - private static byte[] getTestData(File file) throws Exception { + public static byte[] getTestData(File file) throws Exception { final FileInputStream fileStream = new FileInputStream(file); byte[] data = new byte[(int) file.length()]; fileStream.read(data); fileStream.close(); return data; } + } diff --git a/src/test/resources/unit/tasks/repositories/https---packages.drupal.org-advisories.json b/src/test/resources/unit/tasks/repositories/https---packages.drupal.org-advisories.json new file mode 100644 index 0000000000..d4cff79931 --- /dev/null +++ b/src/test/resources/unit/tasks/repositories/https---packages.drupal.org-advisories.json @@ -0,0 +1,244 @@ +{ + "advisories": { + "drupal/permissions_by_term": [ + { + "advisoryId": "SA-CONTRIB-2017-082", + "packageName": "drupal/permissions_by_term", + "title": "Permissions by Term - Moderately critical - Access bypass - SA-CONTRIB-2017-082", + "link": "https://www.drupal.org/sa-contrib-2017-082", + "cve": null, + "affectedVersions": "\u003C1.35.0", + "reportedAt": "2017-11-08 17:16:30", + "composerRepository": "http://packages.drupal.org/8/", + "sources": [ + { + "name": "Permissions by Term - Moderately critical - Access bypass - SA-CONTRIB-2017-082", + "remoteId": "SA-CONTRIB-2017-082" + } + ] + }, + { + "advisoryId": "SA-CONTRIB-2019-068", + "packageName": "drupal/permissions_by_term", + "title": "Permissions by Term - Moderately critical - Access bypass - SA-CONTRIB-2019-068", + "link": "https://www.drupal.org/sa-contrib-2019-068", + "cve": null, + "affectedVersions": "\u003C2.11.0", + "reportedAt": "2019-09-25 14:43:49", + "composerRepository": "http://packages.drupal.org/8/", + "sources": [ + { + "name": "Permissions by Term - Moderately critical - Access bypass - SA-CONTRIB-2019-068", + "remoteId": "SA-CONTRIB-2019-068" + } + ] + }, + { + "advisoryId": "SA-CONTRIB-2019-095", + "packageName": "drupal/permissions_by_term", + "title": "Permissions by Term - Moderately critical - Access bypass - SA-CONTRIB-2019-095", + "link": "https://www.drupal.org/sa-contrib-2019-095", + "cve": null, + "affectedVersions": "\u003C2.0.0", + "reportedAt": "2019-12-11 18:59:46", + "composerRepository": "http://packages.drupal.org/8/", + "sources": [ + { + "name": "Permissions by Term - Moderately critical - Access bypass - SA-CONTRIB-2019-095", + "remoteId": "SA-CONTRIB-2019-095" + } + ] + }, + { + "advisoryId": "SA-CONTRIB-2022-055", + "packageName": "drupal/permissions_by_term", + "title": "Permissions by Term - Moderately critical - Access bypass - SA-CONTRIB-2022-055", + "link": "https://www.drupal.org/sa-contrib-2022-055", + "cve": null, + "affectedVersions": "\u003C3.1.19", + "reportedAt": "2022-09-07 17:04:31", + "composerRepository": "http://packages.drupal.org/8/", + "sources": [ + { + "name": "Permissions by Term - Moderately critical - Access bypass - SA-CONTRIB-2022-055", + "remoteId": "SA-CONTRIB-2022-055" + } + ] + }, + { + "advisoryId": "SA-CONTRIB-2022-056", + "packageName": "drupal/permissions_by_term", + "title": "Permissions by Term - Moderately critical - Access bypass - SA-CONTRIB-2022-056", + "link": "https://www.drupal.org/sa-contrib-2022-056", + "cve": null, + "affectedVersions": "\u003C3.1.19", + "reportedAt": "2022-09-07 17:06:06", + "composerRepository": "http://packages.drupal.org/8/", + "sources": [ + { + "name": "Permissions by Term - Moderately critical - Access bypass - SA-CONTRIB-2022-056", + "remoteId": "SA-CONTRIB-2022-056" + } + ] + } + ], + "drupal/config_perms": [ + { + "advisoryId": "SA-CONTRIB-2017-083", + "packageName": "drupal/config_perms", + "title": "Custom Permissions - Moderately critical - Access bypass - SA-CONTRIB-2017-083", + "link": "https://www.drupal.org/sa-contrib-2017-083", + "cve": null, + "affectedVersions": "\u003C1.1.0", + "reportedAt": "2017-11-08 17:22:08", + "composerRepository": "http://packages.drupal.org/8/", + "sources": [ + { + "name": "Custom Permissions - Moderately critical - Access bypass - SA-CONTRIB-2017-083", + "remoteId": "SA-CONTRIB-2017-083" + } + ] + }, + { + "advisoryId": "SA-CONTRIB-2019-055", + "packageName": "drupal/config_perms", + "title": "Custom Permissions - Critical - Access bypass - SA-CONTRIB-2019-055", + "link": "https://www.drupal.org/sa-contrib-2019-055", + "cve": null, + "affectedVersions": "\u003C1.2.0", + "reportedAt": "2019-07-10 16:30:00", + "composerRepository": "http://packages.drupal.org/8/", + "sources": [ + { + "name": "Custom Permissions - Critical - Access bypass - SA-CONTRIB-2019-055", + "remoteId": "SA-CONTRIB-2019-055" + } + ] + } + ], + "drupal/config_update": [ + { + "advisoryId": "SA-CONTRIB-2017-091", + "packageName": "drupal/config_update", + "title": "Configuration Update Manager - Moderately critical - Cross Site Request Forgery (CSRF) - SA-CONTRIB-2017-091", + "link": "https://www.drupal.org/sa-contrib-2017-091", + "cve": null, + "affectedVersions": "\u003C1.5", + "reportedAt": "2017-12-06 18:44:03", + "composerRepository": "http://packages.drupal.org/8/", + "sources": [ + { + "name": "Configuration Update Manager - Moderately critical - Cross Site Request Forgery (CSRF) - SA-CONTRIB-2017-091", + "remoteId": "SA-CONTRIB-2017-091" + } + ] + } + ], + "drupal/link_click_count": [ + { + "advisoryId": "SA-CONTRIB-2017-094", + "packageName": "drupal/link_click_count", + "title": "Link Click Count - Critical - Unsupported - SA-CONTRIB-2017-094", + "link": "https://www.drupal.org/sa-contrib-2017-094", + "cve": null, + "affectedVersions": "*", + "reportedAt": "2017-12-20 14:12:47", + "composerRepository": "http://packages.drupal.org/8/", + "sources": [ + { + "name": "Link Click Count - Critical - Unsupported - SA-CONTRIB-2017-094", + "remoteId": "SA-CONTRIB-2017-094" + } + ] + } + ], + "drupal/stacks": [ + { + "advisoryId": "SA-CONTRIB-2018-001", + "packageName": "drupal/stacks", + "title": "Stacks - Critical - Arbitrary PHP code execution - SA-CONTRIB-2018-001", + "link": "https://www.drupal.org/sa-contrib-2018-001", + "cve": null, + "affectedVersions": "\u003C1.1.0", + "reportedAt": "2018-01-10 17:57:53", + "composerRepository": "http://packages.drupal.org/8/", + "sources": [ + { + "name": "Stacks - Critical - Arbitrary PHP code execution - SA-CONTRIB-2018-001", + "remoteId": "SA-CONTRIB-2018-001" + } + ] + } + ], + "drupal/node_view_permissions": [ + { + "advisoryId": "SA-CONTRIB-2018-002", + "packageName": "drupal/node_view_permissions", + "title": "Node View Permissions - Moderately critical - Access Bypass - SA-CONTRIB-2018-002", + "link": "https://www.drupal.org/sa-contrib-2018-002", + "cve": null, + "affectedVersions": "\u003C1.1.0", + "reportedAt": "2018-01-10 18:02:19", + "composerRepository": "http://packages.drupal.org/8/", + "sources": [ + { + "name": "Node View Permissions - Moderately critical - Access Bypass - SA-CONTRIB-2018-002", + "remoteId": "SA-CONTRIB-2018-002" + } + ] + } + ], + "drupal/entity_ref_tab_formatter": [ + { + "advisoryId": "SA-CONTRIB-2018-008", + "packageName": "drupal/entity_ref_tab_formatter", + "title": "Entity Reference Tab / Accordion Formatter - Moderately critical - Cross Site Scripting - SA-CONTRIB-2018-008", + "link": "https://www.drupal.org/sa-contrib-2018-008", + "cve": null, + "affectedVersions": "\u003C1.3.0", + "reportedAt": "2018-02-07 18:45:12", + "composerRepository": "http://packages.drupal.org/8/", + "sources": [ + { + "name": "Entity Reference Tab / Accordion Formatter - Moderately critical - Cross Site Scripting - SA-CONTRIB-2018-008", + "remoteId": "SA-CONTRIB-2018-008" + } + ] + } + ], + "drupal/core": [ + { + "advisoryId": "SA-CORE-2018-001", + "packageName": "drupal/core", + "title": "Drupal core - Critical - Multiple Vulnerabilities - SA-CORE-2018-001", + "link": "https://www.drupal.org/sa-core-2018-001", + "cve": null, + "affectedVersions": "\u003E=7.0 \u003C7.57 || \u003E= 8.0.0 \u003C8.4.5", + "reportedAt": "2018-02-21 17:10:55", + "composerRepository": "https://packagist.org/", + "sources": [ + { + "name": "Drupal core - Critical - Multiple Vulnerabilities - SA-CORE-2018-001", + "remoteId": "SA-CORE-2018-001" + } + ] + }, + { + "advisoryId": "SA-CORE-2018-002", + "packageName": "drupal/core", + "title": "Drupal core - Highly critical - Remote Code Execution - SA-CORE-2018-002", + "link": "https://www.drupal.org/sa-core-2018-002", + "cve": "CVE-2018-7600", + "affectedVersions": "\u003E=7.0 \u003C7.58", + "reportedAt": "2018-03-28 18:14:10", + "composerRepository": "https://packagist.org/", + "sources": [ + { + "name": "Drupal core - Highly critical - Remote Code Execution - SA-CORE-2018-002", + "remoteId": "SA-CORE-2018-002" + } + ] + } + ] + } +} \ No newline at end of file diff --git a/src/test/resources/unit/tasks/repositories/https---repo.packagist.org-advisories.json b/src/test/resources/unit/tasks/repositories/https---repo.packagist.org-advisories.json new file mode 100644 index 0000000000..c15eb91ea7 --- /dev/null +++ b/src/test/resources/unit/tasks/repositories/https---repo.packagist.org-advisories.json @@ -0,0 +1,200 @@ +{ + "advisories": { + "nilsteampassnet/teampass": [ + { + "advisoryId": "PKSA-q4rt-5vfc-wksb", + "packageName": "nilsteampassnet/teampass", + "remoteId": "GHSA-2697-96mv-3gfm", + "title": "TeamPass does not properly check whether a folder is in a user's allowed folders list", + "link": "https://github.com/advisories/GHSA-2697-96mv-3gfm", + "cve": "CVE-2024-50701", + "affectedVersions": "\u003C3.1.3.1", + "source": "GitHub", + "reportedAt": "2024-12-30 15:31:59", + "composerRepository": "https://packagist.org", + "severity": "medium", + "sources": [ + { + "name": "GitHub", + "remoteId": "GHSA-2697-96mv-3gfm" + } + ] + }, + { + "advisoryId": "PKSA-r8k5-qv9m-hf6j", + "packageName": "nilsteampassnet/teampass", + "remoteId": "GHSA-7rm3-4w6j-8xx4", + "title": "TeamPass mail_me operation authorization issue", + "link": "https://github.com/advisories/GHSA-7rm3-4w6j-8xx4", + "cve": "CVE-2024-50702", + "affectedVersions": "\u003C3.1.3.1", + "source": "GitHub", + "reportedAt": "2024-12-30 15:31:59", + "composerRepository": "https://packagist.org", + "severity": "medium", + "sources": [ + { + "name": "GitHub", + "remoteId": "GHSA-7rm3-4w6j-8xx4" + } + ] + }, + { + "advisoryId": "PKSA-x31v-w4h8-4xrb", + "packageName": "nilsteampassnet/teampass", + "remoteId": "GHSA-9wmc-988h-2mv2", + "title": "TeamPass privileges issue", + "link": "https://github.com/advisories/GHSA-9wmc-988h-2mv2", + "cve": "CVE-2024-50703", + "affectedVersions": "\u003C3.1.3.1", + "source": "GitHub", + "reportedAt": "2024-12-30 15:31:59", + "composerRepository": "https://packagist.org", + "severity": "critical", + "sources": [ + { + "name": "GitHub", + "remoteId": "GHSA-9wmc-988h-2mv2" + } + ] + }, + { + "advisoryId": "PKSA-tmqs-sp77-dd7y", + "packageName": "nilsteampassnet/teampass", + "remoteId": "GHSA-28pv-2j2h-fmhc", + "title": "TeamPass Cross-Site Scripting (XSS)", + "link": "https://github.com/advisories/GHSA-28pv-2j2h-fmhc", + "cve": "CVE-2017-15278", + "affectedVersions": "\u003C2.1.27.9", + "source": "GitHub", + "reportedAt": "2022-05-17 00:29:54", + "composerRepository": "https://packagist.org", + "severity": "medium", + "sources": [ + { + "name": "GitHub", + "remoteId": "GHSA-28pv-2j2h-fmhc" + } + ] + } + ], + "dcat/laravel-admin": [ + { + "advisoryId": "PKSA-6p1r-xpgk-hcz2", + "packageName": "dcat/laravel-admin", + "remoteId": "GHSA-9q34-7hfr-h8jm", + "title": "Dcat Admin Cross-site Scripting (XSS) vulnerability", + "link": "https://github.com/advisories/GHSA-9q34-7hfr-h8jm", + "cve": "CVE-2024-54774", + "affectedVersions": "=2.2.0-beta", + "source": "GitHub", + "reportedAt": "2024-12-28 00:30:43", + "composerRepository": "https://packagist.org", + "severity": "medium", + "sources": [ + { + "name": "GitHub", + "remoteId": "GHSA-9q34-7hfr-h8jm" + } + ] + }, + { + "advisoryId": "PKSA-m82y-5rmz-8w8p", + "packageName": "dcat/laravel-admin", + "remoteId": "GHSA-37x3-j9jq-vrjx", + "title": "Dcat-Admin Cross-Site Scripting (XSS) vulnerability", + "link": "https://github.com/advisories/GHSA-37x3-j9jq-vrjx", + "cve": "CVE-2024-54775", + "affectedVersions": "=2.2.2-beta|=2.2.0-beta", + "source": "GitHub", + "reportedAt": "2024-12-28 00:30:43", + "composerRepository": "https://packagist.org", + "severity": "medium", + "sources": [ + { + "name": "GitHub", + "remoteId": "GHSA-37x3-j9jq-vrjx" + } + ] + }, + { + "advisoryId": "PKSA-98qy-9244-htqs", + "packageName": "dcat/laravel-admin", + "remoteId": "GHSA-mr24-cf69-5chq", + "title": "dcat-admin Cross Site Scripting vulnerability", + "link": "https://github.com/advisories/GHSA-mr24-cf69-5chq", + "cve": "CVE-2024-29644", + "affectedVersions": "\u003C=2.1.3", + "source": "GitHub", + "reportedAt": "2024-03-26 12:31:28", + "composerRepository": "https://packagist.org", + "severity": "medium", + "sources": [ + { + "name": "GitHub", + "remoteId": "GHSA-mr24-cf69-5chq" + } + ] + }, + { + "advisoryId": "PKSA-1b2w-knm8-9nnm", + "packageName": "dcat/laravel-admin", + "remoteId": "GHSA-p74v-mwvg-8ghp", + "title": "Dcat-Admin vulnerable to Stored Cross-site Scripting", + "link": "https://github.com/advisories/GHSA-p74v-mwvg-8ghp", + "cve": "CVE-2023-33736", + "affectedVersions": "\u003C=2.1.3-beta", + "source": "GitHub", + "reportedAt": "2023-05-31 15:30:18", + "composerRepository": "https://packagist.org", + "severity": "medium", + "sources": [ + { + "name": "GitHub", + "remoteId": "GHSA-p74v-mwvg-8ghp" + } + ] + } + ], + "tltneon/lgsl": [ + { + "advisoryId": "PKSA-81bx-8rdn-n435", + "packageName": "tltneon/lgsl", + "remoteId": "GHSA-ggwq-xc72-33r3", + "title": "LGSL has a reflected XSS at /lgsl_files/lgsl_list.php", + "link": "https://github.com/advisories/GHSA-ggwq-xc72-33r3", + "cve": "CVE-2024-56517", + "affectedVersions": "\u003C=6.2.1", + "source": "GitHub", + "reportedAt": "2024-12-30 16:49:28", + "composerRepository": "https://packagist.org", + "severity": "medium", + "sources": [ + { + "name": "GitHub", + "remoteId": "GHSA-ggwq-xc72-33r3" + } + ] + }, + { + "advisoryId": "PKSA-jsdk-m8fr-4rfv", + "packageName": "tltneon/lgsl", + "remoteId": "GHSA-xx95-62h6-h7v3", + "title": "lgsl Stored Cross-Site Scripting vulnerability", + "link": "https://github.com/advisories/GHSA-xx95-62h6-h7v3", + "cve": "CVE-2024-56361", + "affectedVersions": "\u003E=4.3.0,\u003C=4.4.5|\u003C7.0.0", + "source": "GitHub", + "reportedAt": "2024-12-26 20:20:12", + "composerRepository": "https://packagist.org", + "severity": "high", + "sources": [ + { + "name": "GitHub", + "remoteId": "GHSA-xx95-62h6-h7v3" + } + ] + } + ] + } + } \ No newline at end of file