diff --git a/docs/_docs/integrations/notifications.md b/docs/_docs/integrations/notifications.md index e8df4af859..2d70fe79a4 100644 --- a/docs/_docs/integrations/notifications.md +++ b/docs/_docs/integrations/notifications.md @@ -62,7 +62,7 @@ multiple levels, while others can only ever have a single level. | SYSTEM | USER_CREATED | Event | INFORMATIONAL | Notifications generated as a result of a user creation | | SYSTEM | USER_DELETED | Event | INFORMATIONAL | Notifications generated as a result of a user deletion | | PORTFOLIO | NEW_VULNERABILITY | Event | INFORMATIONAL | Notifications generated whenever a new vulnerability is identified | -| PORTFOLIO | NEW_VULNERABILITIES_SUMMARY | Schedule | INFORMATIONAL | Summaries of new vulnerabilities identified in a set of projects | +| PORTFOLIO | NEW_VULNERABILITIES_SUMMARY | Schedule | INFORMATIONAL | Summaries of new vulnerabilities identified in a set of projects and/or tags | | PORTFOLIO | NEW_VULNERABLE_DEPENDENCY | Event | INFORMATIONAL | Notifications generated as a result of a vulnerable component becoming a dependency of a project | | PORTFOLIO | GLOBAL_AUDIT_CHANGE | Event | INFORMATIONAL | Notifications generated whenever an analysis or suppression state has changed on a finding from a component (global) | | PORTFOLIO | PROJECT_AUDIT_CHANGE | Event | INFORMATIONAL | Notifications generated whenever an analysis or suppression state has changed on a finding from a project | @@ -71,7 +71,7 @@ multiple levels, while others can only ever have a single level. | PORTFOLIO | BOM_PROCESSING_FAILED | Event | ERROR | Notifications generated whenever a BOM upload process fails | | PORTFOLIO | BOM_VALIDATION_FAILED | Event | ERROR | Notifications generated whenever an invalid BOM is uploaded | | PORTFOLIO | POLICY_VIOLATION | Event | INFORMATIONAL | Notifications generated whenever a policy violation is identified | -| PORTFOLIO | NEW_POLICY_VIOLATIONS_SUMMARY | Schedule | INFORMATIONAL | Summary of new policy violations identified in a set of projects | +| PORTFOLIO | NEW_POLICY_VIOLATIONS_SUMMARY | Schedule | INFORMATIONAL | Summary of new policy violations identified in a set of projects and/or tags | ## Configuring Publishers @@ -179,13 +179,13 @@ This type of notification will always contain: #### NEW_VULNERABILITIES_SUMMARY -A summary of new vulnerabilities identified in a set of projects. "New" in this context refers to vulnerabilities -identified *since the notification was last triggered*. For example, if the notification is scheduled to trigger -every day at 8AM (cron expression: `0 8 * * *`) it will always contain newly identified vulnerabilities since +A summary of new vulnerabilities identified in a set of projects and/or tags. "New" in this context refers to +vulnerabilities identified *since the notification was last triggered*. For example, if the notification is scheduled to +trigger every day at 8AM (cron expression: `0 8 * * *`) it will always contain newly identified vulnerabilities since the last day at 8AM. Note that this notification can not be configured to cover the entire portfolio, but only a limited set of -projects. This limitation exists to prevent payloads from growing too large. +projects and/or tags. This limitation exists to prevent payloads from growing too large. ```json { @@ -506,13 +506,13 @@ This type of notification will always contain: #### NEW_POLICY_VIOLATIONS_SUMMARY -A summary of new policy violations identified in a set of projects. "New" in this context refers to violations -identified *since the notification was last triggered*. For example, if the notification is scheduled to trigger -every day at 8AM (cron expression: `0 8 * * *`) it will always contain newly identified violations since +A summary of new policy violations identified in a set of projects and/or tags. "New" in this context refers to +violations identified *since the notification was last triggered*. For example, if the notification is scheduled to +trigger every day at 8AM (cron expression: `0 8 * * *`) it will always contain newly identified violations since the last day at 8AM. Note that this notification can not be configured to cover the entire portfolio, but only a limited set of -projects. This limitation exists to prevent payloads from growing too large. +projects and/or tags. This limitation exists to prevent payloads from growing too large. ```json { @@ -761,7 +761,7 @@ Both the last successful, and the next planned trigger timestamp can be viewed i To further reduce the noise produced by the system, users can opt into skipping the publishing of a notification, if no new data has been identified since the last time it triggered. -Certain notification groups may require the alert to be limited to specific projects. +Certain notification groups may require the alert to be limited to specific projects and/or tags. This is to protect the system from generating payloads that are too resource intensive to compute, or too large for receiving systems to accept. diff --git a/src/main/java/org/dependencytrack/tasks/ScheduledNotificationDispatchTask.java b/src/main/java/org/dependencytrack/tasks/ScheduledNotificationDispatchTask.java index 1d62a8c691..9485352c5f 100644 --- a/src/main/java/org/dependencytrack/tasks/ScheduledNotificationDispatchTask.java +++ b/src/main/java/org/dependencytrack/tasks/ScheduledNotificationDispatchTask.java @@ -23,6 +23,7 @@ import alpine.event.framework.Subscriber; import alpine.notification.Notification; import alpine.notification.NotificationLevel; +import alpine.persistence.PaginatedResult; import alpine.server.util.DbUtil; import org.dependencytrack.event.ScheduledNotificationDispatchEvent; import org.dependencytrack.model.AnalysisState; @@ -32,6 +33,7 @@ import org.dependencytrack.model.PolicyCondition; import org.dependencytrack.model.PolicyViolation; import org.dependencytrack.model.Project; +import org.dependencytrack.model.Tag; import org.dependencytrack.model.ViolationAnalysisState; import org.dependencytrack.model.VulnIdAndSource; import org.dependencytrack.model.Vulnerability; @@ -70,7 +72,6 @@ * @since 4.13.0 */ public class ScheduledNotificationDispatchTask implements Subscriber { - private static final Logger LOGGER = Logger.getLogger(ScheduledNotificationDispatchTask.class); @Override @@ -152,9 +153,10 @@ private void processRule(final QueryManager qm, final NotificationRule rule) { } private Notification createNewVulnerabilitiesNotification(final QueryManager qm, final NotificationRule rule) { - if (rule.getProjects() == null || rule.getProjects().isEmpty()) { + if ((rule.getProjects() == null || rule.getProjects().isEmpty()) + && (rule.getTags() == null || rule.getTags().isEmpty())) { throw new IllegalStateException( - "Scheduled notifications for group %s must be limited to at least one project".formatted( + "Scheduled notifications for group %s must be limited to at least one project or tag".formatted( NotificationGroup.NEW_VULNERABILITIES_SUMMARY)); } @@ -243,9 +245,10 @@ private Notification createNewVulnerabilitiesNotification(final QueryManager qm, } private Notification createNewPolicyViolationsNotification(final QueryManager qm, final NotificationRule rule) { - if (rule.getProjects() == null || rule.getProjects().isEmpty()) { + if ((rule.getProjects() == null || rule.getProjects().isEmpty()) + && (rule.getTags() == null || rule.getTags().isEmpty())) { throw new IllegalStateException( - "Scheduled notifications for group %s must be limited to at least one project".formatted( + "Scheduled notifications for group %s must be limited to at least one project or tag".formatted( NotificationGroup.NEW_POLICY_VIOLATIONS_SUMMARY)); } @@ -326,7 +329,10 @@ private Notification createNewPolicyViolationsNotification(final QueryManager qm } private Set getApplicableProjectIds(final QueryManager qm, final NotificationRule rule) { - if (rule.getProjects() == null || rule.getProjects().isEmpty()) { + final boolean hasProjects = rule.getProjects() != null && !rule.getProjects().isEmpty(); + final boolean hasTags = rule.getTags() != null && !rule.getTags().isEmpty(); + + if (!hasProjects && !hasTags) { return Collections.emptySet(); } @@ -334,16 +340,50 @@ private Set getApplicableProjectIds(final QueryManager qm, final Notificat // but it's too much of a hassle getting it to work across // all the RDBMSes we have to support still. - final var projectIds = new HashSet(); - for (final Project project : rule.getProjects()) { - if (!project.isActive()) { - continue; + final Set projectIdsFromProjects; + if (hasProjects) { + projectIdsFromProjects = new HashSet<>(); + for (final Project project : rule.getProjects()) { + if (!project.isActive()) { + continue; + } + + projectIdsFromProjects.add(project.getId()); + + if (rule.isNotifyChildren()) { + projectIdsFromProjects.addAll(getActiveChildProjectIds(qm, project.getId())); + } } + } else { + projectIdsFromProjects = null; + } + + final Set projectIdsFromTags; + if (hasTags) { + projectIdsFromTags = getProjectIdsByTags(qm, rule.getTags(), rule.isNotifyChildren()); + } else { + projectIdsFromTags = null; + } - projectIds.add(project.getId()); + // When both projects and tags are defined, return the intersection. + // When only one is defined, return that set. + if (projectIdsFromProjects != null && projectIdsFromTags != null) { + projectIdsFromProjects.retainAll(projectIdsFromTags); + return projectIdsFromProjects; + } else if (projectIdsFromProjects != null) { + return projectIdsFromProjects; + } else { + return projectIdsFromTags; + } + } + + private Set getProjectIdsByTags(final QueryManager qm, final Set tags, final boolean notifyChildren) { + final var projectIds = new HashSet(); - if (rule.isNotifyChildren()) { - projectIds.addAll(getActiveChildProjectIds(qm, project.getId())); + for (final Tag tag : tags) { + PaginatedResult paginatedResult = qm.getProjects(tag, false, true, !notifyChildren); + for (Project project : paginatedResult.getList(Project.class)) { + projectIds.add(project.getId()); } } diff --git a/src/test/java/org/dependencytrack/tasks/ScheduledNotificationDispatchTaskTest.java b/src/test/java/org/dependencytrack/tasks/ScheduledNotificationDispatchTaskTest.java index 4aea4fcbef..cc2da08a36 100644 --- a/src/test/java/org/dependencytrack/tasks/ScheduledNotificationDispatchTaskTest.java +++ b/src/test/java/org/dependencytrack/tasks/ScheduledNotificationDispatchTaskTest.java @@ -34,6 +34,7 @@ import org.dependencytrack.model.PolicyViolation; import org.dependencytrack.model.Project; import org.dependencytrack.model.Severity; +import org.dependencytrack.model.Tag; import org.dependencytrack.model.ViolationAnalysisState; import org.dependencytrack.model.Vulnerability; import org.dependencytrack.notification.NotificationGroup; @@ -606,4 +607,194 @@ void shouldDispatchNotificationWhenNoNewFindingsAndSkipPublishIfUnchangedIsDisab notification -> assertThat(notification.getGroup()).isEqualTo(NotificationGroup.NEW_POLICY_VIOLATIONS_SUMMARY.name())); } + @Test + void shouldDispatchNotificationWhenLimitedToTagsOnly() { + final Instant ruleLastFiredAt = Instant.now().minus(10, ChronoUnit.MINUTES); + final Instant afterRuleLastFiredAt = ruleLastFiredAt.plus(5, ChronoUnit.MINUTES); + + final var vulnA = new Vulnerability(); + vulnA.setVulnId("INT-001"); + vulnA.setSource(Vulnerability.Source.INTERNAL); + vulnA.setSeverity(Severity.HIGH); + qm.persist(vulnA); + + // Create a tag + final var tag = new Tag("production"); + qm.persist(tag); + + // Create project with the tag + final var taggedProject = new Project(); + taggedProject.setName("acme-app-tagged"); + qm.persist(taggedProject); + qm.bind(taggedProject, Set.of(tag)); + + final var taggedProjectComponent = new Component(); + taggedProjectComponent.setProject(taggedProject); + taggedProjectComponent.setName("acme-lib-tagged"); + qm.persist(taggedProjectComponent); + qm.addVulnerability( + vulnA, + taggedProjectComponent, + AnalyzerIdentity.INTERNAL_ANALYZER, + null, + null, + Date.from(afterRuleLastFiredAt)); + + // Create project without the tag (should not be included) + final var untaggedProject = new Project(); + untaggedProject.setName("acme-app-untagged"); + qm.persist(untaggedProject); + final var untaggedProjectComponent = new Component(); + untaggedProjectComponent.setProject(untaggedProject); + untaggedProjectComponent.setName("acme-lib-untagged"); + qm.persist(untaggedProjectComponent); + qm.addVulnerability( + vulnA, + untaggedProjectComponent, + AnalyzerIdentity.INTERNAL_ANALYZER, + null, + null, + Date.from(afterRuleLastFiredAt)); + + final var publisher = qm.createNotificationPublisher( + "foo", null, WebhookPublisher.class, "template", "templateMimeType", false); + final var rule = qm.createScheduledNotificationRule( + "foo", NotificationScope.PORTFOLIO, NotificationLevel.INFORMATIONAL, publisher); + rule.setNotifyOn(Set.of(NotificationGroup.NEW_VULNERABILITIES_SUMMARY)); + rule.setTags(Set.of(tag)); // Only tags, no projects + rule.setNotifyChildren(true); + rule.setScheduleCron("* * * * *"); + rule.setScheduleLastTriggeredAt(Date.from(ruleLastFiredAt)); + rule.updateScheduleNextTriggerAt(); + rule.setScheduleSkipUnchanged(true); + rule.setEnabled(true); + + new ScheduledNotificationDispatchTask().inform(new ScheduledNotificationDispatchEvent()); + + final Notification notification = await("Notification Dispatch") + .atMost(3, TimeUnit.SECONDS) + .until(NOTIFICATIONS::poll, Objects::nonNull); + assertThat(notification).isNotNull(); + + assertThat(notification.getGroup()).isEqualTo(NotificationGroup.NEW_VULNERABILITIES_SUMMARY.name()); + assertThat(notification.getSubject()).isInstanceOf(NewVulnerabilitiesSummary.class); + + // Verify only the tagged project is included + assertThatJson(notification.getSubject()) + .withMatcher("ruleId", equalTo(BigDecimal.valueOf(rule.getId()))) + .withOptions(Option.IGNORING_EXTRA_FIELDS) + .isEqualTo(/* language=JSON */ """ + { + "overview": { + "affectedProjectsCount": 1, + "affectedComponentsCount": 1, + "newVulnerabilitiesCount": 1 + } + } + """); + } + + @Test + void shouldDispatchNotificationWhenLimitedToProjectsAndTagsIntersection() { + final Instant ruleLastFiredAt = Instant.now().minus(10, ChronoUnit.MINUTES); + final Instant afterRuleLastFiredAt = ruleLastFiredAt.plus(5, ChronoUnit.MINUTES); + + final var vulnA = new Vulnerability(); + vulnA.setVulnId("INT-001"); + vulnA.setSource(Vulnerability.Source.INTERNAL); + vulnA.setSeverity(Severity.HIGH); + qm.persist(vulnA); + + // Create a tag + final var tag = new Tag("production"); + qm.persist(tag); + + // Create project with the tag AND in the projects list (should be included - intersection) + final var taggedAndListedProject = new Project(); + taggedAndListedProject.setName("acme-app-tagged-listed"); + qm.persist(taggedAndListedProject); + qm.bind(taggedAndListedProject, Set.of(tag)); + final var taggedAndListedComponent = new Component(); + taggedAndListedComponent.setProject(taggedAndListedProject); + taggedAndListedComponent.setName("acme-lib-tagged-listed"); + qm.persist(taggedAndListedComponent); + qm.addVulnerability( + vulnA, + taggedAndListedComponent, + AnalyzerIdentity.INTERNAL_ANALYZER, + null, + null, + Date.from(afterRuleLastFiredAt)); + + // Create project with the tag but NOT in the projects list (should NOT be included - no intersection) + final var taggedOnlyProject = new Project(); + taggedOnlyProject.setName("acme-app-tagged-only"); + qm.persist(taggedOnlyProject); + qm.bind(taggedOnlyProject, Set.of(tag)); + final var taggedOnlyComponent = new Component(); + taggedOnlyComponent.setProject(taggedOnlyProject); + taggedOnlyComponent.setName("acme-lib-tagged-only"); + qm.persist(taggedOnlyComponent); + qm.addVulnerability( + vulnA, + taggedOnlyComponent, + AnalyzerIdentity.INTERNAL_ANALYZER, + null, + null, + Date.from(afterRuleLastFiredAt)); + + // Create project in the projects list but WITHOUT the tag (should NOT be included - no intersection) + final var listedOnlyProject = new Project(); + listedOnlyProject.setName("acme-app-listed-only"); + qm.persist(listedOnlyProject); + final var listedOnlyComponent = new Component(); + listedOnlyComponent.setProject(listedOnlyProject); + listedOnlyComponent.setName("acme-lib-listed-only"); + qm.persist(listedOnlyComponent); + qm.addVulnerability( + vulnA, + listedOnlyComponent, + AnalyzerIdentity.INTERNAL_ANALYZER, + null, + null, + Date.from(afterRuleLastFiredAt)); + + final var publisher = qm.createNotificationPublisher( + "foo", null, WebhookPublisher.class, "template", "templateMimeType", false); + final var rule = qm.createScheduledNotificationRule( + "foo", NotificationScope.PORTFOLIO, NotificationLevel.INFORMATIONAL, publisher); + rule.setNotifyOn(Set.of(NotificationGroup.NEW_VULNERABILITIES_SUMMARY)); + rule.setProjects(List.of(taggedAndListedProject, listedOnlyProject)); // Two projects + rule.setTags(Set.of(tag)); // One tag + rule.setNotifyChildren(true); + rule.setScheduleCron("* * * * *"); + rule.setScheduleLastTriggeredAt(Date.from(ruleLastFiredAt)); + rule.updateScheduleNextTriggerAt(); + rule.setScheduleSkipUnchanged(true); + rule.setEnabled(true); + + new ScheduledNotificationDispatchTask().inform(new ScheduledNotificationDispatchEvent()); + + final Notification notification = await("Notification Dispatch") + .atMost(3, TimeUnit.SECONDS) + .until(NOTIFICATIONS::poll, Objects::nonNull); + assertThat(notification).isNotNull(); + + assertThat(notification.getGroup()).isEqualTo(NotificationGroup.NEW_VULNERABILITIES_SUMMARY.name()); + + // Verify only the project that has BOTH the tag AND is in the projects list is included + assertThatJson(notification.getSubject()) + .withMatcher("ruleId", equalTo(BigDecimal.valueOf(rule.getId()))) + .withOptions(Option.IGNORING_EXTRA_FIELDS) + .isEqualTo(/* language=JSON */ """ + { + "overview": { + "affectedProjectsCount": 1, + "affectedComponentsCount": 1, + "newVulnerabilitiesCount": 1 + } + } + """); + } + } \ No newline at end of file