diff --git a/src/main/java/org/dependencytrack/event/kafka/processor/VulnerabilityScanResultProcessor.java b/src/main/java/org/dependencytrack/event/kafka/processor/VulnerabilityScanResultProcessor.java index 7e16f8385..ad682dcb3 100644 --- a/src/main/java/org/dependencytrack/event/kafka/processor/VulnerabilityScanResultProcessor.java +++ b/src/main/java/org/dependencytrack/event/kafka/processor/VulnerabilityScanResultProcessor.java @@ -76,6 +76,7 @@ import static org.dependencytrack.persistence.jdbi.JdbiFactory.jdbi; import static org.dependencytrack.proto.notification.v1.Group.GROUP_NEW_VULNERABILITY; import static org.dependencytrack.proto.notification.v1.Group.GROUP_NEW_VULNERABLE_DEPENDENCY; +import static org.dependencytrack.proto.notification.v1.Group.GROUP_PROJECT_AUDIT_CHANGE; import static org.dependencytrack.proto.notification.v1.Level.LEVEL_INFORMATIONAL; import static org.dependencytrack.proto.notification.v1.Scope.SCOPE_PORTFOLIO; import static org.dependencytrack.proto.vulnanalysis.v1.ScanStatus.SCAN_STATUS_FAILED; @@ -372,13 +373,14 @@ private List synchronizeFindingsAndAnalyses(final QueryManager qm .toList(); dao.createFindingAttributions(findingAttributions); - return maybeApplyPolicyAnalyses(dao, component, vulns, newFindingVulnIds, policiesByVulnUuid); + return maybeApplyPolicyAnalyses(qm, dao, component, vulns, newFindingVulnIds, policiesByVulnUuid); }); } /** * Apply analyses of matched {@link VulnerabilityPolicy}s. Do nothing when no policies matched. * + * @param qm * @param dao The {@link Dao} to use for persistence operations * @param component The {@link Component} to apply analyses for * @param vulns The {@link Vulnerability}s identified for the {@link Component} @@ -387,7 +389,7 @@ private List synchronizeFindingsAndAnalyses(final QueryManager qm * @return A {@link List} of {@link Vulnerability}s, that were not previously associated with the {@link Component}, * and which have not been suppressed via {@link VulnerabilityPolicy}. */ - private List maybeApplyPolicyAnalyses(final Dao dao, final Component component, final Collection vulns, + private List maybeApplyPolicyAnalyses(QueryManager qm, final Dao dao, final Component component, final Collection vulns, final List newFindingVulnIds, final Map policiesByVulnUuid) { // Unless we have any matching vulnerability policies, there's nothing to do! if (policiesByVulnUuid.isEmpty()) { @@ -411,6 +413,7 @@ private List maybeApplyPolicyAnalyses(final Dao dao, final Compon final var analysesToCreateOrUpdate = new ArrayList(); final var analysisCommentsByVulnId = new MultivaluedHashMap(); + for (final Map.Entry vulnUuidAndPolicy : policiesByVulnUuid.entrySet()) { final Vulnerability vuln = vulnByUuid.get(vulnUuidAndPolicy.getKey()); final VulnerabilityPolicy policy = vulnUuidAndPolicy.getValue(); @@ -421,7 +424,6 @@ private List maybeApplyPolicyAnalyses(final Dao dao, final Compon LOGGER.warn("Unable to apply policy %s as it was found to be invalid".formatted(policy.name()), e); continue; } - final Analysis existingAnalysis = existingAnalyses.get(vuln.getUuid()); if (existingAnalysis == null) { policyAnalysis.setComponentId(component.id()); @@ -595,10 +597,6 @@ private List maybeApplyPolicyAnalyses(final Dao dao, final Compon if (!analysesToCreateOrUpdate.isEmpty()) { final List createdAnalyses = dao.createOrUpdateAnalyses(analysesToCreateOrUpdate); - // TODO: Construct notifications for PROJECT_AUDIT_CHANGE, but do not dispatch them here! - // They should be dispatched together with NEW_VULNERABILITY and NEW_VULNERABLE_DEPENDENCY - // notifications, AFTER this database transaction completed successfully. - // Comments for new analyses do not have an analysis ID set yet, as that ID was not known prior // to inserting the respective analysis record. Enrich comments with analysis IDs now that we know them. for (final CreatedAnalysis createdAnalysis : createdAnalyses) { @@ -607,8 +605,10 @@ private List maybeApplyPolicyAnalyses(final Dao dao, final Compon .map(comment -> new AnalysisComment(createdAnalysis.id(), comment.comment(), comment.commenter())) .toList()); } - dao.createAnalysisComments(analysisCommentsByVulnId.values().stream().flatMap(Collection::stream).toList()); + + // dispatch PROJECT_AUDIT_CHANGE notifications + maybeSendProjectAuditChangeNotification(qm, component, createdAnalyses.stream().map(CreatedAnalysis::vulnId).toList()); } return vulnById.entrySet().stream() @@ -668,6 +668,31 @@ private void maybeSendNotifications(final QueryManager qm, final Component compo } } + + private void maybeSendProjectAuditChangeNotification(final QueryManager qm, final Component component, List vulnIds) { + if (vulnIds.isEmpty()) { + return; + } + final Timestamp notificationTimestamp = Timestamps.now(); + final var notifications = new ArrayList(); + jdbi(qm).useExtension(NotificationSubjectDao.class, dao -> { + dao.getForProjectAuditChange(component.uuid(), vulnIds).stream() + .map(subject -> org.dependencytrack.proto.notification.v1.Notification.newBuilder() + .setScope(SCOPE_PORTFOLIO) + .setGroup(GROUP_PROJECT_AUDIT_CHANGE) + .setLevel(LEVEL_INFORMATIONAL) + .setTimestamp(notificationTimestamp) + .setTitle(generateNotificationTitle(NotificationConstants.Title.NEW_VULNERABILITY, subject.getProject())) // TODO + .setContent(generateNotificationContent(subject.getVulnerability())) // TODO + .setSubject(Any.pack(subject)) + .build()) + .forEach(notifications::add); + }); + for (final org.dependencytrack.proto.notification.v1.Notification notification : notifications) { + eventDispatcher.dispatchAsync(component.projectUuid().toString(), notification); + } + } + private boolean canUpdateVulnerability(final Vulnerability vuln, final Scanner scanner) { var canUpdate = true; diff --git a/src/main/java/org/dependencytrack/persistence/jdbi/NotificationSubjectDao.java b/src/main/java/org/dependencytrack/persistence/jdbi/NotificationSubjectDao.java index f87bb7e6c..71b5487a5 100644 --- a/src/main/java/org/dependencytrack/persistence/jdbi/NotificationSubjectDao.java +++ b/src/main/java/org/dependencytrack/persistence/jdbi/NotificationSubjectDao.java @@ -5,9 +5,11 @@ import org.dependencytrack.persistence.jdbi.mapping.NotificationProjectRowMapper; import org.dependencytrack.persistence.jdbi.mapping.NotificationSubjectNewVulnerabilityRowMapper; import org.dependencytrack.persistence.jdbi.mapping.NotificationSubjectNewVulnerableDependencyRowReducer; +import org.dependencytrack.persistence.jdbi.mapping.NotificationSubjectProjectAuditChangeRowMapper; import org.dependencytrack.persistence.jdbi.mapping.NotificationVulnerabilityRowMapper; import org.dependencytrack.proto.notification.v1.NewVulnerabilitySubject; import org.dependencytrack.proto.notification.v1.NewVulnerableDependencySubject; +import org.dependencytrack.proto.notification.v1.VulnerabilityAnalysisDecisionChangeSubject; import org.jdbi.v3.sqlobject.config.RegisterRowMapper; import org.jdbi.v3.sqlobject.config.RegisterRowMappers; import org.jdbi.v3.sqlobject.statement.SqlQuery; @@ -235,4 +237,109 @@ LEFT JOIN LATERAL ( @UseRowReducer(NotificationSubjectNewVulnerableDependencyRowReducer.class) Optional getForNewVulnerableDependency(final UUID componentUuid); + @SqlQuery(""" + SELECT + "C"."UUID" AS "componentUuid", + "C"."GROUP" AS "componentGroup", + "C"."NAME" AS "componentName", + "C"."VERSION" AS "componentVersion", + "C"."PURL" AS "componentPurl", + "C"."MD5" AS "componentMd5", + "C"."SHA1" AS "componentSha1", + "C"."SHA_256" AS "componentSha256", + "C"."SHA_512" AS "componentSha512", + "P"."UUID" AS "projectUuid", + "P"."NAME" AS "projectName", + "P"."VERSION" AS "projectVersion", + "P"."DESCRIPTION" AS "projectDescription", + "P"."PURL" AS "projectPurl", + (SELECT + ARRAY_AGG(DISTINCT "T"."NAME") + FROM + "TAG" AS "T" + INNER JOIN + "PROJECTS_TAGS" AS "PT" ON "PT"."TAG_ID" = "T"."ID" + WHERE + "PT"."PROJECT_ID" = "P"."ID" + ) AS "projectTags", + "V"."UUID" AS "vulnUuid", + "V"."VULNID" AS "vulnId", + "V"."SOURCE" AS "vulnSource", + "V"."TITLE" AS "vulnTitle", + "V"."SUBTITLE" AS "vulnSubTitle", + "V"."DESCRIPTION" AS "vulnDescription", + "V"."RECOMMENDATION" AS "vulnRecommendation", + CASE + WHEN "A"."SEVERITY" IS NOT NULL THEN "A"."CVSSV2SCORE" + ELSE "V"."CVSSV2BASESCORE" + END AS "vulnCvssV2BaseScore", + CASE + WHEN "A"."SEVERITY" IS NOT NULL THEN "A"."CVSSV3SCORE" + ELSE "V"."CVSSV3BASESCORE" + END AS "vulnCvssV3BaseScore", + -- TODO: Analysis only has a single score, but OWASP RR defines multiple. + -- How to handle this? + CASE + WHEN "A"."SEVERITY" IS NOT NULL THEN "A"."OWASPSCORE" + ELSE "V"."OWASPRRBUSINESSIMPACTSCORE" + END AS "vulnOwaspRrBusinessImpactScore", + CASE + WHEN "A"."SEVERITY" IS NOT NULL THEN "A"."OWASPSCORE" + ELSE "V"."OWASPRRLIKELIHOODSCORE" + END AS "vulnOwaspRrLikelihoodScore", + CASE + WHEN "A"."SEVERITY" IS NOT NULL THEN "A"."OWASPSCORE" + ELSE "V"."OWASPRRTECHNICALIMPACTSCORE" + END AS "vulnOwaspRrTechnicalImpactScore", + "CALC_SEVERITY"( + "V"."SEVERITY", + "A"."SEVERITY", + "V"."CVSSV3BASESCORE", + "V"."CVSSV2BASESCORE" + ) AS "vulnSeverity", + STRING_TO_ARRAY("V"."CWES", ',') AS "vulnCwes", + "vulnAliasesJson", + "A"."SUPPRESSED" AS "isVulnAnalysisSuppressed", + "A"."STATE" AS "vulnAnalysisState", + '/api/v1/vulnerability/source/' || "V"."SOURCE" || '/vuln/' || "V"."VULNID" || '/projects' AS "affectedProjectsApiUrl", + '/vulnerabilities/' || "V"."SOURCE" || '/' || "V"."VULNID" || '/affectedProjects' AS "affectedProjectsFrontendUrl" + FROM + "COMPONENT" AS "C" + INNER JOIN + "PROJECT" AS "P" ON "P"."ID" = "C"."PROJECT_ID" + INNER JOIN + "COMPONENTS_VULNERABILITIES" AS "CV" ON "CV"."COMPONENT_ID" = "C"."ID" + INNER JOIN + "VULNERABILITY" AS "V" ON "V"."ID" = "CV"."VULNERABILITY_ID" + LEFT JOIN + "ANALYSIS" AS "A" ON "A"."COMPONENT_ID" = "C"."ID" AND "A"."VULNERABILITY_ID" = "V"."ID" + LEFT JOIN LATERAL ( + SELECT + CAST(JSONB_AGG(DISTINCT JSONB_STRIP_NULLS(JSONB_BUILD_OBJECT( + 'cveId', "VA"."CVE_ID", + 'ghsaId', "VA"."GHSA_ID", + 'gsdId', "VA"."GSD_ID", + 'internalId', "VA"."INTERNAL_ID", + 'osvId', "VA"."OSV_ID", + 'sonatypeId', "VA"."SONATYPE_ID", + 'snykId', "VA"."SNYK_ID", + 'vulnDbId', "VA"."VULNDB_ID" + ))) AS TEXT) AS "vulnAliasesJson" + FROM + "VULNERABILITYALIAS" AS "VA" + WHERE + ("V"."SOURCE" = 'NVD' AND "VA"."CVE_ID" = "V"."VULNID") + OR ("V"."SOURCE" = 'GITHUB' AND "VA"."GHSA_ID" = "V"."VULNID") + OR ("V"."SOURCE" = 'GSD' AND "VA"."GSD_ID" = "V"."VULNID") + OR ("V"."SOURCE" = 'INTERNAL' AND "VA"."INTERNAL_ID" = "V"."VULNID") + OR ("V"."SOURCE" = 'OSV' AND "VA"."OSV_ID" = "V"."VULNID") + OR ("V"."SOURCE" = 'SONATYPE' AND "VA"."SONATYPE_ID" = "V"."VULNID") + OR ("V"."SOURCE" = 'SNYK' AND "VA"."SNYK_ID" = "V"."VULNID") + OR ("V"."SOURCE" = 'VULNDB' AND "VA"."VULNDB_ID" = "V"."VULNID") + ) AS "vulnAliases" ON TRUE + WHERE + "C"."UUID" = (:componentUuid)::TEXT AND "V"."ID" = ANY((:vulnIds)::BIGINT[]) + """) + @RegisterRowMapper(NotificationSubjectProjectAuditChangeRowMapper.class) + List getForProjectAuditChange(final UUID componentUuid, final Collection vulnIds); } diff --git a/src/main/java/org/dependencytrack/persistence/jdbi/mapping/NotificationSubjectProjectAuditChangeRowMapper.java b/src/main/java/org/dependencytrack/persistence/jdbi/mapping/NotificationSubjectProjectAuditChangeRowMapper.java new file mode 100644 index 000000000..4b78c0216 --- /dev/null +++ b/src/main/java/org/dependencytrack/persistence/jdbi/mapping/NotificationSubjectProjectAuditChangeRowMapper.java @@ -0,0 +1,37 @@ +package org.dependencytrack.persistence.jdbi.mapping; + +import org.dependencytrack.proto.notification.v1.Component; +import org.dependencytrack.proto.notification.v1.Project; +import org.dependencytrack.proto.notification.v1.Vulnerability; +import org.dependencytrack.proto.notification.v1.VulnerabilityAnalysis; +import org.dependencytrack.proto.notification.v1.VulnerabilityAnalysisDecisionChangeSubject; +import org.jdbi.v3.core.mapper.RowMapper; +import org.jdbi.v3.core.statement.StatementContext; + +import java.sql.ResultSet; +import java.sql.SQLException; + +import static org.dependencytrack.persistence.jdbi.mapping.RowMapperUtil.maybeSet; + +public class NotificationSubjectProjectAuditChangeRowMapper implements RowMapper { + + @Override + public VulnerabilityAnalysisDecisionChangeSubject map(final ResultSet rs, final StatementContext ctx) throws SQLException { + final RowMapper componentRowMapper = ctx.findRowMapperFor(Component.class).orElseThrow(); + final RowMapper projectRowMapper = ctx.findRowMapperFor(Project.class).orElseThrow(); + final RowMapper vulnRowMapper = ctx.findRowMapperFor(Vulnerability.class).orElseThrow(); + final VulnerabilityAnalysis.Builder vulnAnalysisBuilder = VulnerabilityAnalysis.newBuilder() + .setComponent(componentRowMapper.map(rs, ctx)) + .setProject(projectRowMapper.map(rs, ctx)) + .setVulnerability(vulnRowMapper.map(rs, ctx)); + maybeSet(rs, "vulnAnalysisState", ResultSet::getString, vulnAnalysisBuilder::setState); + maybeSet(rs, "isVulnAnalysisSuppressed", ResultSet::getBoolean, vulnAnalysisBuilder::setSuppressed); + final VulnerabilityAnalysisDecisionChangeSubject.Builder builder = VulnerabilityAnalysisDecisionChangeSubject.newBuilder() + .setComponent(componentRowMapper.map(rs, ctx)) + .setProject(projectRowMapper.map(rs, ctx)) + .setVulnerability(vulnRowMapper.map(rs, ctx)) + .setAnalysis(vulnAnalysisBuilder); + return builder.build(); + } + +} diff --git a/src/test/java/org/dependencytrack/persistence/jdbi/NotificationSubjectDaoTest.java b/src/test/java/org/dependencytrack/persistence/jdbi/NotificationSubjectDaoTest.java index b7f64ac21..1f3fc9180 100644 --- a/src/test/java/org/dependencytrack/persistence/jdbi/NotificationSubjectDaoTest.java +++ b/src/test/java/org/dependencytrack/persistence/jdbi/NotificationSubjectDaoTest.java @@ -14,6 +14,7 @@ import org.dependencytrack.persistence.CweImporter; import org.dependencytrack.proto.notification.v1.NewVulnerabilitySubject; import org.dependencytrack.proto.notification.v1.NewVulnerableDependencySubject; +import org.dependencytrack.proto.notification.v1.VulnerabilityAnalysisDecisionChangeSubject; import org.junit.Before; import org.junit.Test; @@ -449,4 +450,186 @@ public void testGetForNewVulnerableDependencyWithAnalysisRatingOverwrite() throw """); } + @Test + public void testGetForProjectAuditChange() { + final var project = new Project(); + project.setName("projectName"); + project.setVersion("projectVersion"); + project.setDescription("projectDescription"); + project.setPurl("projectPurl"); + qm.persist(project); + qm.bind(project, List.of( + qm.createTag("projectTagA"), + qm.createTag("projectTagB") + )); + + final var component = new Component(); + component.setProject(project); + component.setGroup("componentGroup"); + component.setName("componentName"); + component.setVersion("componentVersion"); + component.setPurl("componentPurl"); + component.setMd5("componentMd5"); + component.setSha1("componentSha1"); + component.setSha256("componentSha256"); + component.setSha512("componentSha512"); + qm.persist(component); + + final var vulnA = new Vulnerability(); + vulnA.setVulnId("CVE-100"); + vulnA.setSource(Vulnerability.Source.NVD); + vulnA.setTitle("vulnATitle"); + vulnA.setSubTitle("vulnASubTitle"); + vulnA.setDescription("vulnADescription"); + vulnA.setRecommendation("vulnARecommendation"); + vulnA.setSeverity(Severity.MEDIUM); + vulnA.setCvssV2BaseScore(BigDecimal.valueOf(1.1)); + vulnA.setCvssV3BaseScore(BigDecimal.valueOf(2.2)); + vulnA.setOwaspRRBusinessImpactScore(BigDecimal.valueOf(3.3)); + vulnA.setOwaspRRLikelihoodScore(BigDecimal.valueOf(4.4)); + vulnA.setOwaspRRTechnicalImpactScore(BigDecimal.valueOf(5.5)); + vulnA.setCwes(List.of(666, 777)); + qm.persist(vulnA); + + final var vulnB = new Vulnerability(); + vulnB.setVulnId("CVE-200"); + vulnB.setSource(Vulnerability.Source.NVD); + qm.persist(vulnB); + + final var vulnAlias = new VulnerabilityAlias(); + vulnAlias.setCveId("CVE-100"); + vulnAlias.setGhsaId("GHSA-100"); + qm.synchronizeVulnerabilityAlias(vulnAlias); + + qm.addVulnerability(vulnA, component, AnalyzerIdentity.INTERNAL_ANALYZER); + qm.addVulnerability(vulnB, component, AnalyzerIdentity.INTERNAL_ANALYZER); + + // Suppress vulnB, it should not appear in the query results. + qm.makeAnalysis(component, vulnB, AnalysisState.FALSE_POSITIVE, null, null, null, true); + qm.makeAnalysis(component, vulnA, AnalysisState.NOT_AFFECTED, null, null, null, false); + + final List subjects = JdbiFactory.jdbi(qm).withExtension(NotificationSubjectDao.class, + dao -> dao.getForProjectAuditChange(component.getUuid(), List.of(vulnA.getId(), vulnB.getId()))); + + assertThat(subjects.size()).isEqualTo(2); + assertThat(subjects.get(0)).satisfies(subject -> + assertThatJson(JsonFormat.printer().print(subject)) + .withMatcher("projectUuid", equalTo(project.getUuid().toString())) + .withMatcher("componentUuid", equalTo(component.getUuid().toString())) + .withMatcher("vulnUuid", equalTo(vulnA.getUuid().toString())) + .isEqualTo(""" + { + "component": { + "uuid": "${json-unit.matches:componentUuid}", + "group": "componentGroup", + "name": "componentName", + "version": "componentVersion", + "purl": "componentPurl", + "md5": "componentmd5", + "sha1": "componentsha1", + "sha256": "componentsha256", + "sha512": "componentsha512" + }, + "project": { + "uuid": "${json-unit.matches:projectUuid}", + "name": "projectName", + "version": "projectVersion", + "description": "projectDescription", + "purl": "projectPurl", + "tags": [ + "projecttaga", + "projecttagb" + ] + }, + "vulnerability": { + "uuid": "${json-unit.matches:vulnUuid}", + "vulnId": "CVE-100", + "source": "NVD", + "aliases": [ + { + "vulnId": "GHSA-100", + "source": "GITHUB" + } + ], + "title": "vulnATitle", + "subtitle": "vulnASubTitle", + "description": "vulnADescription", + "recommendation": "vulnARecommendation", + "cvssv2": 1.1, + "cvssv3": 2.2, + "owaspRRLikelihood": 4.4, + "owaspRRTechnicalImpact": 5.5, + "owaspRRBusinessImpact": 3.3, + "severity": "LOW", + "cwes": [ + { + "cweId": 666, + "name": "Operation on Resource in Wrong Phase of Lifetime" + }, + { + "cweId": 777, + "name": "Regular Expression without Anchors" + } + ] + }, + "analysis": { + "component": { + "uuid": "${json-unit.matches:componentUuid}", + "group": "componentGroup", + "name": "componentName", + "version": "componentVersion", + "purl": "componentPurl", + "md5": "componentmd5", + "sha1": "componentsha1", + "sha256": "componentsha256", + "sha512": "componentsha512" + }, + "project": { + "uuid": "${json-unit.matches:projectUuid}", + "name": "projectName", + "version": "projectVersion", + "description": "projectDescription", + "purl": "projectPurl", + "tags": [ + "projecttaga", + "projecttagb" + ] + }, + "vulnerability": { + "uuid": "${json-unit.matches:vulnUuid}", + "vulnId": "CVE-100", + "source": "NVD", + "aliases": [ + { + "vulnId": "GHSA-100", + "source": "GITHUB" + } + ], + "title": "vulnATitle", + "subtitle": "vulnASubTitle", + "description": "vulnADescription", + "recommendation": "vulnARecommendation", + "cvssv2": 1.1, + "cvssv3": 2.2, + "owaspRRLikelihood": 4.4, + "owaspRRTechnicalImpact": 5.5, + "owaspRRBusinessImpact": 3.3, + "severity": "LOW", + "cwes": [ + { + "cweId": 666, + "name": "Operation on Resource in Wrong Phase of Lifetime" + }, + { + "cweId": 777, + "name": "Regular Expression without Anchors" + } + ] + }, + "state": "NOT_AFFECTED", + "suppressed": false + } + } + """)); + } } \ No newline at end of file