From b0942d62f4fc897c924c20bc1fd5b6a80d4e0b96 Mon Sep 17 00:00:00 2001 From: nscuro Date: Fri, 17 Nov 2023 15:19:07 +0100 Subject: [PATCH 1/9] Include context information in notification publishing logs Signed-off-by: nscuro --- .../notification/NotificationRouter.java | 16 ++- .../publisher/AbstractWebhookPublisher.java | 16 +-- .../publisher/ConsolePublisher.java | 6 +- .../publisher/CsWebexPublisher.java | 4 +- .../notification/publisher/JiraPublisher.java | 4 +- .../publisher/MattermostPublisher.java | 4 +- .../publisher/MsTeamsPublisher.java | 4 +- .../publisher/PublishContext.java | 132 ++++++++++++++++++ .../notification/publisher/Publisher.java | 6 +- .../publisher/SendMailPublisher.java | 20 +-- .../publisher/SlackPublisher.java | 4 +- .../publisher/WebhookPublisher.java | 4 +- .../v1/NotificationPublisherResource.java | 14 +- .../notification/NotificationRouterTest.java | 3 +- .../publisher/AbstractPublisherTest.java | 12 +- .../publisher/ConsolePublisherTest.java | 4 +- .../publisher/CsWebexPublisherTest.java | 2 +- 17 files changed, 194 insertions(+), 61 deletions(-) create mode 100644 src/main/java/org/dependencytrack/notification/publisher/PublishContext.java diff --git a/src/main/java/org/dependencytrack/notification/NotificationRouter.java b/src/main/java/org/dependencytrack/notification/NotificationRouter.java index 5d9d6decf..8f6c942fa 100644 --- a/src/main/java/org/dependencytrack/notification/NotificationRouter.java +++ b/src/main/java/org/dependencytrack/notification/NotificationRouter.java @@ -26,6 +26,7 @@ import org.dependencytrack.model.NotificationPublisher; import org.dependencytrack.model.NotificationRule; import org.dependencytrack.model.Project; +import org.dependencytrack.notification.publisher.PublishContext; import org.dependencytrack.notification.publisher.Publisher; import org.dependencytrack.notification.publisher.SendMailPublisher; import org.dependencytrack.notification.vo.AnalysisDecisionChange; @@ -56,7 +57,10 @@ public class NotificationRouter implements Subscriber { private static final Logger LOGGER = Logger.getLogger(NotificationRouter.class); public void inform(final Notification notification) { + final PublishContext ctx = PublishContext.from(notification); + for (final NotificationRule rule: resolveRules(notification)) { + final PublishContext ruleCtx = ctx.withRule(rule); // Not all publishers need configuration (i.e. ConsolePublisher) JsonObject config = Json.createObjectBuilder().build(); @@ -65,7 +69,7 @@ public void inform(final Notification notification) { final JsonReader jsonReader = Json.createReader(stringReader)) { config = jsonReader.readObject(); } catch (Exception e) { - LOGGER.error("An error occurred while preparing the configuration for the notification publisher", e); + LOGGER.error("An error occurred while preparing the configuration for the notification publisher (%s)".formatted(ruleCtx), e); } } try { @@ -79,19 +83,19 @@ public void inform(final Notification notification) { .addAll(Json.createObjectBuilder(config)) .build(); if (publisherClass != SendMailPublisher.class || rule.getTeams().isEmpty() || rule.getTeams() == null){ - publisher.inform(restrictNotificationToRuleProjects(notification, rule), notificationPublisherConfig); + publisher.inform(ruleCtx, restrictNotificationToRuleProjects(notification, rule), notificationPublisherConfig); } else { - ((SendMailPublisher)publisher).inform(restrictNotificationToRuleProjects(notification, rule), notificationPublisherConfig, rule.getTeams()); + ((SendMailPublisher)publisher).inform(ruleCtx, restrictNotificationToRuleProjects(notification, rule), notificationPublisherConfig, rule.getTeams()); } } else { - LOGGER.error("The defined notification publisher is not assignable from " + Publisher.class.getCanonicalName()); + LOGGER.error("The defined notification publisher is not assignable from " + Publisher.class.getCanonicalName() + " (%s)".formatted(ruleCtx)); } } catch (ClassNotFoundException | NoSuchMethodException | InstantiationException | InvocationTargetException | IllegalAccessException e) { - LOGGER.error("An error occurred while instantiating a notification publisher", e); + LOGGER.error("An error occurred while instantiating a notification publisher (%s)".formatted(ruleCtx), e); } catch (PublisherException publisherException) { - LOGGER.error("An error occured during the publication of the notification", publisherException); + LOGGER.error("An error occured during the publication of the notification (%s)".formatted(ruleCtx), publisherException); } } } diff --git a/src/main/java/org/dependencytrack/notification/publisher/AbstractWebhookPublisher.java b/src/main/java/org/dependencytrack/notification/publisher/AbstractWebhookPublisher.java index ab1b3b5fa..cb7ba2213 100644 --- a/src/main/java/org/dependencytrack/notification/publisher/AbstractWebhookPublisher.java +++ b/src/main/java/org/dependencytrack/notification/publisher/AbstractWebhookPublisher.java @@ -34,17 +34,17 @@ import java.io.IOException; public abstract class AbstractWebhookPublisher implements Publisher { - public void publish(final String publisherName, final PebbleTemplate template, final Notification notification, final JsonObject config) { + public void publish(final PublishContext ctx, final String publisherName, final PebbleTemplate template, final Notification notification, final JsonObject config) { final Logger logger = LoggerFactory.getLogger(getClass()); logger.debug("Preparing to publish " + publisherName + " notification"); if (config == null) { - logger.warn("No configuration found. Skipping notification."); + logger.warn("No configuration found. Skipping notification. (%s)".formatted(ctx)); return; } final String destination = getDestinationUrl(config); - final String content = prepareTemplate(notification, template); + final String content = prepareTemplate(ctx, notification, template); if (destination == null || content == null) { - logger.warn("A destination or template was not found. Skipping notification"); + logger.warn("A destination or template was not found. Skipping notification (%s)".formatted(ctx)); return; } final String mimeType = getTemplateMimeType(config); @@ -55,7 +55,7 @@ public void publish(final String publisherName, final PebbleTemplate template, f try { credentials = getAuthCredentials(); } catch (PublisherException e) { - logger.warn("An error occurred during the retrieval of credentials needed for notification publication. Skipping notification", e); + logger.warn("An error occurred during the retrieval of credentials needed for notification publication. Skipping notification (%s)".formatted(ctx), e); return; } if (credentials != null) { @@ -77,7 +77,7 @@ public void publish(final String publisherName, final PebbleTemplate template, f } } } catch (IOException ex) { - handleRequestException(logger, ex); + handleRequestException(ctx, logger, ex); } } @@ -93,7 +93,7 @@ protected AuthCredentials getAuthCredentials() { protected record AuthCredentials(String user, String password) { } - protected void handleRequestException(final Logger logger, final Exception e) { - logger.error("Request failure", e); + protected void handleRequestException(final PublishContext ctx, final Logger logger, final Exception e) { + logger.error("Request failure (%s)".formatted(ctx), e); } } diff --git a/src/main/java/org/dependencytrack/notification/publisher/ConsolePublisher.java b/src/main/java/org/dependencytrack/notification/publisher/ConsolePublisher.java index 23f08f9f2..9c7d21025 100644 --- a/src/main/java/org/dependencytrack/notification/publisher/ConsolePublisher.java +++ b/src/main/java/org/dependencytrack/notification/publisher/ConsolePublisher.java @@ -31,10 +31,10 @@ public class ConsolePublisher implements Publisher { private static final Logger LOGGER = Logger.getLogger(ConsolePublisher.class); private static final PebbleEngine ENGINE = new PebbleEngine.Builder().newLineTrimming(false).build(); - public void inform(final Notification notification, final JsonObject config) { - final String content = prepareTemplate(notification, getTemplate(config)); + public void inform(final PublishContext ctx, final Notification notification, final JsonObject config) { + final String content = prepareTemplate(ctx, notification, getTemplate(config)); if (content == null) { - LOGGER.warn("A template was not found. Skipping notification"); + LOGGER.warn("A template was not found. Skipping notification (%s)".formatted(ctx)); return; } final PrintStream ps; diff --git a/src/main/java/org/dependencytrack/notification/publisher/CsWebexPublisher.java b/src/main/java/org/dependencytrack/notification/publisher/CsWebexPublisher.java index 9754bb534..9db768246 100644 --- a/src/main/java/org/dependencytrack/notification/publisher/CsWebexPublisher.java +++ b/src/main/java/org/dependencytrack/notification/publisher/CsWebexPublisher.java @@ -27,8 +27,8 @@ public class CsWebexPublisher extends AbstractWebhookPublisher implements Publis private static final PebbleEngine ENGINE = new PebbleEngine.Builder().defaultEscapingStrategy("json").build(); - public void inform(final Notification notification, final JsonObject config) { - publish(DefaultNotificationPublishers.CS_WEBEX.getPublisherName(), getTemplate(config), notification, config); + public void inform(final PublishContext ctx, final Notification notification, final JsonObject config) { + publish(ctx, DefaultNotificationPublishers.CS_WEBEX.getPublisherName(), getTemplate(config), notification, config); } @Override diff --git a/src/main/java/org/dependencytrack/notification/publisher/JiraPublisher.java b/src/main/java/org/dependencytrack/notification/publisher/JiraPublisher.java index 63a53d8b4..c556339e6 100644 --- a/src/main/java/org/dependencytrack/notification/publisher/JiraPublisher.java +++ b/src/main/java/org/dependencytrack/notification/publisher/JiraPublisher.java @@ -46,10 +46,10 @@ public class JiraPublisher extends AbstractWebhookPublisher implements Publisher private String jiraTicketType; @Override - public void inform(final Notification notification, final JsonObject config) { + public void inform(final PublishContext ctx, final Notification notification, final JsonObject config) { jiraTicketType = config.getString("jiraTicketType"); jiraProjectKey = config.getString(CONFIG_DESTINATION); - publish(DefaultNotificationPublishers.JIRA.getPublisherName(), getTemplate(config), notification, config); + publish(ctx, DefaultNotificationPublishers.JIRA.getPublisherName(), getTemplate(config), notification, config); } @Override diff --git a/src/main/java/org/dependencytrack/notification/publisher/MattermostPublisher.java b/src/main/java/org/dependencytrack/notification/publisher/MattermostPublisher.java index 08ef4f444..0e8c358ec 100644 --- a/src/main/java/org/dependencytrack/notification/publisher/MattermostPublisher.java +++ b/src/main/java/org/dependencytrack/notification/publisher/MattermostPublisher.java @@ -27,8 +27,8 @@ public class MattermostPublisher extends AbstractWebhookPublisher implements Pub private static final PebbleEngine ENGINE = new PebbleEngine.Builder().defaultEscapingStrategy("json").build(); - public void inform(final Notification notification, final JsonObject config) { - publish(DefaultNotificationPublishers.MATTERMOST.getPublisherName(), getTemplate(config), notification, config); + public void inform(final PublishContext ctx, final Notification notification, final JsonObject config) { + publish(ctx, DefaultNotificationPublishers.MATTERMOST.getPublisherName(), getTemplate(config), notification, config); } @Override diff --git a/src/main/java/org/dependencytrack/notification/publisher/MsTeamsPublisher.java b/src/main/java/org/dependencytrack/notification/publisher/MsTeamsPublisher.java index 6c8b2204f..ab19f9b5f 100644 --- a/src/main/java/org/dependencytrack/notification/publisher/MsTeamsPublisher.java +++ b/src/main/java/org/dependencytrack/notification/publisher/MsTeamsPublisher.java @@ -27,8 +27,8 @@ public class MsTeamsPublisher extends AbstractWebhookPublisher implements Publis private static final PebbleEngine ENGINE = new PebbleEngine.Builder().defaultEscapingStrategy("json").build(); - public void inform(final Notification notification, final JsonObject config) { - publish(DefaultNotificationPublishers.MS_TEAMS.getPublisherName(), getTemplate(config), notification, config); + public void inform(final PublishContext ctx, final Notification notification, final JsonObject config) { + publish(ctx, DefaultNotificationPublishers.MS_TEAMS.getPublisherName(), getTemplate(config), notification, config); } @Override diff --git a/src/main/java/org/dependencytrack/notification/publisher/PublishContext.java b/src/main/java/org/dependencytrack/notification/publisher/PublishContext.java new file mode 100644 index 000000000..df0a3da2e --- /dev/null +++ b/src/main/java/org/dependencytrack/notification/publisher/PublishContext.java @@ -0,0 +1,132 @@ +/* + * 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.notification.publisher; + +import alpine.notification.Notification; +import org.dependencytrack.model.NotificationRule; +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.PolicyViolationIdentified; +import org.dependencytrack.notification.vo.VexConsumedOrProcessed; +import org.dependencytrack.notification.vo.ViolationAnalysisDecisionChange; + +import java.time.ZoneOffset; +import java.time.format.DateTimeFormatter; +import java.util.HashMap; +import java.util.Map; + +/** + * Context information about a {@link Notification} being published. + * + * @param notificationGroup Group of the {@link Notification} being published + * @param notificationLevel Level of the {@link Notification} being published + * @param notificationScope Scope of the {@link Notification} being published + * @param notificationTimestamp UTC Timestamp in {@link DateTimeFormatter#ISO_DATE_TIME} of the {@link Notification} being published + * @param notificationSubjects Subject(s) of the {@link Notification} being published + * @param ruleName Name of the matched {@link NotificationRule} + * @param ruleScope Scope of the matched {@link NotificationRule} + * @param ruleLevel Level of the matched {@link NotificationRule} + * @since 4.10.0 + */ +public record PublishContext(String notificationGroup, String notificationLevel, String notificationScope, + String notificationTimestamp, Map notificationSubjects, + String ruleName, String ruleScope, String ruleLevel) { + + private static final String SUBJECT_COMPONENT = "component"; + private static final String SUBJECT_PROJECT = "project"; + private static final String SUBJECT_PROJECTS = "projects"; + + public static PublishContext from(final Notification notification) { + final var notificationSubjects = new HashMap(); + if (notification.getSubject() instanceof final BomConsumedOrProcessed subject) { + notificationSubjects.put(SUBJECT_PROJECT, Project.convert(subject.getProject())); + } else if (notification.getSubject() instanceof final BomProcessingFailed subject) { + notificationSubjects.put(SUBJECT_PROJECT, Project.convert(subject.getProject())); + } else if (notification.getSubject() instanceof final NewVulnerabilityIdentified subject) { + notificationSubjects.put(SUBJECT_COMPONENT, Component.convert(subject.getComponent())); + notificationSubjects.put(SUBJECT_PROJECTS, subject.getAffectedProjects().stream().map(Project::convert).toList()); + } 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())); + } else if (notification.getSubject() instanceof final org.dependencytrack.model.Project subject) { + notificationSubjects.put(SUBJECT_PROJECT, Project.convert(subject)); + } else if (notification.getSubject() instanceof final PolicyViolationIdentified subject) { + notificationSubjects.put(SUBJECT_COMPONENT, Component.convert(subject.getComponent())); + notificationSubjects.put(SUBJECT_PROJECT, Project.convert(subject.getProject())); + } else if (notification.getSubject() instanceof final ViolationAnalysisDecisionChange subject) { + notificationSubjects.put(SUBJECT_COMPONENT, Component.convert(subject.getComponent())); + notificationSubjects.put(SUBJECT_PROJECT, Project.convert(subject.getComponent().getProject())); + } else if (notification.getSubject() instanceof final AnalysisDecisionChange subject) { + notificationSubjects.put(SUBJECT_COMPONENT, Component.convert(subject.getComponent())); + notificationSubjects.put(SUBJECT_PROJECT, Project.convert(subject.getProject())); + } else if (notification.getSubject() instanceof final VexConsumedOrProcessed subject) { + notificationSubjects.put(SUBJECT_PROJECT, Project.convert(subject.getProject())); + } + + return new PublishContext(notification.getGroup(), notification.getLevel().name(), notification.getScope(), + notification.getTimestamp().atOffset(ZoneOffset.UTC).format(DateTimeFormatter.ISO_DATE_TIME), notificationSubjects, + /* ruleName */ null, /* ruleScope */ null, /* ruleLevel */ null); + } + + /** + * Enrich the {@link PublishContext} with additional information about the {@link NotificationRule} once known. + * + * @param rule The applicable {@link NotificationRule} + * @return This {@link PublishContext} + */ + public PublishContext withRule(final NotificationRule rule) { + return new PublishContext(this.notificationGroup, this.notificationLevel, this.notificationScope, this.notificationTimestamp, + this.notificationSubjects, rule.getName(), rule.getScope().name(), rule.getNotificationLevel().name()); + } + + public record Component(String uuid, String group, String name, String version) { + + private static Component convert(final org.dependencytrack.model.Component notificationComponent) { + if (notificationComponent == null) { + return null; + } + return new Component( + notificationComponent.getUuid().toString(), + notificationComponent.getGroup(), + notificationComponent.getName(), + notificationComponent.getVersion() + ); + } + + } + + public record Project(String uuid, String name, String version) { + + private static Project convert(final org.dependencytrack.model.Project notificationProject) { + if (notificationProject == null) { + return null; + } + return new Project( + notificationProject.getUuid().toString(), + notificationProject.getName(), + notificationProject.getVersion() + ); + } + + } + +} diff --git a/src/main/java/org/dependencytrack/notification/publisher/Publisher.java b/src/main/java/org/dependencytrack/notification/publisher/Publisher.java index 69e15b411..1ba623de7 100644 --- a/src/main/java/org/dependencytrack/notification/publisher/Publisher.java +++ b/src/main/java/org/dependencytrack/notification/publisher/Publisher.java @@ -54,7 +54,7 @@ public interface Publisher { String CONFIG_DESTINATION = "destination"; - void inform(Notification notification, JsonObject config); + void inform(final PublishContext ctx, final Notification notification, final JsonObject config); PebbleEngine getTemplateEngine(); @@ -78,7 +78,7 @@ default String getTemplateMimeType(JsonObject config) { default void enrichTemplateContext(final Map context) { } - default String prepareTemplate(final Notification notification, final PebbleTemplate template) { + default String prepareTemplate(final PublishContext ctx, final Notification notification, final PebbleTemplate template) { try (QueryManager qm = new QueryManager()) { final ConfigProperty baseUrlProperty = qm.getConfigProperty( @@ -133,7 +133,7 @@ default String prepareTemplate(final Notification notification, final PebbleTemp template.evaluate(writer, context); return writer.toString(); } catch (IOException e) { - Logger.getLogger(this.getClass()).error("An error was encountered evaluating template", e); + Logger.getLogger(this.getClass()).error("An error was encountered evaluating template (%s)".formatted(ctx), e); return null; } } diff --git a/src/main/java/org/dependencytrack/notification/publisher/SendMailPublisher.java b/src/main/java/org/dependencytrack/notification/publisher/SendMailPublisher.java index 3794d947c..abef88b80 100644 --- a/src/main/java/org/dependencytrack/notification/publisher/SendMailPublisher.java +++ b/src/main/java/org/dependencytrack/notification/publisher/SendMailPublisher.java @@ -56,30 +56,30 @@ public class SendMailPublisher implements Publisher { private static final Logger LOGGER = Logger.getLogger(SendMailPublisher.class); private static final PebbleEngine ENGINE = new PebbleEngine.Builder().newLineTrimming(false).build(); - public void inform(final Notification notification, final JsonObject config) { + public void inform(final PublishContext ctx, final Notification notification, final JsonObject config) { if (config == null) { - LOGGER.warn("No configuration found. Skipping notification."); + LOGGER.warn("No configuration found. Skipping notification. (%s)".formatted(ctx)); return; } final String[] destinations = parseDestination(config); - sendNotification(notification, config, destinations); + sendNotification(ctx, notification, config, destinations); } - public void inform(final Notification notification, final JsonObject config, List teams) { + public void inform(final PublishContext ctx, final Notification notification, final JsonObject config, List teams) { if (config == null) { - LOGGER.warn("No configuration found. Skipping notification."); + LOGGER.warn("No configuration found. Skipping notification. (%s)".formatted(ctx)); return; } final String[] destinations = parseDestination(config, teams); - sendNotification(notification, config, destinations); + sendNotification(ctx, notification, config, destinations); } - private void sendNotification(Notification notification, JsonObject config, String[] destinations) { + private void sendNotification(final PublishContext ctx, Notification notification, JsonObject config, String[] destinations) { PebbleTemplate template = getTemplate(config); String mimeType = getTemplateMimeType(config); - final String content = prepareTemplate(notification, template); + final String content = prepareTemplate(ctx, notification, template); if (destinations == null || content == null) { - LOGGER.warn("A destination or template was not found. Skipping notification"); + LOGGER.warn("A destination or template was not found. Skipping notification (%s)".formatted(ctx)); return; } try (QueryManager qm = new QueryManager()) { @@ -113,7 +113,7 @@ private void sendNotification(Notification notification, JsonObject config, Stri .trustCert(Boolean.valueOf(smtpTrustCert.getPropertyValue())); sendMail.send(); } catch (Exception e) { - LOGGER.error("An error occurred sending output email notification", e); + LOGGER.error("An error occurred sending output email notification (%s)".formatted(ctx), e); } } diff --git a/src/main/java/org/dependencytrack/notification/publisher/SlackPublisher.java b/src/main/java/org/dependencytrack/notification/publisher/SlackPublisher.java index 2bd5c6c81..642b25155 100644 --- a/src/main/java/org/dependencytrack/notification/publisher/SlackPublisher.java +++ b/src/main/java/org/dependencytrack/notification/publisher/SlackPublisher.java @@ -27,8 +27,8 @@ public class SlackPublisher extends AbstractWebhookPublisher implements Publishe private static final PebbleEngine ENGINE = new PebbleEngine.Builder().defaultEscapingStrategy("json").build(); - public void inform(final Notification notification, final JsonObject config) { - publish(DefaultNotificationPublishers.SLACK.getPublisherName(), getTemplate(config), notification, config); + public void inform(final PublishContext ctx, final Notification notification, final JsonObject config) { + publish(ctx, DefaultNotificationPublishers.SLACK.getPublisherName(), getTemplate(config), notification, config); } @Override diff --git a/src/main/java/org/dependencytrack/notification/publisher/WebhookPublisher.java b/src/main/java/org/dependencytrack/notification/publisher/WebhookPublisher.java index e64d6f2ed..bab00f5af 100644 --- a/src/main/java/org/dependencytrack/notification/publisher/WebhookPublisher.java +++ b/src/main/java/org/dependencytrack/notification/publisher/WebhookPublisher.java @@ -27,8 +27,8 @@ public class WebhookPublisher extends AbstractWebhookPublisher implements Publis private static final PebbleEngine ENGINE = new PebbleEngine.Builder().defaultEscapingStrategy("json").build(); - public void inform(final Notification notification, final JsonObject config) { - publish(DefaultNotificationPublishers.WEBHOOK.getPublisherName(), getTemplate(config), notification, config); + public void inform(final PublishContext ctx, final Notification notification, final JsonObject config) { + publish(ctx, DefaultNotificationPublishers.WEBHOOK.getPublisherName(), getTemplate(config), notification, config); } @Override diff --git a/src/main/java/org/dependencytrack/resources/v1/NotificationPublisherResource.java b/src/main/java/org/dependencytrack/resources/v1/NotificationPublisherResource.java index 76d725d5f..eaecceb6f 100644 --- a/src/main/java/org/dependencytrack/resources/v1/NotificationPublisherResource.java +++ b/src/main/java/org/dependencytrack/resources/v1/NotificationPublisherResource.java @@ -24,13 +24,11 @@ import alpine.notification.NotificationLevel; import alpine.server.auth.PermissionRequired; import alpine.server.resources.AlpineResource; - -import io.swagger.annotations.ApiResponse; +import io.swagger.annotations.Api; import io.swagger.annotations.ApiOperation; -import io.swagger.annotations.ApiResponses; import io.swagger.annotations.ApiParam; -import io.swagger.annotations.Api; - +import io.swagger.annotations.ApiResponse; +import io.swagger.annotations.ApiResponses; import io.swagger.annotations.Authorization; import org.dependencytrack.auth.Permissions; import org.dependencytrack.model.ConfigPropertyConstants; @@ -39,6 +37,7 @@ import org.dependencytrack.notification.NotificationConstants; import org.dependencytrack.notification.NotificationGroup; import org.dependencytrack.notification.NotificationScope; +import org.dependencytrack.notification.publisher.PublishContext; import org.dependencytrack.notification.publisher.Publisher; import org.dependencytrack.notification.publisher.SendMailPublisher; import org.dependencytrack.persistence.QueryManager; @@ -47,8 +46,6 @@ import javax.json.Json; import javax.json.JsonObject; import javax.validation.Validator; - - import javax.ws.rs.Consumes; import javax.ws.rs.DELETE; import javax.ws.rs.FormParam; @@ -58,7 +55,6 @@ import javax.ws.rs.Path; import javax.ws.rs.PathParam; import javax.ws.rs.Produces; - import javax.ws.rs.core.MediaType; import javax.ws.rs.core.Response; import java.io.IOException; @@ -295,7 +291,7 @@ public Response testSmtpPublisherConfig(@FormParam("destination") String destina .content("SMTP configuration test") .level(NotificationLevel.INFORMATIONAL); // Bypass Notification.dispatch() and go directly to the publisher itself - emailPublisher.inform(notification, config); + emailPublisher.inform(PublishContext.from(notification), notification, config); return Response.ok().build(); } catch (InvocationTargetException | InstantiationException | IllegalAccessException | NoSuchMethodException e) { LOGGER.error(e.getMessage(), e); diff --git a/src/test/java/org/dependencytrack/notification/NotificationRouterTest.java b/src/test/java/org/dependencytrack/notification/NotificationRouterTest.java index 46167ee1d..251b12abd 100644 --- a/src/test/java/org/dependencytrack/notification/NotificationRouterTest.java +++ b/src/test/java/org/dependencytrack/notification/NotificationRouterTest.java @@ -30,6 +30,7 @@ import org.dependencytrack.model.Vex; import org.dependencytrack.model.Vulnerability; import org.dependencytrack.notification.publisher.DefaultNotificationPublishers; +import org.dependencytrack.notification.publisher.PublishContext; import org.dependencytrack.notification.publisher.Publisher; import org.dependencytrack.notification.vo.AnalysisDecisionChange; import org.dependencytrack.notification.vo.BomConsumedOrProcessed; @@ -697,7 +698,7 @@ public MockPublisher() { } @Override - public void inform(Notification notification, JsonObject config) { + public void inform(final PublishContext ctx, Notification notification, JsonObject config) { MockPublisher.config = config; MockPublisher.notification = notification; } diff --git a/src/test/java/org/dependencytrack/notification/publisher/AbstractPublisherTest.java b/src/test/java/org/dependencytrack/notification/publisher/AbstractPublisherTest.java index c06d15b8e..52b8c87a3 100644 --- a/src/test/java/org/dependencytrack/notification/publisher/AbstractPublisherTest.java +++ b/src/test/java/org/dependencytrack/notification/publisher/AbstractPublisherTest.java @@ -76,7 +76,7 @@ public void testInformWithBomConsumedNotification() { .subject(subject); assertThatNoException() - .isThrownBy(() -> publisherInstance.inform(notification, createConfig())); + .isThrownBy(() -> publisherInstance.inform(PublishContext.from(notification), notification, createConfig())); } @Test @@ -93,7 +93,7 @@ public void testInformWithBomProcessingFailedNotification() { .subject(subject); assertThatNoException() - .isThrownBy(() -> publisherInstance.inform(notification, createConfig())); + .isThrownBy(() -> publisherInstance.inform(PublishContext.from(notification), notification, createConfig())); } @Test // https://github.com/DependencyTrack/dependency-track/issues/3197 @@ -110,7 +110,7 @@ public void testInformWithBomProcessingFailedNotificationAndNoSpecVersionInSubje .subject(subject); assertThatNoException() - .isThrownBy(() -> publisherInstance.inform(notification, createConfig())); + .isThrownBy(() -> publisherInstance.inform(PublishContext.from(notification), notification, createConfig())); } @Test @@ -124,7 +124,7 @@ public void testInformWithDataSourceMirroringNotification() { .timestamp(LocalDateTime.ofEpochSecond(66666, 666, ZoneOffset.UTC)); assertThatNoException() - .isThrownBy(() -> publisherInstance.inform(notification, createConfig())); + .isThrownBy(() -> publisherInstance.inform(PublishContext.from(notification), notification, createConfig())); } @Test @@ -146,7 +146,7 @@ public void testInformWithNewVulnerabilityNotification() { .subject(subject); assertThatNoException() - .isThrownBy(() -> publisherInstance.inform(notification, createConfig())); + .isThrownBy(() -> publisherInstance.inform(PublishContext.from(notification), notification, createConfig())); } @Test @@ -168,7 +168,7 @@ public void testInformWithProjectAuditChangeNotification() { .subject(subject); assertThatNoException() - .isThrownBy(() -> publisherInstance.inform(notification, createConfig())); + .isThrownBy(() -> publisherInstance.inform(PublishContext.from(notification), notification, createConfig())); } private static Component createComponent(final Project project) { diff --git a/src/test/java/org/dependencytrack/notification/publisher/ConsolePublisherTest.java b/src/test/java/org/dependencytrack/notification/publisher/ConsolePublisherTest.java index b0799565d..f1820e1c4 100644 --- a/src/test/java/org/dependencytrack/notification/publisher/ConsolePublisherTest.java +++ b/src/test/java/org/dependencytrack/notification/publisher/ConsolePublisherTest.java @@ -60,7 +60,7 @@ public void testOutputStream() throws IOException { notification.setTitle("Test Notification"); notification.setContent("This is only a test"); ConsolePublisher publisher = new ConsolePublisher(); - publisher.inform(notification, getConfig(DefaultNotificationPublishers.CONSOLE, "")); + publisher.inform(PublishContext.from(notification), notification, getConfig(DefaultNotificationPublishers.CONSOLE, "")); Assert.assertTrue(outContent.toString().contains(expectedResult(notification))); } @@ -73,7 +73,7 @@ public void testErrorStream() throws IOException { notification.setTitle("Test Notification"); notification.setContent("This is only a test"); ConsolePublisher publisher = new ConsolePublisher(); - publisher.inform(notification, getConfig(DefaultNotificationPublishers.CONSOLE, "")); + publisher.inform(PublishContext.from(notification), notification, getConfig(DefaultNotificationPublishers.CONSOLE, "")); Assert.assertTrue(errContent.toString().contains(expectedResult(notification))); } diff --git a/src/test/java/org/dependencytrack/notification/publisher/CsWebexPublisherTest.java b/src/test/java/org/dependencytrack/notification/publisher/CsWebexPublisherTest.java index 9cb2b3887..c8aaeb1a8 100644 --- a/src/test/java/org/dependencytrack/notification/publisher/CsWebexPublisherTest.java +++ b/src/test/java/org/dependencytrack/notification/publisher/CsWebexPublisherTest.java @@ -72,6 +72,6 @@ public void testPublish() throws IOException { notification.setTitle("Test Notification"); notification.setContent("This is only a test"); CsWebexPublisher publisher = new CsWebexPublisher(); - publisher.inform(notification, config); + publisher.inform(PublishContext.from(notification), notification, config); } } From deb7125c54c036d347c8806db305f4c8a8f2a16c Mon Sep 17 00:00:00 2001 From: nscuro Date: Sat, 18 Nov 2023 19:32:56 +0100 Subject: [PATCH 2/9] Address NPEs when constructing `PublishContext` Signed-off-by: nscuro --- .../notification/publisher/PublishContext.java | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/src/main/java/org/dependencytrack/notification/publisher/PublishContext.java b/src/main/java/org/dependencytrack/notification/publisher/PublishContext.java index df0a3da2e..f14245f17 100644 --- a/src/main/java/org/dependencytrack/notification/publisher/PublishContext.java +++ b/src/main/java/org/dependencytrack/notification/publisher/PublishContext.java @@ -33,6 +33,8 @@ import java.time.format.DateTimeFormatter; import java.util.HashMap; import java.util.Map; +import java.util.Optional; +import java.util.UUID; /** * Context information about a {@link Notification} being published. @@ -63,7 +65,11 @@ public static PublishContext from(final Notification notification) { notificationSubjects.put(SUBJECT_PROJECT, Project.convert(subject.getProject())); } else if (notification.getSubject() instanceof final NewVulnerabilityIdentified subject) { notificationSubjects.put(SUBJECT_COMPONENT, Component.convert(subject.getComponent())); - notificationSubjects.put(SUBJECT_PROJECTS, subject.getAffectedProjects().stream().map(Project::convert).toList()); + if (subject.getAffectedProjects() != null) { + notificationSubjects.put(SUBJECT_PROJECTS, subject.getAffectedProjects().stream().map(Project::convert).toList()); + } else { + notificationSubjects.put(SUBJECT_PROJECTS, 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())); @@ -105,7 +111,7 @@ private static Component convert(final org.dependencytrack.model.Component notif return null; } return new Component( - notificationComponent.getUuid().toString(), + Optional.ofNullable(notificationComponent.getUuid()).map(UUID::toString).orElse(null), notificationComponent.getGroup(), notificationComponent.getName(), notificationComponent.getVersion() @@ -121,7 +127,7 @@ private static Project convert(final org.dependencytrack.model.Project notificat return null; } return new Project( - notificationProject.getUuid().toString(), + Optional.ofNullable(notificationProject.getUuid()).map(UUID::toString).orElse(null), notificationProject.getName(), notificationProject.getVersion() ); From 3e9255f48d724fcef7886ccb7ca5233776360fa9 Mon Sep 17 00:00:00 2001 From: nscuro Date: Sun, 19 Nov 2023 01:18:24 +0100 Subject: [PATCH 3/9] Add toggle to enable logging of successful notification publishing Also add more debug logs for notification routing, and masking of destination URL for Slack. Signed-off-by: nscuro --- .../model/NotificationRule.java | 20 ++++ .../notification/NotificationRouter.java | 101 ++++++++++------- .../publisher/AbstractWebhookPublisher.java | 66 +++++++---- .../publisher/ConsolePublisher.java | 9 +- .../publisher/CsWebexPublisher.java | 2 +- .../DefaultNotificationPublishers.java | 16 +-- .../notification/publisher/JiraPublisher.java | 25 ++++- .../publisher/MattermostPublisher.java | 2 +- .../publisher/MsTeamsPublisher.java | 2 +- .../publisher/PublishContext.java | 31 +++++- .../notification/publisher/Publisher.java | 7 +- .../publisher/SendMailPublisher.java | 104 ++++++++++++------ .../publisher/SlackPublisher.java | 40 ++++++- .../publisher/WebhookPublisher.java | 2 +- .../persistence/NotificationQueryManager.java | 6 +- .../persistence/QueryManager.java | 4 +- .../notification/NotificationRouterTest.java | 62 +++++------ .../publisher/SlackPublisherTest.java | 16 +++ .../v1/NotificationPublisherResourceTest.java | 4 +- 19 files changed, 360 insertions(+), 159 deletions(-) diff --git a/src/main/java/org/dependencytrack/model/NotificationRule.java b/src/main/java/org/dependencytrack/model/NotificationRule.java index cfbe2aa06..731cd1e74 100644 --- a/src/main/java/org/dependencytrack/model/NotificationRule.java +++ b/src/main/java/org/dependencytrack/model/NotificationRule.java @@ -89,6 +89,18 @@ public class NotificationRule implements Serializable { @Column(name = "NOTIFY_CHILDREN", allowsNull = "true") // New column, must allow nulls on existing data bases) private boolean notifyChildren; + /** + * In addition to warnings and errors, also emit a log message upon successful publishing. + *

+ * Intended to aid in debugging of missing notifications, or environments where notification + * delivery is critical and subject to auditing. + * + * @since 4.10.0 + */ + @Persistent + @Column(name = "LOG_SUCCESSFUL_PUBLISH", allowsNull = "true") + private boolean logSuccessfulPublish; + @Persistent(defaultFetchGroup = "true") @Column(name = "SCOPE", jdbcType = "VARCHAR", allowsNull = "false") @NotNull @@ -169,6 +181,14 @@ public void setNotifyChildren(boolean notifyChildren) { this.notifyChildren = notifyChildren; } + public boolean isLogSuccessfulPublish() { + return logSuccessfulPublish; + } + + public void setLogSuccessfulPublish(final boolean logSuccessfulPublish) { + this.logSuccessfulPublish = logSuccessfulPublish; + } + @NotNull public NotificationScope getScope() { return scope; diff --git a/src/main/java/org/dependencytrack/notification/NotificationRouter.java b/src/main/java/org/dependencytrack/notification/NotificationRouter.java index 8f6c942fa..033c7fea5 100644 --- a/src/main/java/org/dependencytrack/notification/NotificationRouter.java +++ b/src/main/java/org/dependencytrack/notification/NotificationRouter.java @@ -52,6 +52,9 @@ import java.util.UUID; import java.util.stream.Collectors; +import static org.dependencytrack.notification.publisher.Publisher.CONFIG_TEMPLATE_KEY; +import static org.dependencytrack.notification.publisher.Publisher.CONFIG_TEMPLATE_MIME_TYPE_KEY; + public class NotificationRouter implements Subscriber { private static final Logger LOGGER = Logger.getLogger(NotificationRouter.class); @@ -59,7 +62,7 @@ public class NotificationRouter implements Subscriber { public void inform(final Notification notification) { final PublishContext ctx = PublishContext.from(notification); - for (final NotificationRule rule: resolveRules(notification)) { + for (final NotificationRule rule : resolveRules(ctx, notification)) { final PublishContext ruleCtx = ctx.withRule(rule); // Not all publishers need configuration (i.e. ConsolePublisher) @@ -76,33 +79,32 @@ public void inform(final Notification notification) { NotificationPublisher notificationPublisher = rule.getPublisher(); final Class publisherClass = Class.forName(notificationPublisher.getPublisherClass()); if (Publisher.class.isAssignableFrom(publisherClass)) { - final Publisher publisher = (Publisher)publisherClass.getDeclaredConstructor().newInstance(); + final Publisher publisher = (Publisher) publisherClass.getDeclaredConstructor().newInstance(); JsonObject notificationPublisherConfig = Json.createObjectBuilder() - .add(Publisher.CONFIG_TEMPLATE_MIME_TYPE_KEY, notificationPublisher.getTemplateMimeType()) - .add(Publisher.CONFIG_TEMPLATE_KEY, notificationPublisher.getTemplate()) - .addAll(Json.createObjectBuilder(config)) - .build(); - if (publisherClass != SendMailPublisher.class || rule.getTeams().isEmpty() || rule.getTeams() == null){ + .add(CONFIG_TEMPLATE_MIME_TYPE_KEY, notificationPublisher.getTemplateMimeType()) + .add(CONFIG_TEMPLATE_KEY, notificationPublisher.getTemplate()) + .addAll(Json.createObjectBuilder(config)) + .build(); + if (publisherClass != SendMailPublisher.class || rule.getTeams().isEmpty() || rule.getTeams() == null) { publisher.inform(ruleCtx, restrictNotificationToRuleProjects(notification, rule), notificationPublisherConfig); } else { - ((SendMailPublisher)publisher).inform(ruleCtx, restrictNotificationToRuleProjects(notification, rule), notificationPublisherConfig, rule.getTeams()); + ((SendMailPublisher) publisher).inform(ruleCtx, restrictNotificationToRuleProjects(notification, rule), notificationPublisherConfig, rule.getTeams()); } - - } else { LOGGER.error("The defined notification publisher is not assignable from " + Publisher.class.getCanonicalName() + " (%s)".formatted(ruleCtx)); } - } catch (ClassNotFoundException | NoSuchMethodException | InstantiationException | InvocationTargetException | IllegalAccessException e) { + } catch (ClassNotFoundException | NoSuchMethodException | InstantiationException | + InvocationTargetException | IllegalAccessException e) { LOGGER.error("An error occurred while instantiating a notification publisher (%s)".formatted(ruleCtx), e); } catch (PublisherException publisherException) { - LOGGER.error("An error occured during the publication of the notification (%s)".formatted(ruleCtx), publisherException); + LOGGER.error("An error occurred during the publication of the notification (%s)".formatted(ruleCtx), publisherException); } } } - public Notification restrictNotificationToRuleProjects(Notification initialNotification, NotificationRule rule) { + public Notification restrictNotificationToRuleProjects(final Notification initialNotification, final NotificationRule rule) { Notification restrictedNotification = initialNotification; - if(canRestrictNotificationToRuleProjects(initialNotification, rule)) { + if (canRestrictNotificationToRuleProjects(initialNotification, rule)) { Set ruleProjectsUuids = rule.getProjects().stream().map(Project::getUuid).map(UUID::toString).collect(Collectors.toSet()); restrictedNotification = new Notification(); restrictedNotification.setGroup(initialNotification.getGroup()); @@ -111,7 +113,7 @@ public Notification restrictNotificationToRuleProjects(Notification initialNotif restrictedNotification.setContent(initialNotification.getContent()); restrictedNotification.setTitle(initialNotification.getTitle()); restrictedNotification.setTimestamp(initialNotification.getTimestamp()); - if(initialNotification.getSubject() instanceof final NewVulnerabilityIdentified subject) { + if (initialNotification.getSubject() instanceof final NewVulnerabilityIdentified subject) { Set restrictedProjects = subject.getAffectedProjects().stream().filter(project -> ruleProjectsUuids.contains(project.getUuid().toString())).collect(Collectors.toSet()); NewVulnerabilityIdentified restrictedSubject = new NewVulnerabilityIdentified(subject.getVulnerability(), subject.getComponent(), restrictedProjects, null); restrictedNotification.setSubject(restrictedSubject); @@ -120,19 +122,19 @@ public Notification restrictNotificationToRuleProjects(Notification initialNotif return restrictedNotification; } - private boolean canRestrictNotificationToRuleProjects(Notification initialNotification, NotificationRule rule) { - return initialNotification.getSubject() instanceof NewVulnerabilityIdentified && - rule.getProjects() != null - && rule.getProjects().size() > 0; + private boolean canRestrictNotificationToRuleProjects(final Notification initialNotification, final NotificationRule rule) { + return initialNotification.getSubject() instanceof NewVulnerabilityIdentified + && rule.getProjects() != null + && !rule.getProjects().isEmpty(); } - List resolveRules(final Notification notification) { - // The notification rules to process for this specific notification + List resolveRules(final PublishContext ctx, final Notification notification) { final List rules = new ArrayList<>(); - if (notification == null || notification.getScope() == null || notification.getGroup() == null || notification.getLevel() == null) { + LOGGER.debug("Mandatory fields of notification are missing; Unable to resolve rules (%s)".formatted(ctx)); return rules; } + try (QueryManager qm = new QueryManager()) { final PersistenceManager pm = qm.getPersistenceManager(); final Query query = pm.newQuery(NotificationRule.class); @@ -153,6 +155,7 @@ List resolveRules(final Notification notification) { query.setParameters(NotificationScope.valueOf(notification.getScope())); final List result = query.executeList(); pm.detachCopyAll(result); + LOGGER.debug("Matched %d notification rules (%s)".formatted(result.size(), ctx)); if (NotificationScope.PORTFOLIO.name().equals(notification.getScope()) && notification.getSubject() instanceof final NewVulnerabilityIdentified subject) { @@ -160,10 +163,10 @@ List resolveRules(final Notification notification) { // of the notification down to those projects that the rule matches and which // also match project the component is included in. // NOTE: This logic is slightly different from what is implemented in limitToProject() - for (final NotificationRule rule: result) { + for (final NotificationRule rule : result) { if (rule.getNotifyOn().contains(NotificationGroup.valueOf(notification.getGroup()))) { - if (rule.getProjects() != null && rule.getProjects().size() > 0 - && subject.getComponent() != null && subject.getComponent().getProject() != null) { + if (rule.getProjects() != null && !rule.getProjects().isEmpty() + && subject.getComponent() != null && subject.getComponent().getProject() != null) { for (final Project project : rule.getProjects()) { if (subject.getComponent().getProject().getUuid().equals(project.getUuid()) || (Boolean.TRUE.equals(rule.isNotifyChildren() && checkIfChildrenAreAffected(project, subject.getComponent().getProject().getUuid())))) { rules.add(rule); @@ -176,27 +179,27 @@ List resolveRules(final Notification notification) { } } else if (NotificationScope.PORTFOLIO.name().equals(notification.getScope()) && notification.getSubject() instanceof final NewVulnerableDependency subject) { - limitToProject(rules, result, notification, subject.getComponent().getProject()); + limitToProject(ctx, rules, result, notification, subject.getComponent().getProject()); } else if (NotificationScope.PORTFOLIO.name().equals(notification.getScope()) && notification.getSubject() instanceof final BomConsumedOrProcessed subject) { - limitToProject(rules, result, notification, subject.getProject()); + limitToProject(ctx, rules, result, notification, subject.getProject()); } else if (NotificationScope.PORTFOLIO.name().equals(notification.getScope()) && notification.getSubject() instanceof final BomProcessingFailed subject) { - limitToProject(rules, result, notification, subject.getProject()); + limitToProject(ctx, rules, result, notification, subject.getProject()); } else if (NotificationScope.PORTFOLIO.name().equals(notification.getScope()) && notification.getSubject() instanceof final VexConsumedOrProcessed subject) { - limitToProject(rules, result, notification, subject.getProject()); + limitToProject(ctx, rules, result, notification, subject.getProject()); } else if (NotificationScope.PORTFOLIO.name().equals(notification.getScope()) && notification.getSubject() instanceof final PolicyViolationIdentified subject) { - limitToProject(rules, result, notification, subject.getProject()); + limitToProject(ctx, rules, result, notification, subject.getProject()); } else if (NotificationScope.PORTFOLIO.name().equals(notification.getScope()) && notification.getSubject() instanceof final AnalysisDecisionChange subject) { - limitToProject(rules, result, notification, subject.getProject()); + limitToProject(ctx, rules, result, notification, subject.getProject()); } else if (NotificationScope.PORTFOLIO.name().equals(notification.getScope()) && notification.getSubject() instanceof final ViolationAnalysisDecisionChange subject) { - limitToProject(rules, result, notification, subject.getComponent().getProject()); + limitToProject(ctx, rules, result, notification, subject.getComponent().getProject()); } else { - for (final NotificationRule rule: result) { + for (final NotificationRule rule : result) { if (rule.getNotifyOn().contains(NotificationGroup.valueOf(notification.getGroup()))) { rules.add(rule); } @@ -210,22 +213,42 @@ List resolveRules(final Notification notification) { * if the rule specified one or more projects as targets, reduce the execution * of the notification down to those projects that the rule matches and which * also match projects affected by the vulnerability. - * */ - private void limitToProject(final List applicableRules, final List rules, - final Notification notification, final Project limitToProject) { - for (final NotificationRule rule: rules) { + */ + private void limitToProject(final PublishContext ctx, final List applicableRules, + final List rules, final Notification notification, + final Project limitToProject) { + for (final NotificationRule rule : rules) { + final PublishContext ruleCtx = ctx.withRule(rule); if (rule.getNotifyOn().contains(NotificationGroup.valueOf(notification.getGroup()))) { - if (rule.getProjects() != null && rule.getProjects().size() > 0) { + if (rule.getProjects() != null && !rule.getProjects().isEmpty()) { for (final Project project : rule.getProjects()) { - if (project.getUuid().equals(limitToProject.getUuid()) || (Boolean.TRUE.equals(rule.isNotifyChildren()) && checkIfChildrenAreAffected(project, limitToProject.getUuid()))) { + if (project.getUuid().equals(limitToProject.getUuid())) { + LOGGER.debug("Project %s is part of the \"limit to\" list of the rule; Rule is applicable (%s)" + .formatted(limitToProject.getUuid(), ruleCtx)); applicableRules.add(rule); + } else if (rule.isNotifyChildren()) { + final boolean isChildOfLimitToProject = checkIfChildrenAreAffected(project, limitToProject.getUuid()); + if (isChildOfLimitToProject) { + LOGGER.debug("Project %s is child of \"limit to\" project %s; Rule is applicable (%s)" + .formatted(limitToProject.getUuid(), project.getUuid(), ruleCtx)); + applicableRules.add(rule); + } else { + LOGGER.debug("Project %s is not a child of \"limit to\" project %s; Rule is not applicable (%s)" + .formatted(limitToProject.getUuid(), project.getUuid(), ruleCtx)); + } + } else { + LOGGER.debug("Project %s is not part of the \"limit to\" list of the rule; Rule is not applicable (%s)" + .formatted(limitToProject.getUuid(), ruleCtx)); } } } else { + LOGGER.debug("Rule is not limited to projects; Rule is applicable (%s)".formatted(ruleCtx)); applicableRules.add(rule); } } } + LOGGER.debug("Applicable rules: %s (%s)" + .formatted(applicableRules.stream().map(NotificationRule::getName).collect(Collectors.joining(", ")), ctx)); } private boolean checkIfChildrenAreAffected(Project parent, UUID uuid) { diff --git a/src/main/java/org/dependencytrack/notification/publisher/AbstractWebhookPublisher.java b/src/main/java/org/dependencytrack/notification/publisher/AbstractWebhookPublisher.java index cb7ba2213..d346580f5 100644 --- a/src/main/java/org/dependencytrack/notification/publisher/AbstractWebhookPublisher.java +++ b/src/main/java/org/dependencytrack/notification/publisher/AbstractWebhookPublisher.java @@ -23,9 +23,7 @@ import org.apache.http.client.methods.CloseableHttpResponse; import org.apache.http.client.methods.HttpPost; import org.apache.http.entity.StringEntity; -import org.apache.http.util.EntityUtils; import org.dependencytrack.common.HttpClientPool; -import org.dependencytrack.exception.PublisherException; import org.dependencytrack.util.HttpUtil; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -34,30 +32,43 @@ import java.io.IOException; public abstract class AbstractWebhookPublisher implements Publisher { - public void publish(final PublishContext ctx, final String publisherName, final PebbleTemplate template, final Notification notification, final JsonObject config) { + + public void publish(final PublishContext ctx, final PebbleTemplate template, final Notification notification, final JsonObject config) { final Logger logger = LoggerFactory.getLogger(getClass()); - logger.debug("Preparing to publish " + publisherName + " notification"); + if (config == null) { - logger.warn("No configuration found. Skipping notification. (%s)".formatted(ctx)); + logger.warn("No publisher configuration found; Skipping notification (%s)".formatted(ctx)); return; } + final String destination = getDestinationUrl(config); - final String content = prepareTemplate(ctx, notification, template); - if (destination == null || content == null) { - logger.warn("A destination or template was not found. Skipping notification (%s)".formatted(ctx)); + if (destination == null) { + logger.warn("No destination configured; Skipping notification (%s)".formatted(ctx)); return; } - final String mimeType = getTemplateMimeType(config); - var request = new HttpPost(destination); - request.addHeader("content-type", mimeType); - request.addHeader("accept", mimeType); + final AuthCredentials credentials; try { credentials = getAuthCredentials(); - } catch (PublisherException e) { - logger.warn("An error occurred during the retrieval of credentials needed for notification publication. Skipping notification (%s)".formatted(ctx), e); + } catch (RuntimeException e) { + logger.warn(""" + An error occurred during the retrieval of credentials needed for notification \ + publication; Skipping notification (%s)""".formatted(ctx), e); return; } + + final String content; + try { + content = prepareTemplate(notification, template); + } catch (IOException | RuntimeException e) { + logger.error("Failed to prepare notification content (%s)".formatted(ctx), e); + return; + } + + final String mimeType = getTemplateMimeType(config); + var request = new HttpPost(destination); + request.addHeader("content-type", mimeType); + request.addHeader("accept", mimeType); if (credentials != null) { if(credentials.user() != null) { request.addHeader("Authorization", HttpUtil.basicAuthHeaderValue(credentials.user(), credentials.password())); @@ -69,11 +80,13 @@ public void publish(final PublishContext ctx, final String publisherName, final try { request.setEntity(new StringEntity(content)); try (final CloseableHttpResponse response = HttpClientPool.getClient().execute(request)) { - if (response.getStatusLine().getStatusCode() < 200 || response.getStatusLine().getStatusCode() >= 300) { - logger.error("An error was encountered publishing notification to " + publisherName + - "with HTTP Status : " + response.getStatusLine().getStatusCode() + " " + response.getStatusLine().getReasonPhrase() + - " Destination: " + destination + " Response: " + EntityUtils.toString(response.getEntity())); - logger.debug(content); + final int statusCode = response.getStatusLine().getStatusCode(); + if (statusCode < 200 || statusCode >= 300) { + logger.warn("Destination %s responded with with status code %d, likely indicating a processing failure (%s)" + .formatted(maybeSanitizeDestinationUrl(destination), statusCode, ctx)); + } else if (ctx.logSuccess()) { + logger.info("Destination %s acknowledged reception of notification with status code %d (%s)" + .formatted(maybeSanitizeDestinationUrl(destination), statusCode, ctx)); } } } catch (IOException ex) { @@ -82,9 +95,19 @@ public void publish(final PublishContext ctx, final String publisherName, final } protected String getDestinationUrl(final JsonObject config) { - return config.getString(CONFIG_DESTINATION); + return config.getString(CONFIG_DESTINATION, null); } + /** + * Sanitize the destination URL from any secrets that are not supposed to be logged. + * + * @param destinationUrl The destination URL to sanitize + * @return The sanitized destination URL + * @since 4.10.0 + */ + protected String maybeSanitizeDestinationUrl(final String destinationUrl) { + return destinationUrl; + } protected AuthCredentials getAuthCredentials() { return null; @@ -94,6 +117,7 @@ protected record AuthCredentials(String user, String password) { } protected void handleRequestException(final PublishContext ctx, final Logger logger, final Exception e) { - logger.error("Request failure (%s)".formatted(ctx), e); + logger.error("Failed to send notification request (%s)".formatted(ctx), e); } + } diff --git a/src/main/java/org/dependencytrack/notification/publisher/ConsolePublisher.java b/src/main/java/org/dependencytrack/notification/publisher/ConsolePublisher.java index 9c7d21025..78b29126f 100644 --- a/src/main/java/org/dependencytrack/notification/publisher/ConsolePublisher.java +++ b/src/main/java/org/dependencytrack/notification/publisher/ConsolePublisher.java @@ -24,6 +24,7 @@ import io.pebbletemplates.pebble.PebbleEngine; import javax.json.JsonObject; +import java.io.IOException; import java.io.PrintStream; public class ConsolePublisher implements Publisher { @@ -32,9 +33,11 @@ public class ConsolePublisher implements Publisher { private static final PebbleEngine ENGINE = new PebbleEngine.Builder().newLineTrimming(false).build(); public void inform(final PublishContext ctx, final Notification notification, final JsonObject config) { - final String content = prepareTemplate(ctx, notification, getTemplate(config)); - if (content == null) { - LOGGER.warn("A template was not found. Skipping notification (%s)".formatted(ctx)); + final String content; + try { + content = prepareTemplate(notification, getTemplate(config)); + } catch (IOException | RuntimeException e) { + LOGGER.error("Failed to prepare notification content (%s)".formatted(ctx), e); return; } final PrintStream ps; diff --git a/src/main/java/org/dependencytrack/notification/publisher/CsWebexPublisher.java b/src/main/java/org/dependencytrack/notification/publisher/CsWebexPublisher.java index 9db768246..f820c6494 100644 --- a/src/main/java/org/dependencytrack/notification/publisher/CsWebexPublisher.java +++ b/src/main/java/org/dependencytrack/notification/publisher/CsWebexPublisher.java @@ -28,7 +28,7 @@ public class CsWebexPublisher extends AbstractWebhookPublisher implements Publis private static final PebbleEngine ENGINE = new PebbleEngine.Builder().defaultEscapingStrategy("json").build(); public void inform(final PublishContext ctx, final Notification notification, final JsonObject config) { - publish(ctx, DefaultNotificationPublishers.CS_WEBEX.getPublisherName(), getTemplate(config), notification, config); + publish(ctx, getTemplate(config), notification, config); } @Override diff --git a/src/main/java/org/dependencytrack/notification/publisher/DefaultNotificationPublishers.java b/src/main/java/org/dependencytrack/notification/publisher/DefaultNotificationPublishers.java index 3f4b8c92d..c96e4fdec 100644 --- a/src/main/java/org/dependencytrack/notification/publisher/DefaultNotificationPublishers.java +++ b/src/main/java/org/dependencytrack/notification/publisher/DefaultNotificationPublishers.java @@ -31,14 +31,14 @@ public enum DefaultNotificationPublishers { CS_WEBEX("Cisco Webex", "Publishes notifications to a Cisco Webex Teams channel", CsWebexPublisher.class, "/templates/notification/publisher/cswebex.peb", MediaType.APPLICATION_JSON, true), JIRA("Jira", "Creates a Jira issue in a configurable Jira instance and queue", JiraPublisher.class, "/templates/notification/publisher/jira.peb", MediaType.APPLICATION_JSON, true); - private String name; - private String description; - private Class publisherClass; - private String templateFile; - private String templateMimeType; - private boolean defaultPublisher; + private final String name; + private final String description; + private final Class publisherClass; + private final String templateFile; + private final String templateMimeType; + private final boolean defaultPublisher; - DefaultNotificationPublishers(final String name, final String description, final Class publisherClass, + DefaultNotificationPublishers(final String name, final String description, final Class publisherClass, final String templateFile, final String templateMimeType, final boolean defaultPublisher) { this.name = name; this.description = description; @@ -56,7 +56,7 @@ public String getPublisherDescription() { return description; } - public Class getPublisherClass() { + public Class getPublisherClass() { return publisherClass; } diff --git a/src/main/java/org/dependencytrack/notification/publisher/JiraPublisher.java b/src/main/java/org/dependencytrack/notification/publisher/JiraPublisher.java index c556339e6..faad3d230 100644 --- a/src/main/java/org/dependencytrack/notification/publisher/JiraPublisher.java +++ b/src/main/java/org/dependencytrack/notification/publisher/JiraPublisher.java @@ -40,16 +40,33 @@ * @since 4.7 */ public class JiraPublisher extends AbstractWebhookPublisher implements Publisher { + private static final Logger LOGGER = Logger.getLogger(JiraPublisher.class); private static final PebbleEngine ENGINE = new PebbleEngine.Builder().defaultEscapingStrategy("json").build(); + private String jiraProjectKey; private String jiraTicketType; @Override public void inform(final PublishContext ctx, final Notification notification, final JsonObject config) { - jiraTicketType = config.getString("jiraTicketType"); - jiraProjectKey = config.getString(CONFIG_DESTINATION); - publish(ctx, DefaultNotificationPublishers.JIRA.getPublisherName(), getTemplate(config), notification, config); + if (config == null) { + LOGGER.warn("No publisher configuration provided; Skipping notification (%s)".formatted(ctx)); + return; + } + + jiraTicketType = config.getString("jiraTicketType", null); + if (jiraTicketType == null) { + LOGGER.warn("No JIRA ticket type configured; Skipping notification (%s)".formatted(ctx)); + return; + } + + jiraProjectKey = config.getString(CONFIG_DESTINATION, null); + if (jiraProjectKey == null) { + LOGGER.warn("No JIRA project key configured; Skipping notification (%s)".formatted(ctx)); + return; + } + + publish(ctx, getTemplate(config), notification, config); } @Override @@ -68,7 +85,7 @@ public String getDestinationUrl(final JsonObject config) { } @Override - public AuthCredentials getAuthCredentials() { + protected AuthCredentials getAuthCredentials() { try (final QueryManager qm = new QueryManager()) { final ConfigProperty jiraUsernameProp = qm.getConfigProperty(JIRA_USERNAME.getGroupName(), JIRA_USERNAME.getPropertyName()); final String jiraUsername = (jiraUsernameProp == null) ? null : jiraUsernameProp.getPropertyValue(); diff --git a/src/main/java/org/dependencytrack/notification/publisher/MattermostPublisher.java b/src/main/java/org/dependencytrack/notification/publisher/MattermostPublisher.java index 0e8c358ec..32fa6f18d 100644 --- a/src/main/java/org/dependencytrack/notification/publisher/MattermostPublisher.java +++ b/src/main/java/org/dependencytrack/notification/publisher/MattermostPublisher.java @@ -28,7 +28,7 @@ public class MattermostPublisher extends AbstractWebhookPublisher implements Pub private static final PebbleEngine ENGINE = new PebbleEngine.Builder().defaultEscapingStrategy("json").build(); public void inform(final PublishContext ctx, final Notification notification, final JsonObject config) { - publish(ctx, DefaultNotificationPublishers.MATTERMOST.getPublisherName(), getTemplate(config), notification, config); + publish(ctx, getTemplate(config), notification, config); } @Override diff --git a/src/main/java/org/dependencytrack/notification/publisher/MsTeamsPublisher.java b/src/main/java/org/dependencytrack/notification/publisher/MsTeamsPublisher.java index ab19f9b5f..ec3361b92 100644 --- a/src/main/java/org/dependencytrack/notification/publisher/MsTeamsPublisher.java +++ b/src/main/java/org/dependencytrack/notification/publisher/MsTeamsPublisher.java @@ -28,7 +28,7 @@ public class MsTeamsPublisher extends AbstractWebhookPublisher implements Publis private static final PebbleEngine ENGINE = new PebbleEngine.Builder().defaultEscapingStrategy("json").build(); public void inform(final PublishContext ctx, final Notification notification, final JsonObject config) { - publish(ctx, DefaultNotificationPublishers.MS_TEAMS.getPublisherName(), getTemplate(config), notification, config); + publish(ctx, getTemplate(config), notification, config); } @Override diff --git a/src/main/java/org/dependencytrack/notification/publisher/PublishContext.java b/src/main/java/org/dependencytrack/notification/publisher/PublishContext.java index f14245f17..9ecce9eb0 100644 --- a/src/main/java/org/dependencytrack/notification/publisher/PublishContext.java +++ b/src/main/java/org/dependencytrack/notification/publisher/PublishContext.java @@ -19,6 +19,7 @@ package org.dependencytrack.notification.publisher; import alpine.notification.Notification; +import com.google.common.base.MoreObjects; import org.dependencytrack.model.NotificationRule; import org.dependencytrack.notification.vo.AnalysisDecisionChange; import org.dependencytrack.notification.vo.BomConsumedOrProcessed; @@ -51,13 +52,17 @@ */ public record PublishContext(String notificationGroup, String notificationLevel, String notificationScope, String notificationTimestamp, Map notificationSubjects, - String ruleName, String ruleScope, String ruleLevel) { + String ruleName, String ruleScope, String ruleLevel, Boolean logSuccess) { private static final String SUBJECT_COMPONENT = "component"; private static final String SUBJECT_PROJECT = "project"; private static final String SUBJECT_PROJECTS = "projects"; public static PublishContext from(final Notification notification) { + if (notification == null) { + return null; + } + final var notificationSubjects = new HashMap(); if (notification.getSubject() instanceof final BomConsumedOrProcessed subject) { notificationSubjects.put(SUBJECT_PROJECT, Project.convert(subject.getProject())); @@ -88,9 +93,9 @@ public static PublishContext from(final Notification notification) { notificationSubjects.put(SUBJECT_PROJECT, Project.convert(subject.getProject())); } - return new PublishContext(notification.getGroup(), notification.getLevel().name(), notification.getScope(), - notification.getTimestamp().atOffset(ZoneOffset.UTC).format(DateTimeFormatter.ISO_DATE_TIME), notificationSubjects, - /* ruleName */ null, /* ruleScope */ null, /* ruleLevel */ null); + return new PublishContext(notification.getGroup(), Optional.ofNullable(notification.getLevel()).map(Enum::name).orElse(null), + notification.getScope(), notification.getTimestamp().atOffset(ZoneOffset.UTC).format(DateTimeFormatter.ISO_DATE_TIME), notificationSubjects, + /* ruleName */ null, /* ruleScope */ null, /* ruleLevel */ null, /* logSuccess */ null); } /** @@ -101,7 +106,23 @@ public static PublishContext from(final Notification notification) { */ public PublishContext withRule(final NotificationRule rule) { return new PublishContext(this.notificationGroup, this.notificationLevel, this.notificationScope, this.notificationTimestamp, - this.notificationSubjects, rule.getName(), rule.getScope().name(), rule.getNotificationLevel().name()); + this.notificationSubjects, rule.getName(), rule.getScope().name(), rule.getNotificationLevel().name(), rule.isLogSuccessfulPublish()); + } + + @Override + public String toString() { + return MoreObjects.toStringHelper(this) + .add("notificationGroup", notificationGroup) + .add("notificationLevel", notificationLevel) + .add("notificationScope", notificationScope) + .add("notificationTimestamp", notificationTimestamp) + .add("notificationSubjects", notificationSubjects) + .add("ruleName", ruleName) + .add("ruleScope", ruleScope) + .add("ruleLevel", ruleLevel) + .add("logSuccess", logSuccess) + .omitNullValues() + .toString(); } public record Component(String uuid, String group, String name, String version) { diff --git a/src/main/java/org/dependencytrack/notification/publisher/Publisher.java b/src/main/java/org/dependencytrack/notification/publisher/Publisher.java index 1ba623de7..34c06bc08 100644 --- a/src/main/java/org/dependencytrack/notification/publisher/Publisher.java +++ b/src/main/java/org/dependencytrack/notification/publisher/Publisher.java @@ -18,7 +18,6 @@ */ package org.dependencytrack.notification.publisher; -import alpine.common.logging.Logger; import alpine.common.util.UrlUtil; import alpine.model.ConfigProperty; import alpine.notification.Notification; @@ -78,8 +77,7 @@ default String getTemplateMimeType(JsonObject config) { default void enrichTemplateContext(final Map context) { } - default String prepareTemplate(final PublishContext ctx, final Notification notification, final PebbleTemplate template) { - + default String prepareTemplate(final Notification notification, final PebbleTemplate template) throws IOException { try (QueryManager qm = new QueryManager()) { final ConfigProperty baseUrlProperty = qm.getConfigProperty( ConfigPropertyConstants.GENERAL_BASE_URL.getGroupName(), @@ -132,9 +130,6 @@ default String prepareTemplate(final PublishContext ctx, final Notification noti try (final Writer writer = new StringWriter()) { template.evaluate(writer, context); return writer.toString(); - } catch (IOException e) { - Logger.getLogger(this.getClass()).error("An error was encountered evaluating template (%s)".formatted(ctx), e); - return null; } } } diff --git a/src/main/java/org/dependencytrack/notification/publisher/SendMailPublisher.java b/src/main/java/org/dependencytrack/notification/publisher/SendMailPublisher.java index abef88b80..66e11a21b 100644 --- a/src/main/java/org/dependencytrack/notification/publisher/SendMailPublisher.java +++ b/src/main/java/org/dependencytrack/notification/publisher/SendMailPublisher.java @@ -19,8 +19,6 @@ package org.dependencytrack.notification.publisher; import alpine.common.logging.Logger; -import alpine.common.util.BooleanUtil; -import alpine.model.ConfigProperty; import alpine.model.LdapUser; import alpine.model.ManagedUser; import alpine.model.OidcUser; @@ -28,12 +26,14 @@ import alpine.notification.Notification; import alpine.security.crypto.DataEncryption; import alpine.server.mail.SendMail; +import alpine.server.mail.SendMailException; import io.pebbletemplates.pebble.PebbleEngine; import io.pebbletemplates.pebble.template.PebbleTemplate; import org.dependencytrack.persistence.QueryManager; import javax.json.JsonObject; import javax.json.JsonString; +import java.io.IOException; import java.util.Arrays; import java.util.Collections; import java.util.List; @@ -58,7 +58,7 @@ public class SendMailPublisher implements Publisher { public void inform(final PublishContext ctx, final Notification notification, final JsonObject config) { if (config == null) { - LOGGER.warn("No configuration found. Skipping notification. (%s)".formatted(ctx)); + LOGGER.warn("No configuration found; Skipping notification (%s)".formatted(ctx)); return; } final String[] destinations = parseDestination(config); @@ -75,45 +75,87 @@ public void inform(final PublishContext ctx, final Notification notification, fi } private void sendNotification(final PublishContext ctx, Notification notification, JsonObject config, String[] destinations) { - PebbleTemplate template = getTemplate(config); - String mimeType = getTemplateMimeType(config); - final String content = prepareTemplate(ctx, notification, template); - if (destinations == null || content == null) { - LOGGER.warn("A destination or template was not found. Skipping notification (%s)".formatted(ctx)); + if (config == null) { + LOGGER.warn("No publisher configuration found; Skipping notification (%s)".formatted(ctx)); + return; + } + if (destinations == null) { + LOGGER.warn("No destination(s) provided; Skipping notification (%s)".formatted(ctx)); return; } + + final String content; + final String mimeType; + try { + final PebbleTemplate template = getTemplate(config); + mimeType = getTemplateMimeType(config); + content = prepareTemplate(notification, template); + } catch (IOException | RuntimeException e) { + LOGGER.error("Failed to prepare notification content (%s)".formatted(ctx), e); + return; + } + + final boolean smtpEnabled; + final String smtpFrom; + final String smtpHostname; + final int smtpPort; + final String smtpUser; + final String encryptedSmtpPassword; + final boolean smtpSslTls; + final boolean smtpTrustCert; + try (QueryManager qm = new QueryManager()) { - final ConfigProperty smtpEnabled = qm.getConfigProperty(EMAIL_SMTP_ENABLED.getGroupName(), EMAIL_SMTP_ENABLED.getPropertyName()); - final ConfigProperty smtpFrom = qm.getConfigProperty(EMAIL_SMTP_FROM_ADDR.getGroupName(), EMAIL_SMTP_FROM_ADDR.getPropertyName()); - final ConfigProperty smtpHostname = qm.getConfigProperty(EMAIL_SMTP_SERVER_HOSTNAME.getGroupName(), EMAIL_SMTP_SERVER_HOSTNAME.getPropertyName()); - final ConfigProperty smtpPort = qm.getConfigProperty(EMAIL_SMTP_SERVER_PORT.getGroupName(), EMAIL_SMTP_SERVER_PORT.getPropertyName()); - final ConfigProperty smtpUser = qm.getConfigProperty(EMAIL_SMTP_USERNAME.getGroupName(), EMAIL_SMTP_USERNAME.getPropertyName()); - final ConfigProperty smtpPass = qm.getConfigProperty(EMAIL_SMTP_PASSWORD.getGroupName(), EMAIL_SMTP_PASSWORD.getPropertyName()); - final ConfigProperty smtpSslTls = qm.getConfigProperty(EMAIL_SMTP_SSLTLS.getGroupName(), EMAIL_SMTP_SSLTLS.getPropertyName()); - final ConfigProperty smtpTrustCert = qm.getConfigProperty(EMAIL_SMTP_TRUSTCERT.getGroupName(), EMAIL_SMTP_TRUSTCERT.getPropertyName()); - - if (!BooleanUtil.valueOf(smtpEnabled.getPropertyValue())) { - LOGGER.warn("SMTP is not enabled"); - return; // smtp is not enabled + smtpEnabled = qm.isEnabled(EMAIL_SMTP_ENABLED); + if (!smtpEnabled) { + LOGGER.warn("SMTP is not enabled; Skipping notification (%s)".formatted(ctx)); + return; } - final boolean smtpAuth = (smtpUser.getPropertyValue() != null && smtpPass.getPropertyValue() != null); - final String password = (smtpPass.getPropertyValue() != null) ? DataEncryption.decryptAsString(smtpPass.getPropertyValue()) : null; + + smtpFrom = qm.getConfigProperty(EMAIL_SMTP_FROM_ADDR.getGroupName(), EMAIL_SMTP_FROM_ADDR.getPropertyName()).getPropertyValue(); + smtpHostname = qm.getConfigProperty(EMAIL_SMTP_SERVER_HOSTNAME.getGroupName(), EMAIL_SMTP_SERVER_HOSTNAME.getPropertyName()).getPropertyValue(); + smtpPort = Integer.parseInt(qm.getConfigProperty(EMAIL_SMTP_SERVER_PORT.getGroupName(), EMAIL_SMTP_SERVER_PORT.getPropertyName()).getPropertyValue()); + smtpUser = qm.getConfigProperty(EMAIL_SMTP_USERNAME.getGroupName(), EMAIL_SMTP_USERNAME.getPropertyName()).getPropertyValue(); + encryptedSmtpPassword = qm.getConfigProperty(EMAIL_SMTP_PASSWORD.getGroupName(), EMAIL_SMTP_PASSWORD.getPropertyName()).getPropertyValue(); + smtpSslTls = qm.isEnabled(EMAIL_SMTP_SSLTLS); + smtpTrustCert = qm.isEnabled(EMAIL_SMTP_TRUSTCERT); + } catch (RuntimeException e) { + LOGGER.error("Failed to load SMTP configuration from datastore (%s)".formatted(ctx), e); + return; + } + + final boolean smtpAuth = (smtpUser != null && encryptedSmtpPassword != null); + final String decryptedSmtpPassword; + try { + decryptedSmtpPassword = (encryptedSmtpPassword != null) ? DataEncryption.decryptAsString(encryptedSmtpPassword) : null; + } catch (Exception e) { + LOGGER.error("Failed to decrypt SMTP password (%s)".formatted(ctx), e); + return; + } + + try { final SendMail sendMail = new SendMail() - .from(smtpFrom.getPropertyValue()) + .from(smtpFrom) .to(destinations) .subject("[Dependency-Track] " + notification.getTitle()) .body(content) .bodyMimeType(mimeType) - .host(smtpHostname.getPropertyValue()) - .port(Integer.valueOf(smtpPort.getPropertyValue())) - .username(smtpUser.getPropertyValue()) - .password(password) + .host(smtpHostname) + .port(smtpPort) + .username(smtpUser) + .password(decryptedSmtpPassword) .smtpauth(smtpAuth) - .useStartTLS(BooleanUtil.valueOf(smtpSslTls.getPropertyValue())) - .trustCert(Boolean.valueOf(smtpTrustCert.getPropertyValue())); + .useStartTLS(smtpSslTls) + .trustCert(smtpTrustCert); sendMail.send(); - } catch (Exception e) { - LOGGER.error("An error occurred sending output email notification (%s)".formatted(ctx), e); + } catch (SendMailException | RuntimeException e) { + LOGGER.error("Failed to send notification email via %s:%d (%s)" + .formatted(smtpHostname, smtpPort, ctx), e); + return; + } + + if (ctx.logSuccess()) { + LOGGER.info("Notification email sent successfully via %s:%d (%s)" + .formatted(smtpHostname, smtpPort, ctx)); } } diff --git a/src/main/java/org/dependencytrack/notification/publisher/SlackPublisher.java b/src/main/java/org/dependencytrack/notification/publisher/SlackPublisher.java index 642b25155..e304e72c9 100644 --- a/src/main/java/org/dependencytrack/notification/publisher/SlackPublisher.java +++ b/src/main/java/org/dependencytrack/notification/publisher/SlackPublisher.java @@ -19,16 +19,33 @@ package org.dependencytrack.notification.publisher; import alpine.notification.Notification; +import alpine.server.cache.AbstractCacheManager; +import alpine.server.cache.CacheManager; import io.pebbletemplates.pebble.PebbleEngine; +import org.apache.commons.codec.digest.DigestUtils; import javax.json.JsonObject; +import java.util.regex.Matcher; +import java.util.regex.Pattern; public class SlackPublisher extends AbstractWebhookPublisher implements Publisher { private static final PebbleEngine ENGINE = new PebbleEngine.Builder().defaultEscapingStrategy("json").build(); + private static final Pattern WEBHOOK_URL_PATTERN = + Pattern.compile("^(?https://hooks\\.slack\\.com/services/T[A-Z0-9]+/B[A-Z0-9]+/)(?[A-Za-z0-9]{23,25})$"); + + private final AbstractCacheManager cacheManager; + + public SlackPublisher() { + this(CacheManager.getInstance()); + } + + SlackPublisher(final AbstractCacheManager cacheManager) { + this.cacheManager = cacheManager; + } public void inform(final PublishContext ctx, final Notification notification, final JsonObject config) { - publish(ctx, DefaultNotificationPublishers.SLACK.getPublisherName(), getTemplate(config), notification, config); + publish(ctx, getTemplate(config), notification, config); } @Override @@ -36,4 +53,25 @@ public PebbleEngine getTemplateEngine() { return ENGINE; } + @Override + protected String maybeSanitizeDestinationUrl(final String destinationUrl) { + if (destinationUrl == null) { + return null; + } + + return cacheManager.get(String.class, + "%s-%s".formatted(getClass().getSimpleName(), DigestUtils.sha1Hex(destinationUrl)), + key -> { + final Matcher matcher = WEBHOOK_URL_PATTERN.matcher(destinationUrl); + if (matcher.find()) { + final String prefix = matcher.group("prefix"); + final String secret = matcher.group("secret"); + final String maskedSecret = "*".repeat(secret.length() - 4) + secret.substring(secret.length() - 4); + return prefix + maskedSecret; + } + + return destinationUrl; + }); + } + } diff --git a/src/main/java/org/dependencytrack/notification/publisher/WebhookPublisher.java b/src/main/java/org/dependencytrack/notification/publisher/WebhookPublisher.java index bab00f5af..fcfe7732f 100644 --- a/src/main/java/org/dependencytrack/notification/publisher/WebhookPublisher.java +++ b/src/main/java/org/dependencytrack/notification/publisher/WebhookPublisher.java @@ -28,7 +28,7 @@ public class WebhookPublisher extends AbstractWebhookPublisher implements Publis private static final PebbleEngine ENGINE = new PebbleEngine.Builder().defaultEscapingStrategy("json").build(); public void inform(final PublishContext ctx, final Notification notification, final JsonObject config) { - publish(ctx, DefaultNotificationPublishers.WEBHOOK.getPublisherName(), getTemplate(config), notification, config); + publish(ctx, getTemplate(config), notification, config); } @Override diff --git a/src/main/java/org/dependencytrack/persistence/NotificationQueryManager.java b/src/main/java/org/dependencytrack/persistence/NotificationQueryManager.java index 1a6eab58d..ea94e985d 100644 --- a/src/main/java/org/dependencytrack/persistence/NotificationQueryManager.java +++ b/src/main/java/org/dependencytrack/persistence/NotificationQueryManager.java @@ -68,6 +68,7 @@ public NotificationRule createNotificationRule(String name, NotificationScope sc rule.setPublisher(publisher); rule.setEnabled(true); rule.setNotifyChildren(true); + rule.setLogSuccessfulPublish(false); return persist(rule); } @@ -81,6 +82,7 @@ public NotificationRule updateNotificationRule(NotificationRule transientRule) { rule.setName(transientRule.getName()); rule.setEnabled(transientRule.isEnabled()); rule.setNotifyChildren(transientRule.isNotifyChildren()); + rule.setLogSuccessfulPublish(transientRule.isLogSuccessfulPublish()); rule.setNotificationLevel(transientRule.getNotificationLevel()); rule.setPublisherConfig(transientRule.getPublisherConfig()); rule.setNotifyOn(transientRule.getNotifyOn()); @@ -133,7 +135,7 @@ public NotificationPublisher getNotificationPublisher(final String name) { * @param clazz The Class of the NotificationPublisher * @return a NotificationPublisher */ - public NotificationPublisher getDefaultNotificationPublisher(final Class clazz) { + public NotificationPublisher getDefaultNotificationPublisher(final Class clazz) { return getDefaultNotificationPublisher(clazz.getCanonicalName()); } @@ -155,7 +157,7 @@ private NotificationPublisher getDefaultNotificationPublisher(final String clazz * @return a NotificationPublisher */ public NotificationPublisher createNotificationPublisher(final String name, final String description, - final Class publisherClass, final String templateContent, + final Class publisherClass, final String templateContent, final String templateMimeType, final boolean defaultPublisher) { pm.currentTransaction().begin(); final NotificationPublisher publisher = new NotificationPublisher(); diff --git a/src/main/java/org/dependencytrack/persistence/QueryManager.java b/src/main/java/org/dependencytrack/persistence/QueryManager.java index 6524a339c..e44d74ff4 100644 --- a/src/main/java/org/dependencytrack/persistence/QueryManager.java +++ b/src/main/java/org/dependencytrack/persistence/QueryManager.java @@ -1156,12 +1156,12 @@ public NotificationPublisher getNotificationPublisher(final String name) { return getNotificationQueryManager().getNotificationPublisher(name); } - public NotificationPublisher getDefaultNotificationPublisher(final Class clazz) { + public NotificationPublisher getDefaultNotificationPublisher(final Class clazz) { return getNotificationQueryManager().getDefaultNotificationPublisher(clazz); } public NotificationPublisher createNotificationPublisher(final String name, final String description, - final Class publisherClass, final String templateContent, + final Class publisherClass, final String templateContent, final String templateMimeType, final boolean defaultPublisher) { return getNotificationQueryManager().createNotificationPublisher(name, description, publisherClass, templateContent, templateMimeType, defaultPublisher); } diff --git a/src/test/java/org/dependencytrack/notification/NotificationRouterTest.java b/src/test/java/org/dependencytrack/notification/NotificationRouterTest.java index 251b12abd..1e1acc4b0 100644 --- a/src/test/java/org/dependencytrack/notification/NotificationRouterTest.java +++ b/src/test/java/org/dependencytrack/notification/NotificationRouterTest.java @@ -58,7 +58,7 @@ public class NotificationRouterTest extends PersistenceCapableTest { public void testNullNotification() { Notification notification = null; NotificationRouter router = new NotificationRouter(); - List rules = router.resolveRules(notification); + List rules = router.resolveRules(PublishContext.from(notification), notification); Assert.assertEquals(0, rules.size()); } @@ -66,7 +66,7 @@ public void testNullNotification() { public void testInvalidNotification() { Notification notification = new Notification(); NotificationRouter router = new NotificationRouter(); - List rules = router.resolveRules(notification); + List rules = router.resolveRules(PublishContext.from(notification), notification); Assert.assertEquals(0, rules.size()); } @@ -77,7 +77,7 @@ public void testNoRules() { notification.setGroup(NotificationGroup.NEW_VULNERABILITY.name()); notification.setLevel(NotificationLevel.INFORMATIONAL); NotificationRouter router = new NotificationRouter(); - List rules = router.resolveRules(notification); + List rules = router.resolveRules(PublishContext.from(notification), notification); Assert.assertEquals(0, rules.size()); } @@ -99,7 +99,7 @@ public void testValidMatchingRule() { notification.setSubject(subject); // Ok, let's test this NotificationRouter router = new NotificationRouter(); - List rules = router.resolveRules(notification); + List rules = router.resolveRules(PublishContext.from(notification), notification); Assert.assertEquals(1, rules.size()); } @@ -128,7 +128,7 @@ public void testValidMatchingProjectLimitingRule() { notification.setSubject(subject); // Ok, let's test this NotificationRouter router = new NotificationRouter(); - List rules = router.resolveRules(notification); + List rules = router.resolveRules(PublishContext.from(notification), notification); Assert.assertEquals(1, rules.size()); } @@ -158,7 +158,7 @@ public void testValidNonMatchingProjectLimitingRule() { notification.setSubject(subject); // Ok, let's test this NotificationRouter router = new NotificationRouter(); - List rules = router.resolveRules(notification); + List rules = router.resolveRules(PublishContext.from(notification), notification); Assert.assertEquals(1, rules.size()); } @@ -245,7 +245,7 @@ public void testValidNonMatchingRule() { notification.setSubject(subject); // Ok, let's test this NotificationRouter router = new NotificationRouter(); - List rules = router.resolveRules(notification); + List rules = router.resolveRules(PublishContext.from(notification), notification); Assert.assertEquals(0, rules.size()); } @@ -261,7 +261,7 @@ public void testRuleLevelEqual() { notification.setLevel(NotificationLevel.WARNING); // Rule level is equal final var router = new NotificationRouter(); - assertThat(router.resolveRules(notification)).hasSize(1); + assertThat(router.resolveRules(PublishContext.from(notification), notification)).hasSize(1); } @Test @@ -276,7 +276,7 @@ public void testRuleLevelBelow() { notification.setLevel(NotificationLevel.ERROR); // Rule level is lower final var router = new NotificationRouter(); - assertThat(router.resolveRules(notification)).hasSize(1); + assertThat(router.resolveRules(PublishContext.from(notification), notification)).hasSize(1); } @Test @@ -292,7 +292,7 @@ public void testRuleLevelAbove() { notification.setLevel(NotificationLevel.INFORMATIONAL); // Rule level is higher final var router = new NotificationRouter(); - assertThat(router.resolveRules(notification)).isEmpty(); + assertThat(router.resolveRules(PublishContext.from(notification), notification)).isEmpty(); } @Test @@ -309,7 +309,7 @@ public void testDisabledRule() { notification.setLevel(NotificationLevel.INFORMATIONAL); final var router = new NotificationRouter(); - assertThat(router.resolveRules(notification)).isEmpty(); + assertThat(router.resolveRules(PublishContext.from(notification), notification)).isEmpty(); } @Test @@ -339,10 +339,10 @@ public void testNewVulnerabilityIdentifiedLimitedToProject() { notification.setSubject(new NewVulnerabilityIdentified(null, componentB, Set.of(), null)); final var router = new NotificationRouter(); - assertThat(router.resolveRules(notification)).isEmpty(); + assertThat(router.resolveRules(PublishContext.from(notification), notification)).isEmpty(); notification.setSubject(new NewVulnerabilityIdentified(null, componentA, Set.of(), null)); - assertThat(router.resolveRules(notification)) + assertThat(router.resolveRules(PublishContext.from(notification), notification)) .satisfiesExactly(resolvedRule -> assertThat(resolvedRule.getName()).isEqualTo("Test Rule")); } @@ -373,10 +373,10 @@ public void testNewVulnerableDependencyLimitedToProject() { notification.setSubject(new NewVulnerableDependency(componentB, null)); final var router = new NotificationRouter(); - assertThat(router.resolveRules(notification)).isEmpty(); + assertThat(router.resolveRules(PublishContext.from(notification), notification)).isEmpty(); notification.setSubject(new NewVulnerableDependency(componentA, null)); - assertThat(router.resolveRules(notification)) + assertThat(router.resolveRules(PublishContext.from(notification), notification)) .satisfiesExactly(resolvedRule -> assertThat(resolvedRule.getName()).isEqualTo("Test Rule")); } @@ -398,10 +398,10 @@ public void testBomConsumedOrProcessedLimitedToProject() { notification.setSubject(new BomConsumedOrProcessed(projectB, "", Bom.Format.CYCLONEDX, "")); final var router = new NotificationRouter(); - assertThat(router.resolveRules(notification)).isEmpty(); + assertThat(router.resolveRules(PublishContext.from(notification), notification)).isEmpty(); notification.setSubject(new BomConsumedOrProcessed(projectA, "", Bom.Format.CYCLONEDX, "")); - assertThat(router.resolveRules(notification)) + assertThat(router.resolveRules(PublishContext.from(notification), notification)) .satisfiesExactly(resolvedRule -> assertThat(resolvedRule.getName()).isEqualTo("Test Rule")); } @@ -423,10 +423,10 @@ public void testBomProcessingFailedLimitedToProject() { notification.setSubject(new BomProcessingFailed(projectB, "", null, Bom.Format.CYCLONEDX, "")); final var router = new NotificationRouter(); - assertThat(router.resolveRules(notification)).isEmpty(); + assertThat(router.resolveRules(PublishContext.from(notification), notification)).isEmpty(); notification.setSubject(new BomProcessingFailed(projectA, "", null, Bom.Format.CYCLONEDX, "")); - assertThat(router.resolveRules(notification)) + assertThat(router.resolveRules(PublishContext.from(notification), notification)) .satisfiesExactly(resolvedRule -> assertThat(resolvedRule.getName()).isEqualTo("Test Rule")); } @@ -448,10 +448,10 @@ public void testVexConsumedOrProcessedLimitedToProject() { notification.setSubject(new VexConsumedOrProcessed(projectB, "", Vex.Format.CYCLONEDX, "")); final var router = new NotificationRouter(); - assertThat(router.resolveRules(notification)).isEmpty(); + assertThat(router.resolveRules(PublishContext.from(notification), notification)).isEmpty(); notification.setSubject(new VexConsumedOrProcessed(projectA, "", Vex.Format.CYCLONEDX, "")); - assertThat(router.resolveRules(notification)) + assertThat(router.resolveRules(PublishContext.from(notification), notification)) .satisfiesExactly(resolvedRule -> assertThat(resolvedRule.getName()).isEqualTo("Test Rule")); } @@ -482,10 +482,10 @@ public void testPolicyViolationIdentifiedLimitedToProject() { notification.setSubject(new PolicyViolationIdentified(null, componentB, projectB)); final var router = new NotificationRouter(); - assertThat(router.resolveRules(notification)).isEmpty(); + assertThat(router.resolveRules(PublishContext.from(notification), notification)).isEmpty(); notification.setSubject(new PolicyViolationIdentified(null, componentA, projectA)); - assertThat(router.resolveRules(notification)) + assertThat(router.resolveRules(PublishContext.from(notification), notification)) .satisfiesExactly(resolvedRule -> assertThat(resolvedRule.getName()).isEqualTo("Test Rule")); } @@ -507,10 +507,10 @@ public void testAnalysisDecisionChangeLimitedToProject() { notification.setSubject(new AnalysisDecisionChange(null, null, projectB, null)); final var router = new NotificationRouter(); - assertThat(router.resolveRules(notification)).isEmpty(); + assertThat(router.resolveRules(PublishContext.from(notification), notification)).isEmpty(); notification.setSubject(new AnalysisDecisionChange(null, null, projectA, null)); - assertThat(router.resolveRules(notification)) + assertThat(router.resolveRules(PublishContext.from(notification), notification)) .satisfiesExactly(resolvedRule -> assertThat(resolvedRule.getName()).isEqualTo("Test Rule")); } @@ -541,10 +541,10 @@ public void testViolationAnalysisDecisionChangeLimitedToProject() { notification.setSubject(new ViolationAnalysisDecisionChange(null, componentB, null)); final var router = new NotificationRouter(); - assertThat(router.resolveRules(notification)).isEmpty(); + assertThat(router.resolveRules(PublishContext.from(notification), notification)).isEmpty(); notification.setSubject(new ViolationAnalysisDecisionChange(null, componentA, null)); - assertThat(router.resolveRules(notification)) + assertThat(router.resolveRules(PublishContext.from(notification), notification)) .satisfiesExactly(resolvedRule -> assertThat(resolvedRule.getName()).isEqualTo("Test Rule")); } @@ -579,7 +579,7 @@ public void testAffectedChild() { notification.setSubject(subject); // Ok, let's test this NotificationRouter router = new NotificationRouter(); - List rules = router.resolveRules(notification); + List rules = router.resolveRules(PublishContext.from(notification), notification); Assert.assertTrue(rule.isNotifyChildren()); Assert.assertEquals(1, rules.size()); } @@ -616,7 +616,7 @@ public void testAffectedChildNotifyChildrenDisabled() { notification.setSubject(subject); // Ok, let's test this NotificationRouter router = new NotificationRouter(); - List rules = router.resolveRules(notification); + List rules = router.resolveRules(PublishContext.from(notification), notification); Assert.assertFalse(rule.isNotifyChildren()); Assert.assertEquals(0, rules.size()); } @@ -652,7 +652,7 @@ public void testAffectedInactiveChild() { notification.setSubject(subject); // Ok, let's test this NotificationRouter router = new NotificationRouter(); - List rules = router.resolveRules(notification); + List rules = router.resolveRules(PublishContext.from(notification), notification); Assert.assertTrue(rule.isNotifyChildren()); Assert.assertEquals(0, rules.size()); } @@ -673,7 +673,7 @@ private NotificationPublisher createMockPublisher() { return qm.createNotificationPublisher( MockPublisher.MOCK_PUBLISHER_NAME, MockPublisher.MOCK_PUBLISHER_DESCRIPTION, - (Class) NotificationRouterTest.MockPublisher.class, + NotificationRouterTest.MockPublisher.class, MockPublisher.MOCK_PUBLISHER_TEMPLATE_CONTENT, MockPublisher.MOCK_PUBLISHER_TEMPLATE_MIME_TYPE, true ); diff --git a/src/test/java/org/dependencytrack/notification/publisher/SlackPublisherTest.java b/src/test/java/org/dependencytrack/notification/publisher/SlackPublisherTest.java index bb2d2cb61..d78df31fb 100644 --- a/src/test/java/org/dependencytrack/notification/publisher/SlackPublisherTest.java +++ b/src/test/java/org/dependencytrack/notification/publisher/SlackPublisherTest.java @@ -18,11 +18,17 @@ */ package org.dependencytrack.notification.publisher; +import alpine.server.cache.AbstractCacheManager; +import org.junit.Test; + +import java.util.concurrent.TimeUnit; + import static com.github.tomakehurst.wiremock.client.WireMock.anyUrl; import static com.github.tomakehurst.wiremock.client.WireMock.equalTo; import static com.github.tomakehurst.wiremock.client.WireMock.equalToJson; import static com.github.tomakehurst.wiremock.client.WireMock.postRequestedFor; import static com.github.tomakehurst.wiremock.client.WireMock.verify; +import static org.assertj.core.api.Assertions.assertThat; public class SlackPublisherTest extends AbstractWebhookPublisherTest { @@ -451,4 +457,14 @@ public void testInformWithProjectAuditChangeNotification() { """))); } + @Test + public void testMaybeSanitizeDestinationUrl() { + final var cacheManager = new AbstractCacheManager(5, TimeUnit.SECONDS, 1) { + }; + cacheManager.put("1", "2"); // Ensure String cache exists + + assertThat(new SlackPublisher(cacheManager).maybeSanitizeDestinationUrl("https://hooks.slack.com/services/T00000000/B00000000/XXXXXXXXXXXXXXXXXXXXXXXX")) + .isEqualTo("https://hooks.slack.com/services/T00000000/B00000000/********************XXXX"); + } + } diff --git a/src/test/java/org/dependencytrack/resources/v1/NotificationPublisherResourceTest.java b/src/test/java/org/dependencytrack/resources/v1/NotificationPublisherResourceTest.java index d6f8fb2c3..cee9fc33d 100644 --- a/src/test/java/org/dependencytrack/resources/v1/NotificationPublisherResourceTest.java +++ b/src/test/java/org/dependencytrack/resources/v1/NotificationPublisherResourceTest.java @@ -220,7 +220,7 @@ public void updateUnknownNotificationPublisherTest() { @Test public void updateExistingDefaultNotificationPublisherTest() { - NotificationPublisher notificationPublisher = qm.getDefaultNotificationPublisher((Class) SendMailPublisher.class); + NotificationPublisher notificationPublisher = qm.getDefaultNotificationPublisher(SendMailPublisher.class); notificationPublisher.setName(notificationPublisher.getName() + " Updated"); Response response = target(V1_NOTIFICATION_PUBLISHER).request() .header(X_API_KEY, apiKey) @@ -235,7 +235,7 @@ public void updateExistingDefaultNotificationPublisherTest() { public void updateNotificationPublisherWithNameOfAnotherNotificationPublisherTest() { NotificationPublisher notificationPublisher = qm.createNotificationPublisher( "Example Publisher", "Publisher description", - (Class) SendMailPublisher.class, "template", "text/html", + SendMailPublisher.class, "template", "text/html", false ); notificationPublisher = qm.detach(NotificationPublisher.class, notificationPublisher.getId()); From 4f01551aa64c70c25b99ef42e95bb497bd7ef2c4 Mon Sep 17 00:00:00 2001 From: nscuro Date: Sun, 19 Nov 2023 01:18:50 +0100 Subject: [PATCH 4/9] Add missing documentation for notification levels Signed-off-by: nscuro --- docs/_docs/integrations/notifications.md | 64 +++++++++++++++--------- 1 file changed, 41 insertions(+), 23 deletions(-) diff --git a/docs/_docs/integrations/notifications.md b/docs/_docs/integrations/notifications.md index 9172ca09d..fc53278df 100644 --- a/docs/_docs/integrations/notifications.md +++ b/docs/_docs/integrations/notifications.md @@ -9,34 +9,52 @@ Dependency-Track includes a robust and configurable notification framework capab to the presence of newly discovered vulnerabilities, previously known vulnerable components that are added to projects, as well as providing notifications on various system and error conditions. +## Scopes -Dependency-Track notifications come in two flavors: +Dependency-Track notifications come in two flavors (scopes): -| Scope | Description | -| ------|-------------| -| SYSTEM | Notifications on system-level informational and error conditions | +| Scope | Description | +|-----------|---------------------------------------------------------------------------------------| +| SYSTEM | Notifications on system-level informational and error conditions | | PORTFOLIO | Notifications on objects in the portfolio such as vulnerabilities and audit decisions | +## Levels -Each scope contains a set of notification groups that can be used to subscribe to. - -| Scope | Group | Description | -| ------|-------|-----------------------------------------------------------------------------------------------------------------------------------| -| SYSTEM | ANALYZER | Notifications generated as a result of interacting with an external source of vulnerability intelligence | -| SYSTEM | DATASOURCE_MIRRORING | Notifications generated when performing mirroring of one of the supported datasources such as the NVD | -| SYSTEM | INDEXING_SERVICE | Notifications generated as a result of performing maintenance on Dependency-Tracks internal index used for global searching | -| SYSTEM | FILE_SYSTEM | Notifications generated as a result of a file system operation. These are typically only generated on error conditions | -| SYSTEM | REPOSITORY | Notifications generated as a result of interacting with one of the supported repositories such as Maven Central, RubyGems, or NPM | -| PORTFOLIO | NEW_VULNERABILITY | Notifications generated whenever a new vulnerability is identified | -| PORTFOLIO | NEW_VULNERABLE_DEPENDENCY | Notifications generated as a result of a vulnerable component becoming a dependency of a project | -| PORTFOLIO | GLOBAL_AUDIT_CHANGE | Notifications generated whenever an analysis or suppression state has changed on a finding from a component (global) | -| PORTFOLIO | PROJECT_AUDIT_CHANGE | Notifications generated whenever an analysis or suppression state has changed on a finding from a project | -| PORTFOLIO | BOM_CONSUMED | Notifications generated whenever a supported BOM is ingested and identified | -| PORTFOLIO | BOM_PROCESSED | Notifications generated after a supported BOM is ingested, identified, and successfully processed | -| PORTFOLIO | BOM_PROCESSING_FAILED | Notifications generated whenever a BOM upload process fails | -| PORTFOLIO | POLICY_VIOLATION | Notifications generated whenever a policy violation is identified | +Notifications can have one of three possible levels: + +* INFORMATIONAL +* WARNING +* ERROR + +Notification levels behave identical to logging levels: + +* Configuring a rule for level INFORMATIONAL will match notifications of level INFORMATIONAL, WARNING, and ERROR +* Configuring a rule for level WARNING will match notifications of level WARNING and ERROR +* Configuring a rule for level ERROR will match notifications of level ERROR + +## Groups + +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 | +| 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 | POLICY_VIOLATION | INFORMATIONAL | Notifications generated whenever a policy violation is identified | ## Configuring Publishers + A notification publisher is a Dependency-Track concept allowing users to describe the structure of a notification (i.e. MIME type, template) and how to send a notification (i.e. publisher class). The following notification publishers are included by default : @@ -59,7 +77,7 @@ The template context is enhanced with the following variables : | Variable | Type | Description | |------------------------|-----------------------|----------------------------------------------------------------------------------------------------------------------------| | timestampEpochSecond | long | The notification timestamp | -| timestamp | string | The notification local date time in ISO 8601 format (i.e. uuuu-MM-dd'T'HH:mm:ss.SSSSSSSSS) | +| timestamp | string | The notification local date time in ISO 8601 format (i.e. uuuu-MM-dd'T'HH:mm:ss.SSSSSSSSS) | | notification.level | enum | One of INFORMATIONAL, WARNING, or ERROR | | notification.scope | string | The high-level type of notification. One of SYSTEM or PORTFOLIO | | notification.group | string | The specific type of notification | @@ -69,7 +87,7 @@ The template context is enhanced with the following variables : | notification.subject | Object | An optional object containing specifics of the notification | | baseUrl | string | Dependency Track base url | | subject | Specific | An optional object containing specifics of the notification. It is casted whereas notification.subject is a generic Object | -| subjectJson | javax.json.JsonObject | An optional JSON representation of the subject | +| subjectJson | javax.json.JsonObject | An optional JSON representation of the subject | > The format of the subject object will vary depending on the scope and group of notification. Not all fields in the > subject will be present at all times. Some fields are optional since the underlying fields in the datamodel are optional. From 47cb850a0fca5b984ef561048ccc32ea639d47e5 Mon Sep 17 00:00:00 2001 From: nscuro Date: Sun, 19 Nov 2023 15:15:51 +0100 Subject: [PATCH 5/9] Fix NPE in tests; Do not log Webhook destination Webhook destination URLs contain secrets with high likelihood (e.g. Slack, Mattermost, MS Teams do). We could sanitize the URLs before logging them, but we cannot foresee all the various (and potentially custom) formats. So it's better to not log it at all. Given the applicable rule's name is logged, users can simply check the rule's configuration in case the destination URL is needed. Signed-off-by: nscuro --- .../publisher/AbstractWebhookPublisher.java | 26 +++++-------- .../publisher/PublishContext.java | 5 +++ .../publisher/SendMailPublisher.java | 2 +- .../publisher/SlackPublisher.java | 38 ------------------- .../publisher/SlackPublisherTest.java | 16 -------- 5 files changed, 16 insertions(+), 71 deletions(-) diff --git a/src/main/java/org/dependencytrack/notification/publisher/AbstractWebhookPublisher.java b/src/main/java/org/dependencytrack/notification/publisher/AbstractWebhookPublisher.java index d346580f5..2ae0205b4 100644 --- a/src/main/java/org/dependencytrack/notification/publisher/AbstractWebhookPublisher.java +++ b/src/main/java/org/dependencytrack/notification/publisher/AbstractWebhookPublisher.java @@ -23,6 +23,7 @@ import org.apache.http.client.methods.CloseableHttpResponse; import org.apache.http.client.methods.HttpPost; import org.apache.http.entity.StringEntity; +import org.apache.http.util.EntityUtils; import org.dependencytrack.common.HttpClientPool; import org.dependencytrack.util.HttpUtil; import org.slf4j.Logger; @@ -82,11 +83,15 @@ public void publish(final PublishContext ctx, final PebbleTemplate template, fin try (final CloseableHttpResponse response = HttpClientPool.getClient().execute(request)) { final int statusCode = response.getStatusLine().getStatusCode(); if (statusCode < 200 || statusCode >= 300) { - logger.warn("Destination %s responded with with status code %d, likely indicating a processing failure (%s)" - .formatted(maybeSanitizeDestinationUrl(destination), statusCode, ctx)); - } else if (ctx.logSuccess()) { - logger.info("Destination %s acknowledged reception of notification with status code %d (%s)" - .formatted(maybeSanitizeDestinationUrl(destination), statusCode, ctx)); + logger.warn("Destination responded with with status code %d, likely indicating a processing failure (%s)" + .formatted(statusCode, ctx)); + if (logger.isDebugEnabled()) { + logger.debug("Response headers: %s".formatted((Object[]) response.getAllHeaders())); + logger.debug("Response body: %s".formatted(EntityUtils.toString(response.getEntity()))); + } + } else if (ctx.shouldLogSuccess()) { + logger.info("Destination acknowledged reception of notification with status code %d (%s)" + .formatted(statusCode, ctx)); } } } catch (IOException ex) { @@ -98,17 +103,6 @@ protected String getDestinationUrl(final JsonObject config) { return config.getString(CONFIG_DESTINATION, null); } - /** - * Sanitize the destination URL from any secrets that are not supposed to be logged. - * - * @param destinationUrl The destination URL to sanitize - * @return The sanitized destination URL - * @since 4.10.0 - */ - protected String maybeSanitizeDestinationUrl(final String destinationUrl) { - return destinationUrl; - } - protected AuthCredentials getAuthCredentials() { return null; } diff --git a/src/main/java/org/dependencytrack/notification/publisher/PublishContext.java b/src/main/java/org/dependencytrack/notification/publisher/PublishContext.java index 9ecce9eb0..3e506d3f0 100644 --- a/src/main/java/org/dependencytrack/notification/publisher/PublishContext.java +++ b/src/main/java/org/dependencytrack/notification/publisher/PublishContext.java @@ -48,6 +48,7 @@ * @param ruleName Name of the matched {@link NotificationRule} * @param ruleScope Scope of the matched {@link NotificationRule} * @param ruleLevel Level of the matched {@link NotificationRule} + * @param logSuccess Whether the publisher shall emit a log message upon successful publishing * @since 4.10.0 */ public record PublishContext(String notificationGroup, String notificationLevel, String notificationScope, @@ -109,6 +110,10 @@ public PublishContext withRule(final NotificationRule rule) { this.notificationSubjects, rule.getName(), rule.getScope().name(), rule.getNotificationLevel().name(), rule.isLogSuccessfulPublish()); } + public boolean shouldLogSuccess() { + return logSuccess != null && logSuccess; + } + @Override public String toString() { return MoreObjects.toStringHelper(this) diff --git a/src/main/java/org/dependencytrack/notification/publisher/SendMailPublisher.java b/src/main/java/org/dependencytrack/notification/publisher/SendMailPublisher.java index 66e11a21b..08f8dd637 100644 --- a/src/main/java/org/dependencytrack/notification/publisher/SendMailPublisher.java +++ b/src/main/java/org/dependencytrack/notification/publisher/SendMailPublisher.java @@ -153,7 +153,7 @@ private void sendNotification(final PublishContext ctx, Notification notificatio return; } - if (ctx.logSuccess()) { + if (ctx.shouldLogSuccess()) { LOGGER.info("Notification email sent successfully via %s:%d (%s)" .formatted(smtpHostname, smtpPort, ctx)); } diff --git a/src/main/java/org/dependencytrack/notification/publisher/SlackPublisher.java b/src/main/java/org/dependencytrack/notification/publisher/SlackPublisher.java index e304e72c9..707b17d64 100644 --- a/src/main/java/org/dependencytrack/notification/publisher/SlackPublisher.java +++ b/src/main/java/org/dependencytrack/notification/publisher/SlackPublisher.java @@ -19,30 +19,13 @@ package org.dependencytrack.notification.publisher; import alpine.notification.Notification; -import alpine.server.cache.AbstractCacheManager; -import alpine.server.cache.CacheManager; import io.pebbletemplates.pebble.PebbleEngine; -import org.apache.commons.codec.digest.DigestUtils; import javax.json.JsonObject; -import java.util.regex.Matcher; -import java.util.regex.Pattern; public class SlackPublisher extends AbstractWebhookPublisher implements Publisher { private static final PebbleEngine ENGINE = new PebbleEngine.Builder().defaultEscapingStrategy("json").build(); - private static final Pattern WEBHOOK_URL_PATTERN = - Pattern.compile("^(?https://hooks\\.slack\\.com/services/T[A-Z0-9]+/B[A-Z0-9]+/)(?[A-Za-z0-9]{23,25})$"); - - private final AbstractCacheManager cacheManager; - - public SlackPublisher() { - this(CacheManager.getInstance()); - } - - SlackPublisher(final AbstractCacheManager cacheManager) { - this.cacheManager = cacheManager; - } public void inform(final PublishContext ctx, final Notification notification, final JsonObject config) { publish(ctx, getTemplate(config), notification, config); @@ -53,25 +36,4 @@ public PebbleEngine getTemplateEngine() { return ENGINE; } - @Override - protected String maybeSanitizeDestinationUrl(final String destinationUrl) { - if (destinationUrl == null) { - return null; - } - - return cacheManager.get(String.class, - "%s-%s".formatted(getClass().getSimpleName(), DigestUtils.sha1Hex(destinationUrl)), - key -> { - final Matcher matcher = WEBHOOK_URL_PATTERN.matcher(destinationUrl); - if (matcher.find()) { - final String prefix = matcher.group("prefix"); - final String secret = matcher.group("secret"); - final String maskedSecret = "*".repeat(secret.length() - 4) + secret.substring(secret.length() - 4); - return prefix + maskedSecret; - } - - return destinationUrl; - }); - } - } diff --git a/src/test/java/org/dependencytrack/notification/publisher/SlackPublisherTest.java b/src/test/java/org/dependencytrack/notification/publisher/SlackPublisherTest.java index d78df31fb..bb2d2cb61 100644 --- a/src/test/java/org/dependencytrack/notification/publisher/SlackPublisherTest.java +++ b/src/test/java/org/dependencytrack/notification/publisher/SlackPublisherTest.java @@ -18,17 +18,11 @@ */ package org.dependencytrack.notification.publisher; -import alpine.server.cache.AbstractCacheManager; -import org.junit.Test; - -import java.util.concurrent.TimeUnit; - import static com.github.tomakehurst.wiremock.client.WireMock.anyUrl; import static com.github.tomakehurst.wiremock.client.WireMock.equalTo; import static com.github.tomakehurst.wiremock.client.WireMock.equalToJson; import static com.github.tomakehurst.wiremock.client.WireMock.postRequestedFor; import static com.github.tomakehurst.wiremock.client.WireMock.verify; -import static org.assertj.core.api.Assertions.assertThat; public class SlackPublisherTest extends AbstractWebhookPublisherTest { @@ -457,14 +451,4 @@ public void testInformWithProjectAuditChangeNotification() { """))); } - @Test - public void testMaybeSanitizeDestinationUrl() { - final var cacheManager = new AbstractCacheManager(5, TimeUnit.SECONDS, 1) { - }; - cacheManager.put("1", "2"); // Ensure String cache exists - - assertThat(new SlackPublisher(cacheManager).maybeSanitizeDestinationUrl("https://hooks.slack.com/services/T00000000/B00000000/XXXXXXXXXXXXXXXXXXXXXXXX")) - .isEqualTo("https://hooks.slack.com/services/T00000000/B00000000/********************XXXX"); - } - } From ad6e585aecf68e0f1552ba32815669bfae9f98b2 Mon Sep 17 00:00:00 2001 From: nscuro Date: Sun, 19 Nov 2023 19:44:20 +0100 Subject: [PATCH 6/9] Include vuln details in `PublishContext` if applicable Signed-off-by: nscuro --- .../publisher/PublishContext.java | 20 +++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/src/main/java/org/dependencytrack/notification/publisher/PublishContext.java b/src/main/java/org/dependencytrack/notification/publisher/PublishContext.java index 3e506d3f0..26f38f77c 100644 --- a/src/main/java/org/dependencytrack/notification/publisher/PublishContext.java +++ b/src/main/java/org/dependencytrack/notification/publisher/PublishContext.java @@ -58,6 +58,8 @@ public record PublishContext(String notificationGroup, String notificationLevel, private static final String SUBJECT_COMPONENT = "component"; private static final String SUBJECT_PROJECT = "project"; private static final String SUBJECT_PROJECTS = "projects"; + private static final String SUBJECT_VULNERABILITY = "vulnerability"; + private static final String SUBJECT_VULNERABILITIES = "vulnerabilities"; public static PublishContext from(final Notification notification) { if (notification == null) { @@ -76,9 +78,15 @@ public static PublishContext from(final Notification notification) { } else { notificationSubjects.put(SUBJECT_PROJECTS, null); } + notificationSubjects.put(SUBJECT_VULNERABILITY, Vulnerability.convert(subject.getVulnerability())); } 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())); + if (subject.getVulnerabilities() != null) { + notificationSubjects.put(SUBJECT_VULNERABILITIES, subject.getVulnerabilities().stream().map(Vulnerability::convert).toList()); + } else { + notificationSubjects.put(SUBJECT_VULNERABILITIES, null); + } } else if (notification.getSubject() instanceof final org.dependencytrack.model.Project subject) { notificationSubjects.put(SUBJECT_PROJECT, Project.convert(subject)); } else if (notification.getSubject() instanceof final PolicyViolationIdentified subject) { @@ -90,6 +98,7 @@ public static PublishContext from(final Notification notification) { } else if (notification.getSubject() instanceof final AnalysisDecisionChange subject) { notificationSubjects.put(SUBJECT_COMPONENT, Component.convert(subject.getComponent())); notificationSubjects.put(SUBJECT_PROJECT, Project.convert(subject.getProject())); + notificationSubjects.put(SUBJECT_VULNERABILITY, Vulnerability.convert(subject.getVulnerability())); } else if (notification.getSubject() instanceof final VexConsumedOrProcessed subject) { notificationSubjects.put(SUBJECT_PROJECT, Project.convert(subject.getProject())); } @@ -161,4 +170,15 @@ private static Project convert(final org.dependencytrack.model.Project notificat } + public record Vulnerability(String id, String source) { + + private static Vulnerability convert(final org.dependencytrack.model.Vulnerability notificationVuln) { + if (notificationVuln == null) { + return null; + } + return new Vulnerability(notificationVuln.getVulnId(), notificationVuln.getSource()); + } + + } + } From 84a2719fa60ae979bf18dea81e902d193bb92130 Mon Sep 17 00:00:00 2001 From: nscuro Date: Mon, 20 Nov 2023 19:16:31 +0100 Subject: [PATCH 7/9] Omit `logSuccess` from `PublishContext#toString` Signed-off-by: nscuro --- .../dependencytrack/notification/publisher/PublishContext.java | 1 - 1 file changed, 1 deletion(-) diff --git a/src/main/java/org/dependencytrack/notification/publisher/PublishContext.java b/src/main/java/org/dependencytrack/notification/publisher/PublishContext.java index 26f38f77c..542a10ba3 100644 --- a/src/main/java/org/dependencytrack/notification/publisher/PublishContext.java +++ b/src/main/java/org/dependencytrack/notification/publisher/PublishContext.java @@ -134,7 +134,6 @@ public String toString() { .add("ruleName", ruleName) .add("ruleScope", ruleScope) .add("ruleLevel", ruleLevel) - .add("logSuccess", logSuccess) .omitNullValues() .toString(); } From b5c39485a268a60a2a390ed9ea73ff05c6ac0efd Mon Sep 17 00:00:00 2001 From: nscuro Date: Mon, 20 Nov 2023 19:37:47 +0100 Subject: [PATCH 8/9] Add docs for debugging missing notifications Signed-off-by: nscuro --- docs/_docs/integrations/notifications.md | 38 +++++++++++++++++++++++- 1 file changed, 37 insertions(+), 1 deletion(-) diff --git a/docs/_docs/integrations/notifications.md b/docs/_docs/integrations/notifications.md index fc53278df..c590391c4 100644 --- a/docs/_docs/integrations/notifications.md +++ b/docs/_docs/integrations/notifications.md @@ -437,4 +437,40 @@ by optionally limiting the projects. Expand the 'Limit To' button to reveal and With outbound webhooks, notifications and all of their relevant details can be delivered via HTTP to an endpoint configured through Dependency-Track's notification settings. -Notifications are sent via HTTP(S) POST and contain a JSON payload. The payload has the format described above in the templating section. \ No newline at end of file +Notifications are sent via HTTP(S) POST and contain a JSON payload. The payload has the format described above in the templating section. + +## Debugging missing notifications + +Missing notifications may be caused by a variety of issues: + +* Network outage between Dependency-Track and notification destination +* Faulty proxy configuration, causing Dependency-Track to be unable to reach the notification destination +* Misconfiguration of notification rules in Dependency-Track, causing the notification to not be sent +* Bug in Dependency-Track's notification routing mechanism, causing the notification to not be sent +* Syntactically invalid notification content, causing the destination system to fail upon parsing it + +Generally, when Dependency-Track *fails* to deliver a notification to the destination, it will emit log messages +with level `WARN` or `ERROR` about it. + +As of Dependency-Track v4.10, notification rules can additionally be configured to emit a log message with level `INFO` +when publishing *succeeded*. Other than for debugging missing notifications, enabling this may also be useful in cases +where notification volume needs to be audited or monitored. Note that this can cause a significant increase in log +output, depending on how busy the system is. + +Logs include high-level details about the notification itself, its subjects, as well as the matched rule. For example: + +``` +INFO [WebhookPublisher] Destination acknowledged reception of notification with status code 200 (PublishContext{notificationGroup=NEW_VULNERABILITY, notificationLevel=INFORMATIONAL, notificationScope=PORTFOLIO, notificationTimestamp=2023-11-20T19:14:43.427901Z, notificationSubjects={component=Component[uuid=9f608f76-382c-4e05-b05f-7f69f2f6f507, group=org.apache.commons, name=commons-compress, version=1.23.0], projects=[Project[uuid=79de8ff7-6929-4fa4-8bff-ddec2424cbd2, name=Acme App, version=1.2.3]], vulnerability=Vulnerability[id=GHSA-cgwf-w82q-5jrr, source=GITHUB]}, ruleName=Foo, ruleScope=PORTFOLIO, ruleLevel=INFORMATIONAL}) +``` + +For Webhook-based notifications (*Outbound Webhook*, *Slack*, *MS Teams*, *Mattermost*, *Cisco WebEx*, *JIRA*), +services like [Request Bin](https://pipedream.com/requestbin) can be used to manually verify that notifications are sent: + +* Create a (private) Request Bin at https://pipedream.com/requestbin +* Copy the generated endpoint URL to the *Destination* field of the notification rule +* Ensure the desired *Groups* are selected for the notification rule +* Perform an action that triggers any of the selected groups + * e.g. for group `BOM_PROCESSED`, upload a BOM +* Observe the Request Bin output for any incoming requests + +If requests make it to the Bin, the problem is not in Dependency-Track. From 1058fb3b27bc2cb7695a1bcba0b0f4f701ca1a15 Mon Sep 17 00:00:00 2001 From: nscuro Date: Mon, 20 Nov 2023 19:44:51 +0100 Subject: [PATCH 9/9] Add changelog entry Signed-off-by: nscuro --- docs/_posts/2023-xx-xx-v4.10.0.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/docs/_posts/2023-xx-xx-v4.10.0.md b/docs/_posts/2023-xx-xx-v4.10.0.md index 0fd55e802..d94765d87 100644 --- a/docs/_posts/2023-xx-xx-v4.10.0.md +++ b/docs/_posts/2023-xx-xx-v4.10.0.md @@ -8,6 +8,7 @@ type: major * Add support for mirroring the NVD via its REST API - [apiserver/#3175] * Refer to the [NVD datasource documentation] for details * Improve efficiency of search index operations - [apiserver/#3116] +* Add option to emit log for successfully published notifications, and improve logging around notifications in general - [apiserver/#3211] **Fixes:** @@ -59,5 +60,6 @@ TBD [apiserver/#3117]: https://github.com/DependencyTrack/dependency-track/issues/3117 [apiserver/#3175]: https://github.com/DependencyTrack/dependency-track/pull/3175 [apiserver/#3209]: https://github.com/DependencyTrack/dependency-track/pull/3209 +[apiserver/#3211]: https://github.com/DependencyTrack/dependency-track/pull/3211 [NVD datasource documentation]: {{ site.baseurl }}{% link _docs/datasources/nvd.md %}#mirroring-via-nvd-rest-api \ No newline at end of file