diff --git a/docs/_docs/integrations/notifications.md b/docs/_docs/integrations/notifications.md index 8cdc82a919..9dc2944090 100644 --- a/docs/_docs/integrations/notifications.md +++ b/docs/_docs/integrations/notifications.md @@ -37,24 +37,25 @@ Notification levels behave identical to logging levels: Each scope contains a set of notification groups that can be subscribed to. Some groups contain notifications of multiple levels, while others can only ever have a single level. -| Scope | Group | Level(s) | Description | -|-----------|---------------------------|---------------|-----------------------------------------------------------------------------------------------------------------------------------| -| SYSTEM | ANALYZER | (Any) | Notifications generated as a result of interacting with an external source of vulnerability intelligence | -| SYSTEM | DATASOURCE_MIRRORING | (Any) | Notifications generated when performing mirroring of one of the supported datasources such as the NVD | -| SYSTEM | INDEXING_SERVICE | (Any) | Notifications generated as a result of performing maintenance on Dependency-Tracks internal index used for global searching | -| SYSTEM | FILE_SYSTEM | (Any) | Notifications generated as a result of a file system operation. These are typically only generated on error conditions | -| SYSTEM | REPOSITORY | (Any) | Notifications generated as a result of interacting with one of the supported repositories such as Maven Central, RubyGems, or NPM | -| SYSTEM | USER_CREATED | INFORMATIONAL | Notifications generated as a result of a user creation | -| SYSTEM | USER_DELETED | INFORMATIONAL | Notifications generated as a result of a user deletion | -| PORTFOLIO | NEW_VULNERABILITY | INFORMATIONAL | Notifications generated whenever a new vulnerability is identified | -| PORTFOLIO | NEW_VULNERABLE_DEPENDENCY | INFORMATIONAL | Notifications generated as a result of a vulnerable component becoming a dependency of a project | -| PORTFOLIO | GLOBAL_AUDIT_CHANGE | INFORMATIONAL | Notifications generated whenever an analysis or suppression state has changed on a finding from a component (global) | -| PORTFOLIO | PROJECT_AUDIT_CHANGE | INFORMATIONAL | Notifications generated whenever an analysis or suppression state has changed on a finding from a project | -| PORTFOLIO | BOM_CONSUMED | INFORMATIONAL | Notifications generated whenever a supported BOM is ingested and identified | -| PORTFOLIO | BOM_PROCESSED | INFORMATIONAL | Notifications generated after a supported BOM is ingested, identified, and successfully processed | -| PORTFOLIO | BOM_PROCESSING_FAILED | ERROR | Notifications generated whenever a BOM upload process fails | -| PORTFOLIO | BOM_VALIDATION_FAILED | ERROR | Notifications generated whenever an invalid BOM is uploaded | -| PORTFOLIO | POLICY_VIOLATION | INFORMATIONAL | Notifications generated whenever a policy violation is identified | +| Scope | Group | Level(s) | Description | +|-----------|-------------------------------|-----------------|----------------------------------------------------------------------------------------------------------------------------------------------| +| SYSTEM | ANALYZER | (Any) | Notifications generated as a result of interacting with an external source of vulnerability intelligence | +| SYSTEM | DATASOURCE_MIRRORING | (Any) | Notifications generated when performing mirroring of one of the supported datasources such as the NVD | +| SYSTEM | INDEXING_SERVICE | (Any) | Notifications generated as a result of performing maintenance on Dependency-Tracks internal index used for global searching | +| SYSTEM | FILE_SYSTEM | (Any) | Notifications generated as a result of a file system operation. These are typically only generated on error conditions | +| SYSTEM | REPOSITORY | (Any) | Notifications generated as a result of interacting with one of the supported repositories such as Maven Central, RubyGems, or NPM | +| SYSTEM | USER_CREATED | INFORMATIONAL | Notifications generated as a result of a user creation | +| SYSTEM | USER_DELETED | INFORMATIONAL | Notifications generated as a result of a user deletion | +| PORTFOLIO | NEW_VULNERABILITY | INFORMATIONAL | Notifications generated whenever a new vulnerability is identified | +| PORTFOLIO | NEW_VULNERABLE_DEPENDENCY | INFORMATIONAL | Notifications generated as a result of a vulnerable component becoming a dependency of a project | +| PORTFOLIO | PROJECT_VULNERABILITY_UPDATED | INFORMATIONAL | Notifications generated if a vulnerability associated with a project is updated after creation. Currently only triggers on severity updates. | +| PORTFOLIO | GLOBAL_AUDIT_CHANGE | INFORMATIONAL | Notifications generated whenever an analysis or suppression state has changed on a finding from a component (global) | +| PORTFOLIO | PROJECT_AUDIT_CHANGE | INFORMATIONAL | Notifications generated whenever an analysis or suppression state has changed on a finding from a project | +| PORTFOLIO | BOM_CONSUMED | INFORMATIONAL | Notifications generated whenever a supported BOM is ingested and identified | +| PORTFOLIO | BOM_PROCESSED | INFORMATIONAL | Notifications generated after a supported BOM is ingested, identified, and successfully processed | +| PORTFOLIO | BOM_PROCESSING_FAILED | ERROR | Notifications generated whenever a BOM upload process fails | +| PORTFOLIO | BOM_VALIDATION_FAILED | ERROR | Notifications generated whenever an invalid BOM is uploaded | +| PORTFOLIO | POLICY_VIOLATION | INFORMATIONAL | Notifications generated whenever a policy violation is identified | ## Configuring Publishers @@ -233,6 +234,55 @@ This type of notification will always contain: > The `cwe` field is deprecated and will be removed in a later version. Please use `cwes` instead. +#### PROJECT_VULNERABILITY_UPDATED +This type of notification will always contain: +* 1 vulnerability update +* 1 component + +To minimise noise, a notification will only be published if the vulnerability already affects an existing project. This can be scoped down further by limiting notifications to specific projects when configuring an alert rule. + +```json +{ + "notification": { + "level": "INFORMATIONAL", + "scope": "PORTFOLIO", + "group": "PROJECT_VULNERABILITY_UPDATED", + "timestamp": "2018-08-27T23:26:22.961", + "title": "Vulnerability Update", + "content": "The vulnerability CVE-2012-5784 on component axis has changed severity from LOW to MEDIUM", + "subject": { + "vulnerability": { + "uuid": "941a93f5-e06b-4304-84de-4d788eeb4969", + "vulnId": "CVE-2012-5784", + "source": "NVD", + "aliases": [ + { + "vulnId": "GHSA-55w9-c3g2-4rrh", + "source": "GITHUB" + } + ], + "old": { + "severity": "LOW" + }, + "new": { + "severity": "MEDIUM" + } + }, + "component": { + "uuid": "4d5cd8df-cff7-4212-a038-91ae4ab79396", + "group": "apache", + "name": "axis", + "version": "1.4", + "md5": "03dcfdd88502505cc5a805a128bfdd8d", + "sha1": "94a9ce681a42d0352b3ad22659f67835e560d107", + "sha256": "05aebb421d0615875b4bf03497e041fe861bf0556c3045d8dda47e29241ffdd3", + "purl": "pkg:maven/apache/axis@1.4" + } + } + } +} +``` + #### PROJECT_AUDIT_CHANGE and GLOBAL_AUDIT_CHANGE This type of notification will always contain: * 1 component diff --git a/src/main/java/org/dependencytrack/event/EventSubsystemInitializer.java b/src/main/java/org/dependencytrack/event/EventSubsystemInitializer.java index c4784dbea6..7547c54d3e 100644 --- a/src/main/java/org/dependencytrack/event/EventSubsystemInitializer.java +++ b/src/main/java/org/dependencytrack/event/EventSubsystemInitializer.java @@ -46,6 +46,7 @@ import org.dependencytrack.tasks.VexUploadProcessingTask; import org.dependencytrack.tasks.VulnDbSyncTask; import org.dependencytrack.tasks.VulnerabilityAnalysisTask; +import org.dependencytrack.tasks.ProjectVulnerabilityUpdateTask; import org.dependencytrack.tasks.metrics.ComponentMetricsUpdateTask; import org.dependencytrack.tasks.metrics.PortfolioMetricsUpdateTask; import org.dependencytrack.tasks.metrics.ProjectMetricsUpdateTask; @@ -118,6 +119,7 @@ public void contextInitialized(final ServletContextEvent event) { EVENT_SERVICE.subscribe(ClearComponentAnalysisCacheEvent.class, ClearComponentAnalysisCacheTask.class); EVENT_SERVICE.subscribe(CallbackEvent.class, CallbackTask.class); EVENT_SERVICE.subscribe(NewVulnerableDependencyAnalysisEvent.class, NewVulnerableDependencyAnalysisTask.class); + EVENT_SERVICE.subscribe(ProjectVulnerabilityUpdateEvent.class, ProjectVulnerabilityUpdateTask.class); EVENT_SERVICE.subscribe(NistMirrorEvent.class, NistMirrorTask.class); EVENT_SERVICE.subscribe(NistApiMirrorEvent.class, NistApiMirrorTask.class); EVENT_SERVICE.subscribe(EpssMirrorEvent.class, EpssMirrorTask.class); @@ -160,6 +162,7 @@ public void contextDestroyed(final ServletContextEvent event) { EVENT_SERVICE.unsubscribe(InternalComponentIdentificationTask.class); EVENT_SERVICE.unsubscribe(CallbackTask.class); EVENT_SERVICE.unsubscribe(NewVulnerableDependencyAnalysisTask.class); + EVENT_SERVICE.unsubscribe(ProjectVulnerabilityUpdateTask.class); EVENT_SERVICE.unsubscribe(NistMirrorTask.class); EVENT_SERVICE.unsubscribe(NistApiMirrorTask.class); EVENT_SERVICE.unsubscribe(EpssMirrorTask.class); diff --git a/src/main/java/org/dependencytrack/event/ProjectVulnerabilityUpdateEvent.java b/src/main/java/org/dependencytrack/event/ProjectVulnerabilityUpdateEvent.java new file mode 100644 index 0000000000..949be8d4ea --- /dev/null +++ b/src/main/java/org/dependencytrack/event/ProjectVulnerabilityUpdateEvent.java @@ -0,0 +1,42 @@ +/* + * 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.event; + +import alpine.event.framework.SingletonCapableEvent; +import org.dependencytrack.model.Vulnerability; +import org.dependencytrack.model.VulnerabilityUpdateDiff; + + +public class ProjectVulnerabilityUpdateEvent extends SingletonCapableEvent { + private final VulnerabilityUpdateDiff vulnerabilityUpdateDiff; + private final Vulnerability vulnerability; + + public ProjectVulnerabilityUpdateEvent(Vulnerability vulnerability, VulnerabilityUpdateDiff vulnerabilityUpdateDiff) { + this.vulnerability = vulnerability; + this.vulnerabilityUpdateDiff = vulnerabilityUpdateDiff; + } + + public Vulnerability getVulnerability() { + return vulnerability; + } + + public VulnerabilityUpdateDiff getVulnerabilityUpdateDiff() { + return vulnerabilityUpdateDiff; + } +} diff --git a/src/main/java/org/dependencytrack/model/VulnerabilityUpdateDiff.java b/src/main/java/org/dependencytrack/model/VulnerabilityUpdateDiff.java new file mode 100644 index 0000000000..a61ced928d --- /dev/null +++ b/src/main/java/org/dependencytrack/model/VulnerabilityUpdateDiff.java @@ -0,0 +1,33 @@ +/* + * 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.model; + +public class VulnerabilityUpdateDiff { + private Severity oldSeverity; + private Severity newSeverity; + + public VulnerabilityUpdateDiff(Severity oldSeverity, Severity newSeverity) { + this.oldSeverity = oldSeverity; + this.newSeverity = newSeverity; + } + + public Severity getOldSeverity() { return oldSeverity; } + + public Severity getNewSeverity() { return newSeverity; } +} diff --git a/src/main/java/org/dependencytrack/notification/NotificationConstants.java b/src/main/java/org/dependencytrack/notification/NotificationConstants.java index 83e78c5339..df37d1c04c 100644 --- a/src/main/java/org/dependencytrack/notification/NotificationConstants.java +++ b/src/main/java/org/dependencytrack/notification/NotificationConstants.java @@ -41,6 +41,7 @@ public static class Title { public static final String INTEGRATION_ERROR = "Integration Error"; public static final String NEW_VULNERABILITY = "New Vulnerability Identified"; public static final String NEW_VULNERABLE_DEPENDENCY = "Vulnerable Dependency Introduced"; + public static final String VULNERABILITY_UPDATED = "Vulnerability Updated"; public static final String ANALYSIS_DECISION_EXPLOITABLE = "Analysis Decision: Exploitable"; public static final String ANALYSIS_DECISION_IN_TRIAGE = "Analysis Decision: In Triage"; public static final String ANALYSIS_DECISION_FALSE_POSITIVE = "Analysis Decision: False Positive"; diff --git a/src/main/java/org/dependencytrack/notification/NotificationGroup.java b/src/main/java/org/dependencytrack/notification/NotificationGroup.java index 64596886c5..f0ffd487df 100644 --- a/src/main/java/org/dependencytrack/notification/NotificationGroup.java +++ b/src/main/java/org/dependencytrack/notification/NotificationGroup.java @@ -32,6 +32,7 @@ public enum NotificationGroup { // Portfolio Groups NEW_VULNERABILITY, NEW_VULNERABLE_DEPENDENCY, + PROJECT_VULNERABILITY_UPDATED, //NEW_OUTDATED_COMPONENT, //FIXED_VULNERABILITY, //FIXED_OUTDATED, diff --git a/src/main/java/org/dependencytrack/notification/NotificationRouter.java b/src/main/java/org/dependencytrack/notification/NotificationRouter.java index a5b6cf7aa5..384f2f73ca 100644 --- a/src/main/java/org/dependencytrack/notification/NotificationRouter.java +++ b/src/main/java/org/dependencytrack/notification/NotificationRouter.java @@ -26,6 +26,8 @@ import org.dependencytrack.model.NotificationPublisher; import org.dependencytrack.model.NotificationRule; import org.dependencytrack.model.Project; +import org.dependencytrack.model.Component; +import org.dependencytrack.model.ComponentIdentity; import org.dependencytrack.notification.publisher.PublishContext; import org.dependencytrack.notification.publisher.Publisher; import org.dependencytrack.notification.publisher.SendMailPublisher; @@ -38,6 +40,7 @@ import org.dependencytrack.notification.vo.PolicyViolationIdentified; import org.dependencytrack.notification.vo.VexConsumedOrProcessed; import org.dependencytrack.notification.vo.ViolationAnalysisDecisionChange; +import org.dependencytrack.notification.vo.ProjectVulnerabilityUpdate; import org.dependencytrack.persistence.QueryManager; import jakarta.json.Json; @@ -178,6 +181,33 @@ List resolveRules(final PublishContext ctx, final Notification } } } + } else if (NotificationScope.PORTFOLIO.name().equals(notification.getScope()) + && notification.getSubject() instanceof final ProjectVulnerabilityUpdate subject) { + for (final NotificationRule rule: result) { + // As above, reduce the execution of the notification down to those projects that the rule matches. + // As we only emit a single notification per vulnerability, we need to check if any affected component + // matches the rule projects, not just the component included in the notification subject. + if (rule.getNotifyOn().contains(NotificationGroup.valueOf(notification.getGroup()))) { + if (subject.getComponent() != null) { + final List components = qm.matchIdentity(new ComponentIdentity(subject.getComponent())); + if (components != null && !components.isEmpty()) { + if (rule.getProjects() != null && !rule.getProjects().isEmpty()) { + for (final Component component : components) { + if (component.getProject() != null) { + for (final Project project : rule.getProjects()) { + if (component.getProject().getUuid().equals(project.getUuid()) || (Boolean.TRUE.equals(rule.isNotifyChildren() && checkIfChildrenAreAffected(project, component.getProject().getUuid())))) { + rules.add(rule); + } + } + } + } + } else { + rules.add(rule); + } + } + } + } + } } else if (NotificationScope.PORTFOLIO.name().equals(notification.getScope()) && notification.getSubject() instanceof final NewVulnerableDependency subject) { limitToProject(ctx, rules, result, notification, subject.getComponent().getProject()); diff --git a/src/main/java/org/dependencytrack/notification/publisher/PublishContext.java b/src/main/java/org/dependencytrack/notification/publisher/PublishContext.java index e60a1e9287..6cdfc0a127 100644 --- a/src/main/java/org/dependencytrack/notification/publisher/PublishContext.java +++ b/src/main/java/org/dependencytrack/notification/publisher/PublishContext.java @@ -21,11 +21,13 @@ import alpine.notification.Notification; import com.google.common.base.MoreObjects; import org.dependencytrack.model.NotificationRule; +import org.dependencytrack.model.Severity; import org.dependencytrack.notification.vo.AnalysisDecisionChange; import org.dependencytrack.notification.vo.BomConsumedOrProcessed; import org.dependencytrack.notification.vo.BomProcessingFailed; import org.dependencytrack.notification.vo.NewVulnerabilityIdentified; import org.dependencytrack.notification.vo.NewVulnerableDependency; +import org.dependencytrack.notification.vo.ProjectVulnerabilityUpdate; import org.dependencytrack.notification.vo.PolicyViolationIdentified; import org.dependencytrack.notification.vo.VexConsumedOrProcessed; import org.dependencytrack.notification.vo.ViolationAnalysisDecisionChange; @@ -60,6 +62,7 @@ public record PublishContext(String notificationGroup, String notificationLevel, private static final String SUBJECT_PROJECTS = "projects"; private static final String SUBJECT_VULNERABILITY = "vulnerability"; private static final String SUBJECT_VULNERABILITIES = "vulnerabilities"; + private static final String SUBJECT_VULNERABILITY_UPDATE = "vulnerabilityUpdate"; public static PublishContext from(final Notification notification) { if (notification == null) { @@ -79,6 +82,13 @@ public static PublishContext from(final Notification notification) { notificationSubjects.put(SUBJECT_PROJECTS, null); } notificationSubjects.put(SUBJECT_VULNERABILITY, Vulnerability.convert(subject.getVulnerability())); + } else if (notification.getSubject() instanceof final ProjectVulnerabilityUpdate subject) { + notificationSubjects.put(SUBJECT_VULNERABILITY_UPDATE, VulnerabilityUpdateDiff.convert(subject.getVulnerability(), subject.getVulnerabilityUpdateDiff())); + if (subject.getComponent() != null) { + notificationSubjects.put(SUBJECT_COMPONENT, Component.convert(subject.getComponent())); + } else { + notificationSubjects.put(SUBJECT_COMPONENT, null); + } } else if (notification.getSubject() instanceof final NewVulnerableDependency subject) { notificationSubjects.put(SUBJECT_COMPONENT, Component.convert(subject.getComponent())); notificationSubjects.put(SUBJECT_PROJECT, Project.convert(subject.getComponent().getProject())); @@ -180,4 +190,21 @@ private static Vulnerability convert(final org.dependencytrack.model.Vulnerabili } + public record VulnerabilityUpdateDiff(String id, String source, Severity oldSeverity, Severity newSeverity) { + + private static VulnerabilityUpdateDiff convert(final org.dependencytrack.model.Vulnerability notificationVuln, + final org.dependencytrack.model.VulnerabilityUpdateDiff notificationVulnUpdateDiff) { + if (notificationVuln == null || notificationVulnUpdateDiff == null) { + return null; + } + return new VulnerabilityUpdateDiff( + notificationVuln.getVulnId(), + notificationVuln.getSource(), + notificationVulnUpdateDiff.getOldSeverity(), + notificationVulnUpdateDiff.getNewSeverity() + ); + } + + } + } diff --git a/src/main/java/org/dependencytrack/notification/publisher/Publisher.java b/src/main/java/org/dependencytrack/notification/publisher/Publisher.java index 50a319d92f..072c5f35d5 100644 --- a/src/main/java/org/dependencytrack/notification/publisher/Publisher.java +++ b/src/main/java/org/dependencytrack/notification/publisher/Publisher.java @@ -36,6 +36,7 @@ import org.dependencytrack.notification.vo.PolicyViolationIdentified; import org.dependencytrack.notification.vo.VexConsumedOrProcessed; import org.dependencytrack.notification.vo.ViolationAnalysisDecisionChange; +import org.dependencytrack.notification.vo.ProjectVulnerabilityUpdate; import org.dependencytrack.persistence.QueryManager; import org.dependencytrack.util.NotificationUtil; @@ -106,6 +107,9 @@ default String prepareTemplate(final Notification notification, final PebbleTemp if (notification.getSubject() instanceof final NewVulnerabilityIdentified subject) { context.put("subject", subject); context.put("subjectJson", NotificationUtil.toJson(subject)); + } else if (notification.getSubject() instanceof final ProjectVulnerabilityUpdate subject) { + context.put("subject", subject); + context.put("subjectJson", NotificationUtil.toJson(subject)); } else if (notification.getSubject() instanceof final NewVulnerableDependency subject) { context.put("subject", subject); context.put("subjectJson", NotificationUtil.toJson(subject)); diff --git a/src/main/java/org/dependencytrack/notification/vo/ProjectVulnerabilityUpdate.java b/src/main/java/org/dependencytrack/notification/vo/ProjectVulnerabilityUpdate.java new file mode 100644 index 0000000000..5d782baab0 --- /dev/null +++ b/src/main/java/org/dependencytrack/notification/vo/ProjectVulnerabilityUpdate.java @@ -0,0 +1,43 @@ +/* + * 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.notification.vo; + +import org.dependencytrack.model.Component; +import org.dependencytrack.model.Vulnerability; +import org.dependencytrack.model.VulnerabilityUpdateDiff; + +public class ProjectVulnerabilityUpdate { + private final Vulnerability vulnerability; + private final VulnerabilityUpdateDiff vulnerabilityUpdateDiff; + private final Component component; + + public ProjectVulnerabilityUpdate(final Vulnerability vulnerability, final VulnerabilityUpdateDiff vulnerabilityUpdateDiff, final Component component) { + this.vulnerability = vulnerability; + this.vulnerabilityUpdateDiff = vulnerabilityUpdateDiff; + this.component = component; + } + + public Vulnerability getVulnerability() { + return vulnerability; + } + + public VulnerabilityUpdateDiff getVulnerabilityUpdateDiff(){ return vulnerabilityUpdateDiff; } + + public Component getComponent() { return component; } +} diff --git a/src/main/java/org/dependencytrack/persistence/VulnerabilityQueryManager.java b/src/main/java/org/dependencytrack/persistence/VulnerabilityQueryManager.java index 4594e539d8..b48f3a9dcb 100644 --- a/src/main/java/org/dependencytrack/persistence/VulnerabilityQueryManager.java +++ b/src/main/java/org/dependencytrack/persistence/VulnerabilityQueryManager.java @@ -23,6 +23,7 @@ import alpine.resources.AlpineRequest; import org.apache.commons.lang3.StringUtils; import org.dependencytrack.event.IndexEvent; +import org.dependencytrack.event.ProjectVulnerabilityUpdateEvent; import org.dependencytrack.model.AffectedVersionAttribution; import org.dependencytrack.model.Analysis; import org.dependencytrack.model.Component; @@ -31,6 +32,7 @@ import org.dependencytrack.model.VulnIdAndSource; import org.dependencytrack.model.Vulnerability; import org.dependencytrack.model.VulnerabilityAlias; +import org.dependencytrack.model.VulnerabilityUpdateDiff; import org.dependencytrack.model.VulnerableSoftware; import org.dependencytrack.resources.v1.vo.AffectedProject; import org.dependencytrack.tasks.scanners.AnalyzerIdentity; @@ -129,6 +131,11 @@ public Vulnerability updateVulnerability(Vulnerability transientVulnerability, b final Vulnerability result = persist(vulnerability); Event.dispatch(new IndexEvent(IndexEvent.Action.UPDATE, result)); commitSearchIndex(commitIndex, Vulnerability.class); + + if (vulnerability.getSeverity() != transientVulnerability.getSeverity()) { + Event.dispatch(new ProjectVulnerabilityUpdateEvent(vulnerability, new VulnerabilityUpdateDiff(vulnerability.getSeverity(), transientVulnerability.getSeverity()))); + } + return result; } return null; diff --git a/src/main/java/org/dependencytrack/tasks/AbstractNistMirrorTask.java b/src/main/java/org/dependencytrack/tasks/AbstractNistMirrorTask.java index c5d0763fe2..254b6649d5 100644 --- a/src/main/java/org/dependencytrack/tasks/AbstractNistMirrorTask.java +++ b/src/main/java/org/dependencytrack/tasks/AbstractNistMirrorTask.java @@ -19,10 +19,14 @@ package org.dependencytrack.tasks; import alpine.common.logging.Logger; +import alpine.event.framework.Event; import alpine.persistence.Transaction; +import org.dependencytrack.event.ProjectVulnerabilityUpdateEvent; import org.dependencytrack.model.AffectedVersionAttribution; import org.dependencytrack.model.Vulnerability; import org.dependencytrack.model.VulnerableSoftware; +import org.dependencytrack.model.VulnerabilityUpdateDiff; +import org.dependencytrack.model.Severity; import org.dependencytrack.persistence.QueryManager; import org.dependencytrack.util.PersistenceUtil; @@ -55,6 +59,15 @@ Vulnerability synchronizeVulnerability(final QueryManager qm, final Vulnerabilit final Map diffs = updateVulnerability(persistentVuln, vuln); if (!diffs.isEmpty()) { logger.debug("%s has changed: %s".formatted(vuln.getVulnId(), diffs)); + + // Check if we need to emit a vulnerability changed event + if (diffs.containsKey("severity") && diffs.get("severity") != null) { + final PersistenceUtil.Diff severityDiff = diffs.get("severity"); + if (severityDiff.before() instanceof final Severity oldSeverity && severityDiff.after() instanceof final Severity newSeverity) { + Event.dispatch(new ProjectVulnerabilityUpdateEvent(persistentVuln, new VulnerabilityUpdateDiff(oldSeverity, newSeverity))); + } + } + return persistentVuln; } diff --git a/src/main/java/org/dependencytrack/tasks/ProjectVulnerabilityUpdateTask.java b/src/main/java/org/dependencytrack/tasks/ProjectVulnerabilityUpdateTask.java new file mode 100644 index 0000000000..bbb89f5d17 --- /dev/null +++ b/src/main/java/org/dependencytrack/tasks/ProjectVulnerabilityUpdateTask.java @@ -0,0 +1,47 @@ +/* + * 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; + +import alpine.common.logging.Logger; +import alpine.event.framework.Event; +import alpine.event.framework.Subscriber; +import org.dependencytrack.event.ProjectVulnerabilityUpdateEvent; +import org.dependencytrack.model.Vulnerability; +import org.dependencytrack.model.VulnerabilityUpdateDiff; +import org.dependencytrack.persistence.QueryManager; +import org.dependencytrack.util.NotificationUtil; + +public class ProjectVulnerabilityUpdateTask implements Subscriber { + + private static final Logger LOGGER = Logger.getLogger(ProjectVulnerabilityUpdateTask.class); + + @Override + public void inform(Event e) { + if (e instanceof ProjectVulnerabilityUpdateEvent event){ + try (final var qm = new QueryManager()) { + Vulnerability vulnerability = event.getVulnerability(); + VulnerabilityUpdateDiff diff = event.getVulnerabilityUpdateDiff(); + + NotificationUtil.analyzeNotificationCriteria(qm, vulnerability, diff); + } catch (Exception ex) { + LOGGER.error("An unknown error occurred while analyzing notification criteria for vulnerability change", ex); + } + } + } +} diff --git a/src/main/java/org/dependencytrack/util/NotificationUtil.java b/src/main/java/org/dependencytrack/util/NotificationUtil.java index 4eb50a6320..54eb0c8837 100644 --- a/src/main/java/org/dependencytrack/util/NotificationUtil.java +++ b/src/main/java/org/dependencytrack/util/NotificationUtil.java @@ -39,6 +39,7 @@ import org.dependencytrack.model.ViolationAnalysisState; import org.dependencytrack.model.Vulnerability; import org.dependencytrack.model.VulnerabilityAnalysisLevel; +import org.dependencytrack.model.VulnerabilityUpdateDiff; import org.dependencytrack.notification.NotificationConstants; import org.dependencytrack.notification.NotificationGroup; import org.dependencytrack.notification.NotificationScope; @@ -52,6 +53,7 @@ import org.dependencytrack.notification.vo.PolicyViolationIdentified; import org.dependencytrack.notification.vo.VexConsumedOrProcessed; import org.dependencytrack.notification.vo.ViolationAnalysisDecisionChange; +import org.dependencytrack.notification.vo.ProjectVulnerabilityUpdate; import org.dependencytrack.parser.common.resolver.CweResolver; import org.dependencytrack.persistence.QueryManager; @@ -108,6 +110,29 @@ public static void analyzeNotificationCriteria(QueryManager qm, Vulnerability vu } } + public static void analyzeNotificationCriteria(QueryManager qm, Vulnerability vulnerability, VulnerabilityUpdateDiff vulnerabilityUpdateDiff) { + // Vulnerabilities are not guaranteed to include component relationships depending on where the event was generated + final Vulnerability completeVulnerability = qm.getVulnerabilityByVulnId(vulnerability.getSource(), vulnerability.getVulnId()); + final List components = completeVulnerability.getComponents(); + if (components != null && !components.isEmpty()) { + // To reduce noise we only emit a single notification for each updated vulnerability if it affects one + // of our components. The component details are still useful for event consumers, so we pick the first one. + final Component detachedComponent = qm.detach(Component.class, components.get(0).getId()); + + final Vulnerability detachedVuln = qm.detach(Vulnerability.class, completeVulnerability.getId()); + detachedVuln.setAliases(qm.detach(qm.getVulnerabilityAliases(completeVulnerability))); // Aliases are lost during detach above + + Notification.dispatch(new Notification() + .scope(NotificationScope.PORTFOLIO) + .group(NotificationGroup.PROJECT_VULNERABILITY_UPDATED) + .title(generateNotificationTitle(NotificationConstants.Title.VULNERABILITY_UPDATED,null)) + .level(NotificationLevel.INFORMATIONAL) + .content(generateNotificationContent(detachedVuln, vulnerabilityUpdateDiff, detachedComponent)) + .subject(new ProjectVulnerabilityUpdate(detachedVuln, vulnerabilityUpdateDiff, detachedComponent)) + ); + } + } + public static void analyzeNotificationCriteria(final QueryManager qm, Component component) { List vulnerabilities = qm.getAllVulnerabilities(component, false); if (vulnerabilities != null && !vulnerabilities.isEmpty()) { @@ -398,6 +423,45 @@ public static JsonObject toJson(final NewVulnerabilityIdentified vo) { return builder.build(); } + public static JsonObject toJson(final ProjectVulnerabilityUpdate vo) { + final JsonObjectBuilder builder = Json.createObjectBuilder(); + final Vulnerability vulnerability = vo.getVulnerability(); + if (vulnerability.getUuid() != null) { + final JsonObjectBuilder vulnerabilityBuilder = Json.createObjectBuilder(); + vulnerabilityBuilder.add("uuid", vulnerability.getUuid().toString()); + JsonUtil.add(vulnerabilityBuilder, "vulnId", vulnerability.getVulnId()); + JsonUtil.add(vulnerabilityBuilder, "source", vulnerability.getSource()); + final JsonArrayBuilder aliasesBuilder = Json.createArrayBuilder(); + if (vulnerability.getAliases() != null) { + for (final Map.Entry vulnIdBySource : VulnerabilityUtil.getUniqueAliases(vulnerability)) { + aliasesBuilder.add(Json.createObjectBuilder() + .add("source", vulnIdBySource.getKey().name()) + .add("vulnId", vulnIdBySource.getValue()) + .build()); + } + } + vulnerabilityBuilder.add("aliases", aliasesBuilder.build()); + + final VulnerabilityUpdateDiff vulnerabilityUpdateDiff = vo.getVulnerabilityUpdateDiff(); + if (vulnerabilityUpdateDiff != null) { + final JsonObjectBuilder oldBuilder = Json.createObjectBuilder(); + oldBuilder.add("severity", vulnerabilityUpdateDiff.getOldSeverity().toString()); + vulnerabilityBuilder.add("old", oldBuilder.build()); + + final JsonObjectBuilder newBuilder = Json.createObjectBuilder(); + newBuilder.add("severity", vulnerabilityUpdateDiff.getNewSeverity().toString()); + vulnerabilityBuilder.add("new", newBuilder.build()); + } + + builder.add("vulnerability", vulnerabilityBuilder.build()); + } + + if (vo.getComponent() != null) { + builder.add("component", toJson(vo.getComponent())); + } + return builder.build(); + } + public static JsonObject toJson(final NewVulnerableDependency vo) { final JsonObjectBuilder builder = Json.createObjectBuilder(); if (vo.getComponent().getProject() != null) { @@ -600,6 +664,10 @@ private static String generateNotificationContent(final Vulnerability vulnerabil return content; } + private static String generateNotificationContent(final Vulnerability vulnerability, final VulnerabilityUpdateDiff vulnerabilityUpdateDiff, final Component component){ + return "The vulnerability " + vulnerability.getVulnId() + " on component " + component.getName() + " has changed severity from " + vulnerabilityUpdateDiff.getOldSeverity() + " to " + vulnerabilityUpdateDiff.getNewSeverity(); + } + private static String generateNotificationContent(final PolicyViolation policyViolation) { return "A " + policyViolation.getType().name().toLowerCase() + " policy violation occurred"; } diff --git a/src/main/resources/templates/notification/publisher/cswebex.peb b/src/main/resources/templates/notification/publisher/cswebex.peb index c458226e27..bf0a7e4614 100644 --- a/src/main/resources/templates/notification/publisher/cswebex.peb +++ b/src/main/resources/templates/notification/publisher/cswebex.peb @@ -1 +1 @@ -{"markdown":"**{{ notification.title | escape(strategy="json") }}**{% if notification.group == "NEW_VULNERABILITY" %}\n**VulnID:** {{ subject.vulnerability.vulnId | escape(strategy="json") }}\n**Severity:** {{ subject.vulnerability.severity | escape(strategy="json") }}\n**Source:** {{ subject.vulnerability.source | escape(strategy="json") }}\n**Component:** {{ subject.component.toString | escape(strategy="json") }}\n**Actions:**\n[View Vulnerability]({{ baseUrl }}/vulnerability/?source={{ subject.vulnerability.source | escape(strategy="json") }}&vulnId={{ subject.vulnerability.vulnId | escape(strategy="json") }}){% elseif notification.group == "NEW_VULNERABLE_DEPENDENCY" %}\n**Project:** {{ subject.dependency.project.toString | escape(strategy="json") }}\n**Component:** {{ subject.dependency.component.toString | escape(strategy="json") }}\n**Actions:**\n[View Project]({{ baseUrl }}/projects/?uuid={{ subject.dependency.project.uuid | escape(strategy="json") }}){% endif %}\n[View Component]({{ baseUrl }}/component/?uuid={{ subject.dependency.component.uuid | escape(strategy="json") }})\n**Description:** {{ notification.content | escape(strategy="json") }}"} +{"markdown":"**{{ notification.title | escape(strategy="json") }}**{% if notification.group == "NEW_VULNERABILITY" %}\n**VulnID:** {{ subject.vulnerability.vulnId | escape(strategy="json") }}\n**Severity:** {{ subject.vulnerability.severity | escape(strategy="json") }}\n**Source:** {{ subject.vulnerability.source | escape(strategy="json") }}\n**Component:** {{ subject.component.toString | escape(strategy="json") }}\n**Actions:**\n[View Vulnerability]({{ baseUrl }}/vulnerability/?source={{ subject.vulnerability.source | escape(strategy="json") }}&vulnId={{ subject.vulnerability.vulnId | escape(strategy="json") }}){% elseif notification.group == "PROJECT_VULNERABILITY_UPDATED" %}\n**VulnID:** {{ subject.vulnerability.vulnId | escape(strategy="json") }}\n**OldSeverity:** {{ subject.vulnerabilityUpdateDiff.oldSeverity | escape(strategy="json") }}\n**NewSeverity:** {{ subject.vulnerabilityUpdateDiff.newSeverity | escape(strategy="json") }}\n**Source:** {{ subject.vulnerability.source | escape(strategy="json") }}\n**Component:** {{ subject.component.toString | escape(strategy="json") }}\n**Actions:**\n[View Vulnerability]({{ baseUrl }}/vulnerability/?source={{ subject.vulnerability.source | escape(strategy="json") }}&vulnId={{ subject.vulnerability.vulnId | escape(strategy="json") }}){% elseif notification.group == "NEW_VULNERABLE_DEPENDENCY" %}\n**Project:** {{ subject.dependency.project.toString | escape(strategy="json") }}\n**Component:** {{ subject.dependency.component.toString | escape(strategy="json") }}\n**Actions:**\n[View Project]({{ baseUrl }}/projects/?uuid={{ subject.dependency.project.uuid | escape(strategy="json") }}){% endif %}\n[View Component]({{ baseUrl }}/component/?uuid={{ subject.dependency.component.uuid | escape(strategy="json") }})\n**Description:** {{ notification.content | escape(strategy="json") }}"} diff --git a/src/main/resources/templates/notification/publisher/email.peb b/src/main/resources/templates/notification/publisher/email.peb index 2e09ce5fca..9c58ed8e23 100644 --- a/src/main/resources/templates/notification/publisher/email.peb +++ b/src/main/resources/templates/notification/publisher/email.peb @@ -19,7 +19,16 @@ Other affected projects: {% for affectedProject in notification.subject.affectedProjects %}{% if not (affectedProject.uuid == subject.component.project.uuid) %} Project: [{{ affectedProject.name }} : {{ affectedProject.version }}] Project URL: {{ baseUrl }}/projects/{{ affectedProject.uuid }} -{% endif %}{% endfor %}{% endif %}{% elseif notification.group == "NEW_VULNERABLE_DEPENDENCY" %} +{% endif %}{% endfor %}{% endif %} +{% elseif notification.group == "PROJECT_VULNERABILITY_UPDATED" %} +Vulnerability ID: {{ subject.vulnerability.vulnId }} +Vulnerability URL: {{ baseUrl }}/vulnerability/?source={{ subject.vulnerability.source }}&vulnId={{ subject.vulnerability.vulnId }} +Old Severity: {{ subject.vulnerabilityUpdateDiff.oldSeverity }} +New Severity: {{ subject.vulnerabilityUpdateDiff.newSeverity }} +Source: {{ subject.vulnerability.source }} +Component: {{ subject.component.toString }} +Component URL: {{ baseUrl }}/component/?uuid={{ subject.component.uuid }} +{% elseif notification.group == "NEW_VULNERABLE_DEPENDENCY" %} Project: {{ subject.component.project.toString }} Project URL: {{ baseUrl }}/projects/{{ subject.component.project.uuid }} Component: {{ subject.component.toString }} diff --git a/src/main/resources/templates/notification/publisher/jira.peb b/src/main/resources/templates/notification/publisher/jira.peb index 996abe94c7..71f2fffa56 100644 --- a/src/main/resources/templates/notification/publisher/jira.peb +++ b/src/main/resources/templates/notification/publisher/jira.peb @@ -6,9 +6,11 @@ "issuetype": { "name": "{{ jiraTicketType }}" }, - "summary": "[Dependency-Track] [{{ notification.group | escape(strategy="json") }}] {% if notification.group == "NEW_VULNERABILITY" %}[{{ subject.vulnerability.severity }}] New {{ subject.vulnerability.severity | lower }} vulnerability identified: {{ subject.vulnerability.vulnId }}{% elseif notification.group == "NEW_VULNERABLE_DEPENDENCY" %}Vulnerable dependency introduced on project {{ subject.component.project.name | escape(strategy="json") }}{% else %}{{ notification.title | escape(strategy="json") }}{% endif %}", + "summary": "[Dependency-Track] [{{ notification.group | escape(strategy="json") }}] {% if notification.group == "NEW_VULNERABILITY" %}[{{ subject.vulnerability.severity }}] New {{ subject.vulnerability.severity | lower }} vulnerability identified: {{ subject.vulnerability.vulnId }}{% elseif notification.group == "PROJECT_VULNERABILITY_UPDATED" %}[{{ subject.vulnerabilityUpdateDiff.newSeverity }}] Change in the severity {{ subject.vulnerabilityUpdateDiff.oldSeverity }} to {{ subject.vulnerabilityUpdateDiff.newSeverity }} of a vulnerability: {{ subject.vulnerability.vulnId }}{% elseif notification.group == "NEW_VULNERABLE_DEPENDENCY" %}Vulnerable dependency introduced on project {{ subject.component.project.name | escape(strategy="json") }}{% else %}{{ notification.title | escape(strategy="json") }}{% endif %}", {% if notification.group == "NEW_VULNERABILITY" %} "description": "A new vulnerability has been identified on your project(s).\n\\\\\n\\\\\n*Vulnerability description*\n{code:none|bgColor=white|borderStyle=none}{{ subject.vulnerability.description | escape(strategy="json") }}{code}\n\n*VulnID*\n{{ subject.vulnerability.vulnId }}\n\n*Severity*\n{{ subject.vulnerability.severity | lower | capitalize }}\n\n*Component*\n[{{ subject.component | escape(strategy="json") }}|{{ baseUrl }}/components/{{ subject.component.uuid }}]\n\n*Affected project(s)*\n{% for project in subject.affectedProjects %}- [{{ project.name | escape(strategy="json") }} ({{ project.version | escape(strategy="json") }})|{{ baseUrl }}/projects/{{ project.uuid }}]\n{% endfor %}" + {% elseif notification.group == "PROJECT_VULNERABILITY_UPDATED" %} + "description": "A vulnerability has been updated on your project(s).\n\\\\\n\\\\\n*Vulnerability description*\n{code:none|bgColor=white|borderStyle=none}{{ subject.vulnerability.description | escape(strategy="json") }}{code}\n\n*VulnID*\n{{ subject.vulnerability.vulnId }}\n\n*OldSeverity*\n{{ subject.vulnerabilityUpdateDiff.oldSeverity | lower | capitalize }}\n\n*NewSeverity*\n{{ subject.vulnerabilityUpdateDiff.newSeverity | lower | capitalize }}\n\n*Component*\n[{{ subject.component | escape(strategy="json") }}|{{ baseUrl }}/components/{{ subject.component.uuid }}]" {% elseif notification.group == "NEW_VULNERABLE_DEPENDENCY" %} "description": "A component which contains one or more vulnerabilities has been added to your project.\n\\\\\n\\\\\n*Project*\n[{{ subject.component.project | escape(strategy="json") }}|{{ baseUrl }}/projects/{{ subject.component.project.uuid }}]\n\n*Component*\n[{{ subject.component | escape(strategy="json") }}|{{ baseUrl }}/components/{{ subject.component.uuid }}]\n\n*Vulnerabilities*\n{% for vulnerability in subject.vulnerabilities %}- {{ vulnerability.vulnId }} ({{ vulnerability.severity | lower | capitalize }})\n{% endfor %}" {% else %} diff --git a/src/main/resources/templates/notification/publisher/mattermost.peb b/src/main/resources/templates/notification/publisher/mattermost.peb index 2a01512163..03bc22ee69 100644 --- a/src/main/resources/templates/notification/publisher/mattermost.peb +++ b/src/main/resources/templates/notification/publisher/mattermost.peb @@ -1,5 +1,5 @@ { "username": "Dependency Track", "icon_url": "https://raw.githubusercontent.com/DependencyTrack/branding/master/dt-logo-symbol-blue-background.png", - "text": "#### {{ notification.title | escape(strategy="json") }}\n{{ notification.content | escape(strategy="json") }}\n{% if notification.group == "NEW_VULNERABILITY" %}**Component**: {{ subject.component.toString | escape(strategy="json") }}\n**Vulnerability**: {{ subject.vulnerability.vulnId | escape(strategy="json") }}, {{ subject.vulnerability.severity | escape(strategy="json") }}\n[View Component]({{ baseUrl }}/components/{{ subject.component.uuid | escape(strategy="json") }}) - [View Vulnerability]({{ baseUrl }}/vulnerabilities/{{ subject.vulnerability.source | escape(strategy="json") }}/{{ subject.vulnerability.vulnId | escape(strategy="json") }}){% elseif notification.group == "NEW_VULNERABLE_DEPENDENCY" %}**Project**: {{ subject.project.toString | escape(strategy="json") }}\n**Component**: {{ subject.component.toString | escape(strategy="json") }}\n[View Project]({{ baseUrl }}/projects/{{ subject.project.uuid | escape(strategy="json") }}) - [View Component]({{ baseUrl }}/components/{{ subject.component.uuid | escape(strategy="json") }}){% elseif notification.group == "PROJECT_AUDIT_CHANGE" or notification.group == "GLOBAL_AUDIT_CHANGE" %}**Project**: {{ subject.project.toString | escape(strategy="json") }}\n**Component**: {{ subject.component.toString | escape(strategy="json") }}\n**Vulnerability**: {{ subject.vulnerability.vulnId | escape(strategy="json") }}, {{ subject.vulnerability.severity | escape(strategy="json") }}\n**Analysis**: {{ subject.analysis.analysisState | escape(strategy="json") }}, suppressed: {{ subject.analysis.suppressed | escape(strategy="json") }}\n[View Project]({{ baseUrl }}/projects/{{ subject.project.uuid | escape(strategy="json") }}) - [View Component]({{ baseUrl }}/components/{{ subject.component.uuid | escape(strategy="json") }}) - [View Vulnerability]({{ baseUrl }}/vulnerabilities/{{ subject.vulnerability.source | escape(strategy="json") }}/{{ subject.vulnerability.vulnId | escape(strategy="json") }}){% elseif notification.group == "BOM_CONSUMED" or notification.group == "BOM_PROCESSED" %}**Project**: {{ subject.project.toString | escape(strategy="json") }}\n[View Project]({{ baseUrl }}/projects/{{ subject.project.uuid | escape(strategy="json") }}){% elseif notification.group == "POLICY_VIOLATION" %}**Project**: {{ subject.project.toString | escape(strategy="json") }}\n**Component**: {{ subject.component.toString | escape(strategy="json") }}\n**Policy**: {{ subject.policyViolation.policyCondition.policy.violationState | escape(strategy="json") }}, {{ subject.policyViolation.policyCondition.policy.name | escape(strategy="json") }}\n[View Project]({{ baseUrl }}/projects/{{ subject.project.uuid | escape(strategy="json") }}) - [View Component]({{ baseUrl }}/components/{{ subject.component.uuid | escape(strategy="json") }}){% endif %}" + "text": "#### {{ notification.title | escape(strategy="json") }}\n{{ notification.content | escape(strategy="json") }}\n{% if notification.group == "NEW_VULNERABILITY" %}**Component**: {{ subject.component.toString | escape(strategy="json") }}\n**Vulnerability**: {{ subject.vulnerability.vulnId | escape(strategy="json") }}, {{ subject.vulnerability.severity | escape(strategy="json") }}\n[View Component]({{ baseUrl }}/components/{{ subject.component.uuid | escape(strategy="json") }}) - [View Vulnerability]({{ baseUrl }}/vulnerabilities/{{ subject.vulnerability.source | escape(strategy="json") }}/{{ subject.vulnerability.vulnId | escape(strategy="json") }}){% elseif notification.group == "PROJECT_VULNERABILITY_UPDATED" %}**Component**: {{ subject.component.toString | escape(strategy="json") }}\n**Vulnerability**: {{ subject.vulnerability.vulnId | escape(strategy="json") }}, Old severity: {{ subject.vulnerabilityUpdateDiff.oldSeverity | escape(strategy="json") }}, New severity: {{ subject.vulnerabilityUpdateDiff.newSeverity | escape(strategy="json") }}\n[View Component]({{ baseUrl }}/components/{{ subject.component.uuid | escape(strategy="json") }}) - [View Vulnerability]({{ baseUrl }}/vulnerabilities/{{ subject.vulnerability.source | escape(strategy="json") }}/{{ subject.vulnerability.vulnId | escape(strategy="json") }}){% elseif notification.group == "NEW_VULNERABLE_DEPENDENCY" %}**Project**: {{ subject.project.toString | escape(strategy="json") }}\n**Component**: {{ subject.component.toString | escape(strategy="json") }}\n[View Project]({{ baseUrl }}/projects/{{ subject.project.uuid | escape(strategy="json") }}) - [View Component]({{ baseUrl }}/components/{{ subject.component.uuid | escape(strategy="json") }}){% elseif notification.group == "PROJECT_AUDIT_CHANGE" or notification.group == "GLOBAL_AUDIT_CHANGE" %}**Project**: {{ subject.project.toString | escape(strategy="json") }}\n**Component**: {{ subject.component.toString | escape(strategy="json") }}\n**Vulnerability**: {{ subject.vulnerability.vulnId | escape(strategy="json") }}, {{ subject.vulnerability.severity | escape(strategy="json") }}\n**Analysis**: {{ subject.analysis.analysisState | escape(strategy="json") }}, suppressed: {{ subject.analysis.suppressed | escape(strategy="json") }}\n[View Project]({{ baseUrl }}/projects/{{ subject.project.uuid | escape(strategy="json") }}) - [View Component]({{ baseUrl }}/components/{{ subject.component.uuid | escape(strategy="json") }}) - [View Vulnerability]({{ baseUrl }}/vulnerabilities/{{ subject.vulnerability.source | escape(strategy="json") }}/{{ subject.vulnerability.vulnId | escape(strategy="json") }}){% elseif notification.group == "BOM_CONSUMED" or notification.group == "BOM_PROCESSED" %}**Project**: {{ subject.project.toString | escape(strategy="json") }}\n[View Project]({{ baseUrl }}/projects/{{ subject.project.uuid | escape(strategy="json") }}){% elseif notification.group == "POLICY_VIOLATION" %}**Project**: {{ subject.project.toString | escape(strategy="json") }}\n**Component**: {{ subject.component.toString | escape(strategy="json") }}\n**Policy**: {{ subject.policyViolation.policyCondition.policy.violationState | escape(strategy="json") }}, {{ subject.policyViolation.policyCondition.policy.name | escape(strategy="json") }}\n[View Project]({{ baseUrl }}/projects/{{ subject.project.uuid | escape(strategy="json") }}) - [View Component]({{ baseUrl }}/components/{{ subject.component.uuid | escape(strategy="json") }}){% endif %}" } diff --git a/src/main/resources/templates/notification/publisher/msteams.peb b/src/main/resources/templates/notification/publisher/msteams.peb index c91c8f691b..83f982aa1c 100644 --- a/src/main/resources/templates/notification/publisher/msteams.peb +++ b/src/main/resources/templates/notification/publisher/msteams.peb @@ -27,6 +27,29 @@ "value": "{{ subject.component.toString | escape(strategy="json") }}" } ], + {% elseif notification.group == "PROJECT_VULNERABILITY_UPDATED" %} + "facts": [ + { + "name": "VulnID", + "value": "{{ subject.vulnerability.vulnId | escape(strategy="json") }}" + }, + { + "name": "Old Severity", + "value": "{{ subject.vulnerabilityUpdateDiff.oldSeverity | escape(strategy="json") }}" + }, + { + "name": "New Severity", + "value": "{{ subject.vulnerabilityUpdateDiff.newSeverity | escape(strategy="json") }}" + }, + { + "name": "Source", + "value": "{{ subject.vulnerability.source | escape(strategy="json") }}" + }, + { + "name": "Component", + "value": "{{ subject.component.toString | escape(strategy="json") }}" + } + ], {% elseif notification.group == "NEW_VULNERABLE_DEPENDENCY" %} "facts": [ { diff --git a/src/main/resources/templates/notification/publisher/slack.peb b/src/main/resources/templates/notification/publisher/slack.peb index beb0de3eb9..bdf80761a9 100644 --- a/src/main/resources/templates/notification/publisher/slack.peb +++ b/src/main/resources/templates/notification/publisher/slack.peb @@ -87,6 +87,101 @@ {% endif %} ] } +{% elseif notification.group == "PROJECT_VULNERABILITY_UPDATED" %} +{ + "blocks": [ + { + "type": "header", + "text": { + "type": "plain_text", + "text": "Vulnerability Updated" + } + }, + { + "type": "context", + "elements": [ + { + "text": "*{{ notification.level | escape(strategy="json") }}* | *{{ notification.scope | escape(strategy="json") }}*", + "type": "mrkdwn" + } + ] + }, + { + "type": "divider" + }, + { + "type": "section", + "text": { + "text": "{{ notification.title | escape(strategy="json") }}", + "type": "mrkdwn" + }, + "fields": [ + { + "type": "mrkdwn", + "text": "*VulnID*" + }, + { + "type": "plain_text", + "text": "{{ subject.vulnerability.vulnId | escape(strategy="json") }}" + }, + { + "type": "mrkdwn", + "text": "*Old Severity*" + }, + { + "type": "plain_text", + "text": "{{ subject.vulnerabilityUpdateDiff.oldSeverity | escape(strategy="json") }}" + },{ + "type": "mrkdwn", + "text": "*New Severity*" + }, + { + "type": "plain_text", + "text": "{{ subject.vulnerabilityUpdateDiff.newSeverity | escape(strategy="json") }}" + }, + { + "type": "mrkdwn", + "text": "*Source*" + }, + { + "type": "plain_text", + "text": "{{ subject.vulnerability.source | escape(strategy="json") }}" + }, + { + "type": "mrkdwn", + "text": "*Component*" + }, + { + "type": "plain_text", + "text": "{{ subject.component.toString | escape(strategy="json") }}" + } + ] + }, + { + "type": "actions", + "elements": [ + { + "type": "button", + "text": { + "type": "plain_text", + "text": "View Vulnerability" + }, + "action_id": "actionId-1", + "url": "{{ baseUrl }}/vulnerabilities/{{ subject.vulnerability.source | escape(strategy="json") }}/{{ subject.vulnerability.vulnId | escape(strategy="json") }}" + }, + { + "type": "button", + "text": { + "type": "plain_text", + "text": "View Component" + }, + "action_id": "actionId-2", + "url": "{{ baseUrl }}/components/{{ subject.component.uuid | escape(strategy="json") }}" + } + ] + } + ] +} {% elseif notification.group == "NEW_VULNERABLE_DEPENDENCY" %} { "blocks": [ diff --git a/src/test/java/org/dependencytrack/event/ProjectVulnerabilityUpdateEventTest.java b/src/test/java/org/dependencytrack/event/ProjectVulnerabilityUpdateEventTest.java new file mode 100644 index 0000000000..81d5bc9a97 --- /dev/null +++ b/src/test/java/org/dependencytrack/event/ProjectVulnerabilityUpdateEventTest.java @@ -0,0 +1,39 @@ +/* + * 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) Steve Springett. All Rights Reserved. + */ +package org.dependencytrack.event; + +import org.dependencytrack.model.Severity; +import org.dependencytrack.model.Vulnerability; +import org.dependencytrack.model.VulnerabilityUpdateDiff; +import org.junit.Assert; +import org.junit.Test; + + +public class ProjectVulnerabilityUpdateEventTest { + + @Test + public void testEvent() { + Vulnerability vulnerability = new Vulnerability(); + VulnerabilityUpdateDiff vulnerabilityUpdateDiff = new VulnerabilityUpdateDiff(Severity.UNASSIGNED, Severity.HIGH); + ProjectVulnerabilityUpdateEvent event = new ProjectVulnerabilityUpdateEvent(vulnerability, vulnerabilityUpdateDiff); + Assert.assertNotNull(event.getVulnerability()); + Assert.assertEquals(Severity.UNASSIGNED, event.getVulnerabilityUpdateDiff().getOldSeverity()); + Assert.assertEquals(Severity.HIGH, event.getVulnerabilityUpdateDiff().getNewSeverity()); + } +} diff --git a/src/test/java/org/dependencytrack/notification/NotificationConstantsTest.java b/src/test/java/org/dependencytrack/notification/NotificationConstantsTest.java index 48f7252e67..4604885972 100644 --- a/src/test/java/org/dependencytrack/notification/NotificationConstantsTest.java +++ b/src/test/java/org/dependencytrack/notification/NotificationConstantsTest.java @@ -38,6 +38,7 @@ public void testConstants() { Assert.assertEquals("Repository Error", NotificationConstants.Title.REPO_ERROR); Assert.assertEquals("Integration Error", NotificationConstants.Title.INTEGRATION_ERROR); Assert.assertEquals("New Vulnerability Identified", NotificationConstants.Title.NEW_VULNERABILITY); + Assert.assertEquals("Vulnerability Updated", NotificationConstants.Title.VULNERABILITY_UPDATED); Assert.assertEquals("Vulnerable Dependency Introduced", NotificationConstants.Title.NEW_VULNERABLE_DEPENDENCY); Assert.assertEquals("Analysis Decision: Exploitable", NotificationConstants.Title.ANALYSIS_DECISION_EXPLOITABLE); Assert.assertEquals("Analysis Decision: In Triage", NotificationConstants.Title.ANALYSIS_DECISION_IN_TRIAGE); diff --git a/src/test/java/org/dependencytrack/notification/NotificationGroupTest.java b/src/test/java/org/dependencytrack/notification/NotificationGroupTest.java index d10d5f0dff..09ace4badb 100644 --- a/src/test/java/org/dependencytrack/notification/NotificationGroupTest.java +++ b/src/test/java/org/dependencytrack/notification/NotificationGroupTest.java @@ -34,6 +34,7 @@ public void testEnums() { Assert.assertEquals("INDEXING_SERVICE", NotificationGroup.INDEXING_SERVICE.name()); // Portfolio Groups Assert.assertEquals("NEW_VULNERABILITY", NotificationGroup.NEW_VULNERABILITY.name()); + Assert.assertEquals("PROJECT_VULNERABILITY_UPDATED", NotificationGroup.PROJECT_VULNERABILITY_UPDATED.name()); Assert.assertEquals("NEW_VULNERABLE_DEPENDENCY", NotificationGroup.NEW_VULNERABLE_DEPENDENCY.name()); //Assert.assertEquals("NEW_OUTDATED_COMPONENT", NotificationGroup.NEW_OUTDATED_COMPONENT.name()); //Assert.assertEquals("FIXED_VULNERABILITY", NotificationGroup.FIXED_VULNERABILITY.name()); diff --git a/src/test/java/org/dependencytrack/notification/NotificationRouterTest.java b/src/test/java/org/dependencytrack/notification/NotificationRouterTest.java index 944b59bcf3..8dc5817fa6 100644 --- a/src/test/java/org/dependencytrack/notification/NotificationRouterTest.java +++ b/src/test/java/org/dependencytrack/notification/NotificationRouterTest.java @@ -41,10 +41,12 @@ import org.dependencytrack.notification.vo.PolicyViolationIdentified; import org.dependencytrack.notification.vo.VexConsumedOrProcessed; import org.dependencytrack.notification.vo.ViolationAnalysisDecisionChange; +import org.dependencytrack.notification.vo.ProjectVulnerabilityUpdate; import org.junit.Assert; import org.junit.Test; import jakarta.json.JsonObject; + import java.util.ArrayList; import java.util.HashSet; import java.util.List; @@ -347,6 +349,118 @@ public void testNewVulnerabilityIdentifiedLimitedToProject() { .satisfiesExactly(resolvedRule -> assertThat(resolvedRule.getName()).isEqualTo("Test Rule")); } + @Test + public void testVulnerabilityUpdateLimitedToProject() { + final Project projectA = qm.createProject("Project A", null, "1.0", null, null, null, true, false); + var componentA = new Component(); + componentA.setProject(projectA); + componentA.setName("Component A"); + componentA.setPurl("pkg:npm/foo@1.0.0"); + componentA = qm.createComponent(componentA, false); + var vulnerabilityA = new Vulnerability(); + vulnerabilityA.setSource("INTERNAL"); + vulnerabilityA.setVulnId("INTERNAL-A"); + vulnerabilityA.setComponents(List.of(componentA)); + vulnerabilityA = qm.createVulnerability(vulnerabilityA, false); + + final Project projectB = qm.createProject("Project B", null, "1.0", null, null, null, true, false); + var componentB = new Component(); + componentB.setProject(projectB); + componentB.setName("Component B"); + componentB.setPurl("pkg:npm/bar@2.0.0"); + componentB = qm.createComponent(componentB, false); + var vulnerabilityB = new Vulnerability(); + vulnerabilityB.setSource("INTERNAL"); + vulnerabilityB.setVulnId("INTERNAL-B"); + vulnerabilityB.setComponents(List.of(componentB)); + vulnerabilityB = qm.createVulnerability(vulnerabilityB, false); + + final NotificationPublisher publisher = createSlackPublisher(); + + final NotificationRule rule = qm.createNotificationRule("Test Rule", NotificationScope.PORTFOLIO, NotificationLevel.INFORMATIONAL, publisher); + rule.setNotifyOn(Set.of(NotificationGroup.PROJECT_VULNERABILITY_UPDATED)); + rule.setProjects(List.of(projectA)); + + final var notification = new Notification(); + notification.setScope(NotificationScope.PORTFOLIO.name()); + notification.setGroup(NotificationGroup.PROJECT_VULNERABILITY_UPDATED.name()); + notification.setLevel(NotificationLevel.INFORMATIONAL); + notification.setSubject(new ProjectVulnerabilityUpdate(vulnerabilityB, null, componentB)); + + final var router = new NotificationRouter(); + assertThat(router.resolveRules(PublishContext.from(notification), notification)).isEmpty(); + + notification.setSubject(new ProjectVulnerabilityUpdate(vulnerabilityA, null, componentA)); + assertThat(router.resolveRules(PublishContext.from(notification), notification)) + .satisfiesExactly(resolvedRule -> assertThat(resolvedRule.getName()).isEqualTo("Test Rule")); + } + + @Test + public void testVulnerabilityUpdateComponentMatchesAcrossProjects() { + final Project projectA = qm.createProject("Project A", null, "1.0", null, null, null, true, false); + var componentA = new Component(); + componentA.setProject(projectA); + componentA.setName("Component A"); + componentA.setPurl("pkg:npm/foo@1.0.0"); + componentA = qm.createComponent(componentA, false); + + final Project projectB = qm.createProject("Project B", null, "1.0", null, null, null, true, false); + var componentB = new Component(); + componentB.setProject(projectB); + componentB.setName("Component B"); + componentB.setPurl("pkg:npm/foo@1.0.0"); // same purl + componentB = qm.createComponent(componentB, false); + + final ArrayList components = new ArrayList<>(); + components.add(componentA); + components.add(componentB); + + var vulnerability = new Vulnerability(); + vulnerability.setSource("INTERNAL"); + vulnerability.setVulnId("INTERNAL-A"); + vulnerability.setComponents(components); + vulnerability = qm.createVulnerability(vulnerability, false); + + final NotificationPublisher publisher = createSlackPublisher(); + + final NotificationRule rule = qm.createNotificationRule("Test Rule", NotificationScope.PORTFOLIO, NotificationLevel.INFORMATIONAL, publisher); + rule.setNotifyOn(Set.of(NotificationGroup.PROJECT_VULNERABILITY_UPDATED)); + rule.setProjects(List.of(projectA)); + + final var notification = new Notification(); + notification.setScope(NotificationScope.PORTFOLIO.name()); + notification.setGroup(NotificationGroup.PROJECT_VULNERABILITY_UPDATED.name()); + notification.setLevel(NotificationLevel.INFORMATIONAL); + notification.setSubject(new ProjectVulnerabilityUpdate(vulnerability, null, componentA)); + + // Both component identifiers match, so it shouldn't matter which one we pass to the notification router even + // though only one of them is associated with a project in scope for the rule. + final var router = new NotificationRouter(); + assertThat(router.resolveRules(PublishContext.from(notification), notification)) + .satisfiesExactly(resolvedRule -> assertThat(resolvedRule.getName()).isEqualTo("Test Rule")); + + notification.setSubject(new ProjectVulnerabilityUpdate(vulnerability, null, componentB)); + assertThat(router.resolveRules(PublishContext.from(notification), notification)) + .satisfiesExactly(resolvedRule -> assertThat(resolvedRule.getName()).isEqualTo("Test Rule")); + } + + @Test + public void testVulnerabilityUpdateWithNoComponents() { + final NotificationPublisher publisher = createSlackPublisher(); + + final NotificationRule rule = qm.createNotificationRule("Test Rule", NotificationScope.PORTFOLIO, NotificationLevel.INFORMATIONAL, publisher); + rule.setNotifyOn(Set.of(NotificationGroup.PROJECT_VULNERABILITY_UPDATED)); + + final var notification = new Notification(); + notification.setScope(NotificationScope.PORTFOLIO.name()); + notification.setGroup(NotificationGroup.PROJECT_VULNERABILITY_UPDATED.name()); + notification.setLevel(NotificationLevel.INFORMATIONAL); + notification.setSubject(new ProjectVulnerabilityUpdate(null, null, null)); + + final var router = new NotificationRouter(); + assertThat(router.resolveRules(PublishContext.from(notification), notification)).isEmpty(); + } + @Test public void testNewVulnerableDependencyLimitedToProject() { final Project projectA = qm.createProject("Project A", null, "1.0", null, null, null, true, false); diff --git a/src/test/java/org/dependencytrack/notification/publisher/AbstractPublisherTest.java b/src/test/java/org/dependencytrack/notification/publisher/AbstractPublisherTest.java index 1f46b1b45f..4a2fc77956 100644 --- a/src/test/java/org/dependencytrack/notification/publisher/AbstractPublisherTest.java +++ b/src/test/java/org/dependencytrack/notification/publisher/AbstractPublisherTest.java @@ -30,6 +30,7 @@ import org.dependencytrack.model.Severity; import org.dependencytrack.model.Tag; import org.dependencytrack.model.Vulnerability; +import org.dependencytrack.model.VulnerabilityUpdateDiff; import org.dependencytrack.model.VulnerabilityAnalysisLevel; import org.dependencytrack.notification.NotificationConstants; import org.dependencytrack.notification.NotificationGroup; @@ -39,7 +40,7 @@ import org.dependencytrack.notification.vo.BomProcessingFailed; import org.dependencytrack.notification.vo.BomValidationFailed; import org.dependencytrack.notification.vo.NewVulnerabilityIdentified; -import org.dependencytrack.resources.v1.problems.InvalidBomProblemDetails; +import org.dependencytrack.notification.vo.ProjectVulnerabilityUpdate; import org.dependencytrack.notification.vo.NewVulnerableDependency; import org.junit.Test; @@ -193,6 +194,28 @@ public void testInformWithNewVulnerableDependencyNotification() { .isThrownBy(() -> publisherInstance.inform(PublishContext.from(notification), notification, createConfig())); } + @Test + public void testInformWithVulnerabilityUpdateNotification() { + final var project = createProject(); + final var component = createComponent(project); + final var vuln = createVulnerability(); + final var vulnUpdateDiff = createVulnerabilityUpdateDiff(vuln); + + final var subject = new ProjectVulnerabilityUpdate(vuln, vulnUpdateDiff, component); + + final var notification = new Notification() + .scope(NotificationScope.PORTFOLIO) + .group(NotificationGroup.PROJECT_VULNERABILITY_UPDATED) + .level(NotificationLevel.INFORMATIONAL) + .title(NotificationConstants.Title.VULNERABILITY_UPDATED) + .content("") + .timestamp(LocalDateTime.ofEpochSecond(66666, 666, ZoneOffset.UTC)) + .subject(subject); + + assertThatNoException() + .isThrownBy(() -> publisherInstance.inform(PublishContext.from(notification), notification, createConfig())); + } + @Test public void testInformWithProjectAuditChangeNotification() { final var project = createProject(); @@ -264,6 +287,10 @@ private static Vulnerability createVulnerability() { return vuln; } + private static VulnerabilityUpdateDiff createVulnerabilityUpdateDiff(final Vulnerability vulnerability) { + return new org.dependencytrack.model.VulnerabilityUpdateDiff(Severity.UNASSIGNED, vulnerability.getSeverity()); + } + private static Analysis createAnalysis(final Component component, final Vulnerability vuln) { final var analysis = new Analysis(); analysis.setComponent(component); diff --git a/src/test/java/org/dependencytrack/notification/vo/ProjectVulnerabilityUpdateTest.java b/src/test/java/org/dependencytrack/notification/vo/ProjectVulnerabilityUpdateTest.java new file mode 100644 index 0000000000..a3f8810813 --- /dev/null +++ b/src/test/java/org/dependencytrack/notification/vo/ProjectVulnerabilityUpdateTest.java @@ -0,0 +1,22 @@ +package org.dependencytrack.notification.vo; + +import org.dependencytrack.model.Component; +import org.dependencytrack.model.Severity; +import org.dependencytrack.model.Vulnerability; +import org.dependencytrack.model.VulnerabilityUpdateDiff; +import org.junit.Assert; +import org.junit.Test; + +public class ProjectVulnerabilityUpdateTest { + + @Test + public void testVo() { + Vulnerability vuln = new Vulnerability(); + VulnerabilityUpdateDiff vulnDiff = new VulnerabilityUpdateDiff(Severity.UNASSIGNED, Severity.LOW); + Component component = new Component(); + ProjectVulnerabilityUpdate vo = new ProjectVulnerabilityUpdate(vuln, vulnDiff, component); + Assert.assertEquals(vuln, vo.getVulnerability()); + Assert.assertEquals(vulnDiff, vo.getVulnerabilityUpdateDiff()); + Assert.assertEquals(component, vo.getComponent()); + } +} diff --git a/src/test/java/org/dependencytrack/util/NotificationUtilTest.java b/src/test/java/org/dependencytrack/util/NotificationUtilTest.java new file mode 100644 index 0000000000..c3595915fa --- /dev/null +++ b/src/test/java/org/dependencytrack/util/NotificationUtilTest.java @@ -0,0 +1,186 @@ +package org.dependencytrack.util; + +import alpine.notification.Notification; +import alpine.notification.NotificationLevel; +import alpine.notification.NotificationService; +import alpine.notification.Subscriber; +import alpine.notification.Subscription; +import jakarta.json.JsonObject; +import org.dependencytrack.PersistenceCapableTest; + +import org.dependencytrack.model.Component; +import org.dependencytrack.model.Project; +import org.dependencytrack.model.Severity; +import org.dependencytrack.model.Vulnerability; +import org.dependencytrack.model.VulnerabilityUpdateDiff; +import org.dependencytrack.notification.NotificationGroup; +import org.dependencytrack.notification.NotificationScope; +import org.dependencytrack.notification.vo.ProjectVulnerabilityUpdate; +import org.junit.After; +import org.junit.AfterClass; +import org.junit.Before; +import org.junit.BeforeClass; +import org.junit.Test; + +import java.time.Duration; +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.ConcurrentLinkedQueue; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.awaitility.Awaitility.await; + +public class NotificationUtilTest extends PersistenceCapableTest { + + public static class NotificationSubscriber implements Subscriber { + + @Override + public void inform(final Notification notification) { + NOTIFICATIONS.add(notification); + } + + } + + private static final ConcurrentLinkedQueue NOTIFICATIONS = new ConcurrentLinkedQueue<>(); + + @BeforeClass + public static void setUpClass() { + NotificationService.getInstance().subscribe(new Subscription(NotificationUtilTest.NotificationSubscriber.class)); + } + + @AfterClass + public static void tearDownClass() { + NotificationService.getInstance().unsubscribe(new Subscription(NotificationUtilTest.NotificationSubscriber.class)); + } + + @Before + public void setup() { + NOTIFICATIONS.clear(); + } + + @After + public void tearDown() { + NOTIFICATIONS.clear(); + } + + @Test + public void testVulnerabilityUpdateNoAffectedComponents() { + Vulnerability vulnerability = new Vulnerability(); + vulnerability.setVulnId("CVE-2024-12345"); + vulnerability.setSource(Vulnerability.Source.NVD); + vulnerability.setSeverity(Severity.CRITICAL); + qm.createVulnerability(vulnerability, false); + + final VulnerabilityUpdateDiff vulnerabilityUpdateDiff = new VulnerabilityUpdateDiff(Severity.UNASSIGNED, vulnerability.getSeverity()); + + NotificationUtil.analyzeNotificationCriteria(qm, vulnerability, vulnerabilityUpdateDiff); + + // The Awaitility API is a bit awkward for asserting that something did not happen. + // Here we wait for 3 continuous seconds (out of the 4s timeout period) where there is no vuln update notification + // and fail early if we do find one. Due to the polling implementation atMost must be > the 'during' internal or + // we will trigger a timeout and fail the test. + org.awaitility.core.ThrowingRunnable assertion = ( + () -> assertThat(NOTIFICATIONS).extracting(Notification::getGroup).doesNotContain(NotificationGroup.PROJECT_VULNERABILITY_UPDATED.name()) + ); + await().during(Duration.ofSeconds(3)).atMost(Duration.ofSeconds(4)) + .failFast(assertion) + .untilAsserted(assertion); + } + + @Test + public void testVulnerabilityUpdateMultipleComponents() { + final Project projectA = qm.createProject("Project A", null, "1.0", null, null, null, true, false); + var componentA = new Component(); + componentA.setProject(projectA); + componentA.setName("Component A"); + componentA.setPurl("pkg:npm/foo@1.0.0"); + componentA = qm.createComponent(componentA, false); + + final Project projectB = qm.createProject("Project B", null, "1.0", null, null, null, true, false); + var componentB = new Component(); + componentB.setProject(projectB); + componentB.setName("Component B"); + componentB.setPurl("pkg:npm/foo@1.0.0"); // same purl + componentB = qm.createComponent(componentB, false); + + final ArrayList components = new ArrayList<>(); + components.add(componentA); + components.add(componentB); + + Vulnerability vulnerability = new Vulnerability(); + vulnerability.setVulnId("CVE-2024-12345"); + vulnerability.setSource(Vulnerability.Source.NVD); + vulnerability.setSeverity(Severity.CRITICAL); + vulnerability.setComponents(components); + qm.createVulnerability(vulnerability, false); + + final VulnerabilityUpdateDiff vulnerabilityUpdateDiff = new VulnerabilityUpdateDiff(Severity.UNASSIGNED, vulnerability.getSeverity()); + + NotificationUtil.analyzeNotificationCriteria(qm, vulnerability, vulnerabilityUpdateDiff); + + // During the waiting period we expect no more than one vuln update notification + org.awaitility.core.ThrowingRunnable assertNoMoreThanOneNotification = ( + () -> assertThat(NOTIFICATIONS) + .filteredOn(notification -> notification.getGroup().equals(NotificationGroup.PROJECT_VULNERABILITY_UPDATED.name())) + .hasSizeLessThanOrEqualTo(1) + ); + await().during(Duration.ofSeconds(3)).atMost(Duration.ofSeconds(4)) + .failFast(assertNoMoreThanOneNotification) + .untilAsserted(assertNoMoreThanOneNotification); + + // After the waiting period we expect exactly one vuln update notification + assertThat(NOTIFICATIONS) + .filteredOn(notification -> notification.getGroup().equals(NotificationGroup.PROJECT_VULNERABILITY_UPDATED.name())) + .hasSize(1) + .satisfiesExactly(notification -> { + assertThat(notification.getScope()).isEqualTo(NotificationScope.PORTFOLIO.name()); + assertThat(notification.getGroup()).isEqualTo(NotificationGroup.PROJECT_VULNERABILITY_UPDATED.name()); + assertThat(notification.getLevel()).isEqualTo(NotificationLevel.INFORMATIONAL); + assertThat(notification.getSubject()).isInstanceOf(ProjectVulnerabilityUpdate.class); + final var subject = (ProjectVulnerabilityUpdate) notification.getSubject(); + assertThat(components.stream().map(Component::getUuid).toList()).contains(subject.getComponent().getUuid()); + assertThat(subject.getVulnerability().getUuid()).isEqualTo(vulnerability.getUuid()); + assertThat(subject.getVulnerabilityUpdateDiff().getOldSeverity()).isEqualTo(vulnerabilityUpdateDiff.getOldSeverity()); + assertThat(subject.getVulnerabilityUpdateDiff().getNewSeverity()).isEqualTo(vulnerabilityUpdateDiff.getNewSeverity()); + }); + } + + @Test + public void testVulnerabilityUpdateToJson() { + final Project project = qm.createProject("Project A", null, "1.0", null, null, null, true, false); + var component = new Component(); + component.setProject(project); + component.setName("Component A"); + component.setPurl("pkg:npm/foo@1.0.0"); + component = qm.createComponent(component, false); + + var vulnerability = new Vulnerability(); + vulnerability.setVulnId("CVE-2024-12345"); + vulnerability.setSource(Vulnerability.Source.NVD); + vulnerability.setSeverity(Severity.CRITICAL); + vulnerability.setComponents(List.of(component)); + vulnerability = qm.createVulnerability(vulnerability, false); + + final VulnerabilityUpdateDiff vulnerabilityUpdateDiff = new VulnerabilityUpdateDiff(Severity.UNASSIGNED, vulnerability.getSeverity()); + + final ProjectVulnerabilityUpdate vo = new ProjectVulnerabilityUpdate(vulnerability, vulnerabilityUpdateDiff, component); + final JsonObject subjectJson = NotificationUtil.toJson(vo); + + final String expectedJson = String.format( + "{\"vulnerability\":{\"uuid\":\"%s\",\"vulnId\":\"%s\",\"source\":\"%s\",\"aliases\":[]," + + "\"old\":{\"severity\":\"%s\"},\"new\":{\"severity\":\"%s\"}}," + + "\"component\":{\"uuid\":\"%s\",\"name\":\"%s\",\"purl\":\"%s\"}}", + vulnerability.getUuid(), + vulnerability.getVulnId(), + vulnerability.getSource(), + vulnerabilityUpdateDiff.getOldSeverity(), + vulnerabilityUpdateDiff.getNewSeverity(), + component.getUuid(), + component.getName(), + component.getPurl() + ); + + assertThat(subjectJson).isNotNull(); + assertThat(subjectJson.toString()).isEqualTo(expectedJson); + } +}