diff --git a/apiserver/src/main/java/org/dependencytrack/model/NotificationRule.java b/apiserver/src/main/java/org/dependencytrack/model/NotificationRule.java index 64df9f7afc..56a8e56fc7 100644 --- a/apiserver/src/main/java/org/dependencytrack/model/NotificationRule.java +++ b/apiserver/src/main/java/org/dependencytrack/model/NotificationRule.java @@ -209,6 +209,11 @@ public class NotificationRule implements Serializable { Must not be set for rules with trigger type EVENT.""") private Boolean scheduleSkipUnchanged; + @Persistent + @Column(name = "FILTER_EXPRESSION", allowsNull = "true") + @JsonDeserialize(using = TrimmedStringDeserializer.class) + private String filterExpression; + @Persistent(defaultFetchGroup = "true", customValueStrategy = "uuid") @Unique(name = "NOTIFICATIONRULE_UUID_IDX") @Column(name = "UUID", sqlType = "UUID", allowsNull = "false") @@ -342,6 +347,14 @@ public void setPublisherConfig(String publisherConfig) { this.publisherConfig = publisherConfig; } + public String getFilterExpression() { + return filterExpression; + } + + public void setFilterExpression(String filterExpression) { + this.filterExpression = filterExpression; + } + @NotNull public UUID getUuid() { return uuid; diff --git a/apiserver/src/main/java/org/dependencytrack/notification/InvalidNotificationFilterExpressionException.java b/apiserver/src/main/java/org/dependencytrack/notification/InvalidNotificationFilterExpressionException.java new file mode 100644 index 0000000000..99563f32b0 --- /dev/null +++ b/apiserver/src/main/java/org/dependencytrack/notification/InvalidNotificationFilterExpressionException.java @@ -0,0 +1,46 @@ +/* + * This file is part of Dependency-Track. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + * SPDX-License-Identifier: Apache-2.0 + * Copyright (c) OWASP Foundation. All Rights Reserved. + */ +package org.dependencytrack.notification; + +import org.projectnessie.cel.common.CELError; + +import java.util.List; + +/** + * @since 5.7.0 + */ +public final class InvalidNotificationFilterExpressionException extends RuntimeException { + + public record Error(int line, int column, String message) { + } + + private final List errors; + + public InvalidNotificationFilterExpressionException(String message, List celErrors) { + super(message); + this.errors = celErrors.stream() + .map(e -> new Error(e.getLocation().line(), e.getLocation().column(), e.getMessage())) + .toList(); + } + + public List getErrors() { + return errors; + } + +} diff --git a/apiserver/src/main/java/org/dependencytrack/notification/NotificationFilterScriptHost.java b/apiserver/src/main/java/org/dependencytrack/notification/NotificationFilterScriptHost.java new file mode 100644 index 0000000000..c6293ccf16 --- /dev/null +++ b/apiserver/src/main/java/org/dependencytrack/notification/NotificationFilterScriptHost.java @@ -0,0 +1,144 @@ +/* + * This file is part of Dependency-Track. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + * SPDX-License-Identifier: Apache-2.0 + * Copyright (c) OWASP Foundation. All Rights Reserved. + */ +package org.dependencytrack.notification; + +import com.github.benmanes.caffeine.cache.Cache; +import com.github.benmanes.caffeine.cache.Caffeine; +import org.apache.commons.codec.digest.DigestUtils; +import org.dependencytrack.notification.proto.v1.BomConsumedOrProcessedSubject; +import org.dependencytrack.notification.proto.v1.BomProcessingFailedSubject; +import org.dependencytrack.notification.proto.v1.BomValidationFailedSubject; +import org.dependencytrack.notification.proto.v1.NewPolicyViolationsSummarySubject; +import org.dependencytrack.notification.proto.v1.NewVulnerabilitiesSummarySubject; +import org.dependencytrack.notification.proto.v1.NewVulnerabilitySubject; +import org.dependencytrack.notification.proto.v1.NewVulnerableDependencySubject; +import org.dependencytrack.notification.proto.v1.Notification; +import org.dependencytrack.notification.proto.v1.PolicyViolationAnalysisDecisionChangeSubject; +import org.dependencytrack.notification.proto.v1.PolicyViolationSubject; +import org.dependencytrack.notification.proto.v1.ProjectVulnAnalysisCompleteSubject; +import org.dependencytrack.notification.proto.v1.UserSubject; +import org.dependencytrack.notification.proto.v1.VexConsumedOrProcessedSubject; +import org.dependencytrack.notification.proto.v1.VulnerabilityAnalysisDecisionChangeSubject; +import org.dependencytrack.notification.proto.v1.VulnerabilityRetractedSubject; +import org.jspecify.annotations.Nullable; +import org.projectnessie.cel.Env; +import org.projectnessie.cel.Env.AstIssuesTuple; +import org.projectnessie.cel.EnvOption; +import org.projectnessie.cel.Library; +import org.projectnessie.cel.Program; +import org.projectnessie.cel.checker.Decls; +import org.projectnessie.cel.common.types.Err; +import org.projectnessie.cel.common.types.pb.ProtoTypeRegistry; +import org.projectnessie.cel.common.types.ref.Val; +import org.projectnessie.cel.extension.StringsLib; + +import java.util.HashMap; +import java.util.List; +import java.util.concurrent.TimeUnit; + +/** + * @since 5.7.0 + */ +public final class NotificationFilterScriptHost { + + private static final NotificationFilterScriptHost INSTANCE = new NotificationFilterScriptHost(); + + private final Cache cache; + private final Env environment; + + private NotificationFilterScriptHost() { + this.cache = Caffeine.newBuilder() + .maximumSize(256) + .expireAfterAccess(1, TimeUnit.HOURS) + .build(); + this.environment = Env.newCustomEnv( + ProtoTypeRegistry.newRegistry( + Notification.getDefaultInstance(), + BomConsumedOrProcessedSubject.getDefaultInstance(), + BomProcessingFailedSubject.getDefaultInstance(), + BomValidationFailedSubject.getDefaultInstance(), + NewPolicyViolationsSummarySubject.getDefaultInstance(), + NewVulnerabilitiesSummarySubject.getDefaultInstance(), + NewVulnerabilitySubject.getDefaultInstance(), + NewVulnerableDependencySubject.getDefaultInstance(), + PolicyViolationSubject.getDefaultInstance(), + PolicyViolationAnalysisDecisionChangeSubject.getDefaultInstance(), + VulnerabilityAnalysisDecisionChangeSubject.getDefaultInstance(), + ProjectVulnAnalysisCompleteSubject.getDefaultInstance(), + VexConsumedOrProcessedSubject.getDefaultInstance(), + VulnerabilityRetractedSubject.getDefaultInstance(), + UserSubject.getDefaultInstance()), + List.of( + Library.StdLib(), + Library.Lib(new StringsLib()), + EnvOption.container("org.dependencytrack.notification.v1"), + EnvOption.declarations( + Decls.newVar("level", Decls.Int), + Decls.newVar("scope", Decls.Int), + Decls.newVar("group", Decls.Int), + Decls.newVar("title", Decls.String), + Decls.newVar("content", Decls.String), + Decls.newVar("timestamp", Decls.Timestamp), + Decls.newVar("subject", Decls.Dyn)))); + } + + public static NotificationFilterScriptHost getInstance() { + return INSTANCE; + } + + public Program compile(String expressionSrc) { + return cache.get(DigestUtils.sha256Hex(expressionSrc), key -> { + AstIssuesTuple astIssuesTuple = environment.parse(expressionSrc); + if (astIssuesTuple.hasIssues()) { + throw new InvalidNotificationFilterExpressionException( + "Failed to parse expression", + astIssuesTuple.getIssues().getErrors()); + } + + astIssuesTuple = environment.check(astIssuesTuple.getAst()); + if (astIssuesTuple.hasIssues()) { + throw new InvalidNotificationFilterExpressionException( + "Failed to check expression", + astIssuesTuple.getIssues().getErrors()); + } + + return environment.program(astIssuesTuple.getAst()); + }); + } + + public boolean evaluate(Program program, Notification notification, @Nullable Object subject) { + final var args = new HashMap(7); + args.put("level", notification.getLevelValue()); + args.put("scope", notification.getScopeValue()); + args.put("group", notification.getGroupValue()); + args.put("title", notification.getTitle()); + args.put("content", notification.getContent()); + args.put("timestamp", notification.getTimestamp()); + args.put("subject", subject); + + final Val result = program.eval(args).getVal(); + + if (Err.isError(result)) { + throw new IllegalStateException("CEL evaluation failed: " + result); + } + + return result.convertToNative(Boolean.class); + } + +} diff --git a/apiserver/src/main/java/org/dependencytrack/notification/NotificationRouter.java b/apiserver/src/main/java/org/dependencytrack/notification/NotificationRouter.java index f1f4b630a4..7b70bd3f9d 100644 --- a/apiserver/src/main/java/org/dependencytrack/notification/NotificationRouter.java +++ b/apiserver/src/main/java/org/dependencytrack/notification/NotificationRouter.java @@ -33,6 +33,7 @@ import org.dependencytrack.notification.proto.v1.PolicyViolationSubject; import org.dependencytrack.notification.proto.v1.Project; import org.dependencytrack.notification.proto.v1.ProjectVulnAnalysisCompleteSubject; +import org.dependencytrack.notification.proto.v1.UserSubject; import org.dependencytrack.notification.proto.v1.VexConsumedOrProcessedSubject; import org.dependencytrack.notification.proto.v1.VulnerabilityAnalysisDecisionChangeSubject; import org.dependencytrack.notification.proto.v1.VulnerabilityRetractedSubject; @@ -40,6 +41,7 @@ import org.jdbi.v3.core.mapper.reflect.ConstructorMapper; import org.jdbi.v3.core.statement.Query; import org.jspecify.annotations.Nullable; +import org.projectnessie.cel.Program; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.slf4j.MDC; @@ -61,7 +63,6 @@ import static org.dependencytrack.common.MdcKeys.MDC_NOTIFICATION_LEVEL; import static org.dependencytrack.common.MdcKeys.MDC_NOTIFICATION_SCOPE; import static org.dependencytrack.notification.NotificationModelConverter.convert; -import static org.dependencytrack.notification.proto.v1.Scope.SCOPE_PORTFOLIO; /** * @since 5.7.0 @@ -156,7 +157,8 @@ public record RuleQueryResult( String name, boolean isNotifyChildProjects, @Nullable Set limitToProjectUuids, - @Nullable Set limitToTagNames) { + @Nullable Set limitToTagNames, + @Nullable String filterExpression) { private boolean isLimitedToProjects() { return limitToProjectUuids != null && !limitToProjectUuids.isEmpty(); @@ -166,6 +168,10 @@ private boolean isLimitedToTags() { return limitToTagNames != null && !limitToTagNames.isEmpty(); } + private boolean hasFilterExpression() { + return filterExpression != null && !filterExpression.isBlank(); + } + } private Map> queryRules(Collection notifications) { @@ -212,6 +218,7 @@ SELECT ARRAY_AGG("TAG"."NAME") ON "TAG"."ID" = "NOTIFICATIONRULE_TAGS"."TAG_ID" WHERE "NOTIFICATIONRULE_ID" = rule."ID" ) AS limit_to_tag_names + , rule."FILTER_EXPRESSION" FROM UNNEST(:indexes, :scopes, :levels, :groups) AS t(index, scope, level, "group") INNER JOIN "NOTIFICATIONRULE" AS rule @@ -240,16 +247,13 @@ AS t(index, scope, level, "group") private List maybeFilterRules( Notification notification, List ruleCandidates) { - final Project projectSubject = getProjectSubject(notification); - if (projectSubject == null) { - LOGGER.debug("Notification can't be filtered; All rules are applicable"); - return ruleCandidates; - } + final Object unpackedSubject = unpackSubject(notification); + final Project projectSubject = getProjectSubject(unpackedSubject); final var applicableRules = new ArrayList(ruleCandidates.size()); for (final RuleQueryResult rule : ruleCandidates) { try (var ignoredMdcRuleName = MDC.putCloseable("notificationRuleName", rule.name())) { - if (isApplicable(rule, projectSubject)) { + if (isApplicable(rule, notification, projectSubject, unpackedSubject)) { LOGGER.debug("Rule is applicable"); applicableRules.add(rule); } else { @@ -261,19 +265,34 @@ private List maybeFilterRules( return applicableRules; } - private boolean isApplicable(RuleQueryResult rule, Project project) { - // TODO: It should be possible to allow for custom filtering using CEL. - // This would address feature requests such as https://github.com/DependencyTrack/dependency-track/issues/3767. - // Since notifications are already well-defined Protobuf messages, - // it would be relatively easy to implement. + private boolean isApplicable( + RuleQueryResult rule, + Notification notification, + @Nullable Project project, + @Nullable Object subject) { + if (!isApplicableByProjectOrTag(rule, project)) { + return false; + } + + if (!evaluateFilterExpression(rule, notification, subject)) { + LOGGER.debug("Notification did not match the rule's filter expression"); + return false; + } + + return true; + } + private boolean isApplicableByProjectOrTag(RuleQueryResult rule, @Nullable Project project) { if (!rule.isLimitedToProjects() && !rule.isLimitedToTags()) { LOGGER.debug("Rule is not limited to projects or tags"); return true; } - // Tag matching is cheaper to perform since it doesn't require additional - // database interactions, so do it first. + if (project == null) { + LOGGER.debug("Notification has no project subject; Skipping project/tag filtering"); + return true; + } + if (rule.isLimitedToTags()) { LOGGER.debug("Rule is limited to tags: {}", rule.limitToTagNames()); @@ -313,46 +332,84 @@ private boolean isApplicable(RuleQueryResult rule, Project project) { return false; } - private @Nullable Project getProjectSubject(Notification notification) { - if (notification.getScope() != SCOPE_PORTFOLIO - || !notification.hasSubject()) { + private boolean evaluateFilterExpression( + RuleQueryResult rule, + Notification notification, + @Nullable Object subject) { + final String filterExpression = rule.filterExpression(); + if (filterExpression == null || filterExpression.isBlank()) { + return true; + } + + final var scriptHost = NotificationFilterScriptHost.getInstance(); + + try { + final Program program = scriptHost.compile(rule.filterExpression()); + final boolean result = scriptHost.evaluate(program, notification, subject); + LOGGER.debug("Filter expression evaluated to {}", result); + return result; + } catch (RuntimeException e) { + LOGGER.warn("Failed to evaluate filter expression for rule {}; Failing open", rule.name(), e); + return true; + } + } + + private @Nullable Project getProjectSubject(@Nullable Object subject) { + return switch (subject) { + case BomConsumedOrProcessedSubject it -> it.getProject(); + case VulnerabilityRetractedSubject it -> it.getProject(); + case BomProcessingFailedSubject it -> it.getProject(); + case BomValidationFailedSubject it -> it.getProject(); + case NewVulnerabilitySubject it -> it.getProject(); + case NewVulnerableDependencySubject it -> it.getProject(); + case PolicyViolationSubject it -> it.getProject(); + case PolicyViolationAnalysisDecisionChangeSubject it -> it.getProject(); + case VulnerabilityAnalysisDecisionChangeSubject it -> it.getProject(); + case Project it -> it; + case ProjectVulnAnalysisCompleteSubject it -> it.getProject(); + case VexConsumedOrProcessedSubject it -> it.getProject(); + case null, default -> null; + }; + } + + private @Nullable Object unpackSubject(Notification notification) { + if (!notification.hasSubject()) { return null; } try { return switch (notification.getGroup()) { case GROUP_BOM_CONSUMED, GROUP_BOM_PROCESSED -> notification.getSubject().unpack( - BomConsumedOrProcessedSubject.class).getProject(); + BomConsumedOrProcessedSubject.class); case GROUP_VULNERABILITY_RETRACTED -> notification.getSubject().unpack( - VulnerabilityRetractedSubject.class).getProject(); + VulnerabilityRetractedSubject.class); case GROUP_BOM_PROCESSING_FAILED -> notification.getSubject().unpack( - BomProcessingFailedSubject.class).getProject(); + BomProcessingFailedSubject.class); case GROUP_BOM_VALIDATION_FAILED -> notification.getSubject().unpack( - BomValidationFailedSubject.class).getProject(); + BomValidationFailedSubject.class); case GROUP_NEW_VULNERABILITY -> notification.getSubject().unpack( - NewVulnerabilitySubject.class).getProject(); + NewVulnerabilitySubject.class); case GROUP_NEW_VULNERABLE_DEPENDENCY -> notification.getSubject().unpack( - NewVulnerableDependencySubject.class).getProject(); + NewVulnerableDependencySubject.class); case GROUP_POLICY_VIOLATION -> notification.getSubject().unpack( - PolicyViolationSubject.class).getProject(); + PolicyViolationSubject.class); case GROUP_PROJECT_AUDIT_CHANGE -> { - if (notification.getSubject().is( - PolicyViolationAnalysisDecisionChangeSubject.class)) { - yield notification.getSubject().unpack( - PolicyViolationAnalysisDecisionChangeSubject.class).getProject(); - } else if (notification.getSubject().is( - VulnerabilityAnalysisDecisionChangeSubject.class)) { - yield notification.getSubject().unpack( - VulnerabilityAnalysisDecisionChangeSubject.class).getProject(); + if (notification.getSubject().is(PolicyViolationAnalysisDecisionChangeSubject.class)) { + yield notification.getSubject().unpack(PolicyViolationAnalysisDecisionChangeSubject.class); + } else if (notification.getSubject().is(VulnerabilityAnalysisDecisionChangeSubject.class)) { + yield notification.getSubject().unpack(VulnerabilityAnalysisDecisionChangeSubject.class); } - throw new IllegalStateException("Unexpected subject for group %s: %s".formatted( - notification.getGroup(), notification.getSubject().getTypeUrl())); + throw new IllegalStateException( + "Unexpected subject for group %s: %s".formatted( + notification.getGroup(), notification.getSubject().getTypeUrl())); } case GROUP_PROJECT_CREATED -> notification.getSubject().unpack(Project.class); case GROUP_PROJECT_VULN_ANALYSIS_COMPLETE -> notification.getSubject().unpack( - ProjectVulnAnalysisCompleteSubject.class).getProject(); + ProjectVulnAnalysisCompleteSubject.class); case GROUP_VEX_CONSUMED, GROUP_VEX_PROCESSED -> notification.getSubject().unpack( - VexConsumedOrProcessedSubject.class).getProject(); + VexConsumedOrProcessedSubject.class); + case GROUP_USER_CREATED, GROUP_USER_DELETED -> notification.getSubject().unpack( + UserSubject.class); default -> null; }; } catch (IOException e) { diff --git a/apiserver/src/main/java/org/dependencytrack/notification/ProcessScheduledNotificationRuleActivity.java b/apiserver/src/main/java/org/dependencytrack/notification/ProcessScheduledNotificationRuleActivity.java index 1f645e800f..3ab017ef14 100644 --- a/apiserver/src/main/java/org/dependencytrack/notification/ProcessScheduledNotificationRuleActivity.java +++ b/apiserver/src/main/java/org/dependencytrack/notification/ProcessScheduledNotificationRuleActivity.java @@ -21,6 +21,7 @@ import com.asahaf.javacron.InvalidExpressionException; import com.asahaf.javacron.Schedule; import com.fasterxml.uuid.Generators; +import com.google.protobuf.InvalidProtocolBufferException; import com.google.protobuf.util.Timestamps; import org.dependencytrack.dex.api.Activity; import org.dependencytrack.dex.api.ActivityContext; @@ -48,6 +49,7 @@ import org.dependencytrack.proto.internal.workflow.v1.ProcessScheduledNotificationRuleArg; import org.dependencytrack.proto.internal.workflow.v1.PublishNotificationWorkflowArg; import org.jspecify.annotations.Nullable; +import org.projectnessie.cel.Program; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.slf4j.MDC; @@ -170,7 +172,7 @@ private void processRule(NotificationRule rule) { }; }); - if (notification != null) { + if (notification != null && evaluateFilterExpression(rule, notification, group)) { dispatchNotification(notification, rule.getName()); } } @@ -479,6 +481,48 @@ private void advanceSchedule(NotificationRule rule, Instant lastTriggeredAt) { return createNewPolicyViolationsSummaryNotification(notificationId, subjectBuilder.build()); } + private boolean evaluateFilterExpression( + NotificationRule rule, + Notification notification, + NotificationGroup group) { + final String filterExpression = rule.getFilterExpression(); + if (filterExpression == null || filterExpression.isBlank()) { + return true; + } + + final Object subject = unpackSubject(notification, group); + final var scriptHost = NotificationFilterScriptHost.getInstance(); + + try { + final Program program = scriptHost.compile(filterExpression); + final boolean result = scriptHost.evaluate(program, notification, subject); + LOGGER.debug("Filter expression evaluated to {}", result); + return result; + } catch (RuntimeException e) { + LOGGER.warn("Failed to evaluate filter expression for rule {}; Failing open", rule.getName(), e); + return true; + } + } + + private @Nullable Object unpackSubject(Notification notification, NotificationGroup group) { + if (!notification.hasSubject()) { + return null; + } + + try { + return switch (group) { + case NEW_VULNERABILITIES_SUMMARY -> notification.getSubject().unpack( + NewVulnerabilitiesSummarySubject.class); + case NEW_POLICY_VIOLATIONS_SUMMARY -> notification.getSubject().unpack( + NewPolicyViolationsSummarySubject.class); + default -> null; + }; + } catch (InvalidProtocolBufferException e) { + LOGGER.warn("Failed to unpack notification subject", e); + return null; + } + } + private void dispatchNotification(Notification notification, String ruleName) { final String workflowInstanceId = "publish-scheduled-notification:" + notification.getId(); diff --git a/apiserver/src/main/java/org/dependencytrack/persistence/NotificationQueryManager.java b/apiserver/src/main/java/org/dependencytrack/persistence/NotificationQueryManager.java index e216aa3f1c..71f14c0426 100644 --- a/apiserver/src/main/java/org/dependencytrack/persistence/NotificationQueryManager.java +++ b/apiserver/src/main/java/org/dependencytrack/persistence/NotificationQueryManager.java @@ -162,6 +162,7 @@ public NotificationRule updateNotificationRule(NotificationRule transientRule) { rule.setLogSuccessfulPublish(transientRule.isLogSuccessfulPublish()); rule.setNotificationLevel(transientRule.getNotificationLevel()); rule.setPublisherConfig(transientRule.getPublisherConfig()); + rule.setFilterExpression(transientRule.getFilterExpression()); rule.setNotifyOn(transientRule.getNotifyOn()); bind(rule, resolveTags(transientRule.getTags())); return rule; diff --git a/apiserver/src/main/java/org/dependencytrack/persistence/jdbi/ScheduledNotificationDao.java b/apiserver/src/main/java/org/dependencytrack/persistence/jdbi/ScheduledNotificationDao.java index 9173ede223..e37d3ed75f 100644 --- a/apiserver/src/main/java/org/dependencytrack/persistence/jdbi/ScheduledNotificationDao.java +++ b/apiserver/src/main/java/org/dependencytrack/persistence/jdbi/ScheduledNotificationDao.java @@ -57,6 +57,7 @@ public ScheduledNotificationDao(Handle jdbiHandle) { , COALESCE("SCHEDULE_SKIP_UNCHANGED", FALSE) AS "SCHEDULE_SKIP_UNCHANGED" , "NOTIFY_CHILDREN" , "NOTIFY_ON" + , "FILTER_EXPRESSION" FROM "NOTIFICATIONRULE" WHERE "NAME" = :name AND "TRIGGER_TYPE" = 'SCHEDULE' diff --git a/apiserver/src/main/java/org/dependencytrack/persistence/jdbi/mapping/NotificationRuleRowMapper.java b/apiserver/src/main/java/org/dependencytrack/persistence/jdbi/mapping/NotificationRuleRowMapper.java index ecaef5f212..7482adced5 100644 --- a/apiserver/src/main/java/org/dependencytrack/persistence/jdbi/mapping/NotificationRuleRowMapper.java +++ b/apiserver/src/main/java/org/dependencytrack/persistence/jdbi/mapping/NotificationRuleRowMapper.java @@ -65,6 +65,7 @@ public NotificationRule map(ResultSet rs, StatementContext ctx) throws SQLExcept maybeSet(rs, "SCHEDULE_CRON", ResultSet::getString, rule::setScheduleCron); maybeSet(rs, "SCHEDULE_LAST_TRIGGERED_AT", ResultSet::getTimestamp, rule::setScheduleLastTriggeredAt); maybeSet(rs, "SCHEDULE_SKIP_UNCHANGED", ResultSet::getBoolean, rule::setScheduleSkipUnchanged); + maybeSet(rs, "FILTER_EXPRESSION", ResultSet::getString, rule::setFilterExpression); return rule; } diff --git a/apiserver/src/main/java/org/dependencytrack/resources/v1/NotificationRuleResource.java b/apiserver/src/main/java/org/dependencytrack/resources/v1/NotificationRuleResource.java index 3771e4dab9..02db48f758 100644 --- a/apiserver/src/main/java/org/dependencytrack/resources/v1/NotificationRuleResource.java +++ b/apiserver/src/main/java/org/dependencytrack/resources/v1/NotificationRuleResource.java @@ -53,6 +53,7 @@ import org.dependencytrack.model.NotificationTriggerType; import org.dependencytrack.model.Project; import org.dependencytrack.model.validation.ValidUuid; +import org.dependencytrack.notification.NotificationFilterScriptHost; import org.dependencytrack.notification.NotificationScope; import org.dependencytrack.notification.api.publishing.NotificationPublisherFactory; import org.dependencytrack.persistence.QueryManager; @@ -63,6 +64,7 @@ import org.dependencytrack.plugin.runtime.config.RuntimeConfigMapper; import org.dependencytrack.resources.AbstractApiResource; import org.dependencytrack.resources.v1.openapi.PaginatedApi; +import org.dependencytrack.resources.v1.problems.InvalidNotificationFilterExpressionProblemDetails; import org.dependencytrack.resources.v1.problems.ProblemDetails; import org.dependencytrack.resources.v1.vo.CreateNotificationRuleRequest; import org.dependencytrack.resources.v1.vo.CreateScheduledNotificationRuleRequest; @@ -268,6 +270,12 @@ public Response createScheduledNotificationRule(@Valid CreateScheduledNotificati description = "The updated notification rule", content = @Content(schema = @Schema(implementation = NotificationRule.class)) ), + @ApiResponse( + responseCode = "400", + description = "Invalid filter expression", + content = @Content( + schema = @Schema(implementation = InvalidNotificationFilterExpressionProblemDetails.class), + mediaType = ProblemDetails.MEDIA_TYPE_JSON)), @ApiResponse(responseCode = "401", description = "Unauthorized"), @ApiResponse(responseCode = "404", description = "The UUID of the notification rule could not be found") }) @@ -276,6 +284,10 @@ public Response createScheduledNotificationRule(@Valid CreateScheduledNotificati Permissions.Constants.SYSTEM_CONFIGURATION_UPDATE }) public Response updateNotificationRule(@Valid UpdateNotificationRuleRequest request) { + if (request.filterExpression() != null && !request.filterExpression().isBlank()) { + NotificationFilterScriptHost.getInstance().compile(request.filterExpression()); + } + final NotificationRule updatedRule; try (final var qm = new QueryManager(getAlpineRequest())) { updatedRule = qm.callInTransaction(() -> { @@ -330,6 +342,7 @@ public Response updateNotificationRule(@Valid UpdateNotificationRuleRequest requ transientRule.setNotificationLevel(request.level()); transientRule.setNotifyOn(request.notifyOn()); transientRule.setPublisherConfig(request.publisherConfig()); + transientRule.setFilterExpression(request.filterExpression()); transientRule.setTags(request.tags()); transientRule.setUuid(rule.getUuid()); transientRule.setTriggerType(rule.getTriggerType()); @@ -577,4 +590,5 @@ public Response removeTeamFromRule( }); } } + } diff --git a/apiserver/src/main/java/org/dependencytrack/resources/v1/exception/InvalidNotificationFilterExpressionExceptionMapper.java b/apiserver/src/main/java/org/dependencytrack/resources/v1/exception/InvalidNotificationFilterExpressionExceptionMapper.java new file mode 100644 index 0000000000..6163e0a719 --- /dev/null +++ b/apiserver/src/main/java/org/dependencytrack/resources/v1/exception/InvalidNotificationFilterExpressionExceptionMapper.java @@ -0,0 +1,46 @@ +/* + * This file is part of Dependency-Track. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + * SPDX-License-Identifier: Apache-2.0 + * Copyright (c) OWASP Foundation. All Rights Reserved. + */ +package org.dependencytrack.resources.v1.exception; + +import jakarta.ws.rs.core.Response; +import jakarta.ws.rs.ext.ExceptionMapper; +import jakarta.ws.rs.ext.Provider; +import org.dependencytrack.notification.InvalidNotificationFilterExpressionException; +import org.dependencytrack.resources.v1.problems.InvalidNotificationFilterExpressionProblemDetails; +import org.dependencytrack.resources.v1.vo.CelExpressionError; + +/** + * @since 5.7.0 + */ +@Provider +public final class InvalidNotificationFilterExpressionExceptionMapper + implements ExceptionMapper { + + @Override + public Response toResponse(InvalidNotificationFilterExpressionException exception) { + return new InvalidNotificationFilterExpressionProblemDetails( + 400, + "Bad Request", + "Filter expression is invalid", + exception.getErrors().stream() + .map(e -> new CelExpressionError(e.line(), e.column(), e.message())) + .toList()).toResponse(); + } + +} diff --git a/apiserver/src/main/java/org/dependencytrack/resources/v1/problems/InvalidNotificationFilterExpressionProblemDetails.java b/apiserver/src/main/java/org/dependencytrack/resources/v1/problems/InvalidNotificationFilterExpressionProblemDetails.java new file mode 100644 index 0000000000..2222c2a3ce --- /dev/null +++ b/apiserver/src/main/java/org/dependencytrack/resources/v1/problems/InvalidNotificationFilterExpressionProblemDetails.java @@ -0,0 +1,43 @@ +/* + * This file is part of Dependency-Track. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + * SPDX-License-Identifier: Apache-2.0 + * Copyright (c) OWASP Foundation. All Rights Reserved. + */ +package org.dependencytrack.resources.v1.problems; + +import io.swagger.v3.oas.annotations.media.Schema; +import org.dependencytrack.resources.v1.vo.CelExpressionError; + +import java.util.List; + +/** + * @since 5.7.0 + */ +public final class InvalidNotificationFilterExpressionProblemDetails extends ProblemDetails { + + @Schema(description = "Errors identified during CEL expression compilation") + private List errors; + + public InvalidNotificationFilterExpressionProblemDetails(int status, String title, String detail, List errors) { + super(status, title, detail); + this.errors = errors; + } + + public List getErrors() { + return errors; + } + +} diff --git a/apiserver/src/main/java/org/dependencytrack/resources/v1/problems/ProblemDetails.java b/apiserver/src/main/java/org/dependencytrack/resources/v1/problems/ProblemDetails.java index 8c77e3a178..610297bdfb 100644 --- a/apiserver/src/main/java/org/dependencytrack/resources/v1/problems/ProblemDetails.java +++ b/apiserver/src/main/java/org/dependencytrack/resources/v1/problems/ProblemDetails.java @@ -33,6 +33,7 @@ description = "An RFC 9457 problem object", subTypes = { InvalidBomProblemDetails.class, + InvalidNotificationFilterExpressionProblemDetails.class, TagOperationProblemDetails.class } ) diff --git a/apiserver/src/main/java/org/dependencytrack/resources/v1/vo/UpdateNotificationRuleRequest.java b/apiserver/src/main/java/org/dependencytrack/resources/v1/vo/UpdateNotificationRuleRequest.java index 99f9616259..f35991212b 100644 --- a/apiserver/src/main/java/org/dependencytrack/resources/v1/vo/UpdateNotificationRuleRequest.java +++ b/apiserver/src/main/java/org/dependencytrack/resources/v1/vo/UpdateNotificationRuleRequest.java @@ -22,6 +22,7 @@ import com.fasterxml.jackson.annotation.JsonIgnoreProperties; import jakarta.validation.constraints.NotBlank; import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Size; import org.dependencytrack.model.Tag; import org.dependencytrack.model.validation.ValidCronExpression; import org.dependencytrack.notification.NotificationGroup; @@ -45,6 +46,7 @@ public record UpdateNotificationRuleRequest( @JsonAlias("notificationLevel") @NotNull NotificationLevel level, Set<@NotNull NotificationGroup> notifyOn, String publisherConfig, + @Size(max = 2048) String filterExpression, Set tags, @NotNull UUID uuid, @Nullable @ValidCronExpression String scheduleCron, diff --git a/apiserver/src/test/java/org/dependencytrack/notification/NotificationRouterTest.java b/apiserver/src/test/java/org/dependencytrack/notification/NotificationRouterTest.java index a2200f10f5..31853491cf 100644 --- a/apiserver/src/test/java/org/dependencytrack/notification/NotificationRouterTest.java +++ b/apiserver/src/test/java/org/dependencytrack/notification/NotificationRouterTest.java @@ -364,4 +364,190 @@ void routeShouldHandleAllNotificationTypes(final Notification notification) { } + @Nested + class FilterExpressionTest { + + @Test + void shouldMatchWhenFilterExpressionEvaluatesToTrue() { + final var rule = new NotificationRule(); + rule.setName("rule"); + rule.setScope(NotificationScope.PORTFOLIO); + rule.setNotifyOn(Set.of(NotificationGroup.NEW_VULNERABILITY)); + rule.setNotificationLevel(NotificationLevel.INFORMATIONAL); + rule.setEnabled(true); + rule.setTriggerType(NotificationTriggerType.EVENT); + rule.setFilterExpression("subject.vulnerability.severity == \"MEDIUM\""); + qm.persist(rule); + + final Notification notification = TestNotificationFactory.createNewVulnerabilityTestNotification(); + + assertThat(router.route(List.of(notification))).satisfiesExactly(result -> { + assertThat(result.ruleNames()).containsOnly(rule.getName()); + assertThat(result.notification()).isEqualTo(notification); + }); + } + + @Test + void shouldNotMatchWhenFilterExpressionEvaluatesToFalse() { + final var rule = new NotificationRule(); + rule.setName("rule"); + rule.setScope(NotificationScope.PORTFOLIO); + rule.setNotifyOn(Set.of(NotificationGroup.NEW_VULNERABILITY)); + rule.setNotificationLevel(NotificationLevel.INFORMATIONAL); + rule.setEnabled(true); + rule.setTriggerType(NotificationTriggerType.EVENT); + rule.setFilterExpression("subject.vulnerability.severity == \"CRITICAL\""); + qm.persist(rule); + + final Notification notification = TestNotificationFactory.createNewVulnerabilityTestNotification(); + + assertThat(router.route(List.of(notification))).isEmpty(); + } + + @Test + void shouldFilterOnNotificationLevel() { + final var rule = new NotificationRule(); + rule.setName("rule"); + rule.setScope(NotificationScope.PORTFOLIO); + rule.setNotifyOn(Set.of(NotificationGroup.NEW_VULNERABILITY)); + rule.setNotificationLevel(NotificationLevel.INFORMATIONAL); + rule.setEnabled(true); + rule.setTriggerType(NotificationTriggerType.EVENT); + rule.setFilterExpression("level == Level.LEVEL_INFORMATIONAL"); + qm.persist(rule); + + final Notification notification = TestNotificationFactory.createNewVulnerabilityTestNotification(); + + assertThat(router.route(List.of(notification))).satisfiesExactly( + result -> assertThat(result.ruleNames()).containsOnly(rule.getName())); + } + + @Test + void shouldFilterOnSubjectProjectName() { + final var rule = new NotificationRule(); + rule.setName("rule"); + rule.setScope(NotificationScope.PORTFOLIO); + rule.setNotifyOn(Set.of(NotificationGroup.BOM_CONSUMED)); + rule.setNotificationLevel(NotificationLevel.INFORMATIONAL); + rule.setEnabled(true); + rule.setTriggerType(NotificationTriggerType.EVENT); + rule.setFilterExpression("subject.project.name.startsWith(\"project\")"); + qm.persist(rule); + + final Notification notification = TestNotificationFactory.createBomConsumedTestNotification(); + + assertThat(router.route(List.of(notification))).satisfiesExactly( + result -> assertThat(result.ruleNames()).containsOnly(rule.getName())); + } + + @Test + void shouldFailOpenOnInvalidFilterExpression() { + final var rule = new NotificationRule(); + rule.setName("rule"); + rule.setScope(NotificationScope.PORTFOLIO); + rule.setNotifyOn(Set.of(NotificationGroup.NEW_VULNERABILITY)); + rule.setNotificationLevel(NotificationLevel.INFORMATIONAL); + rule.setEnabled(true); + rule.setTriggerType(NotificationTriggerType.EVENT); + rule.setFilterExpression("this is not valid CEL"); + qm.persist(rule); + + final Notification notification = TestNotificationFactory.createNewVulnerabilityTestNotification(); + + assertThat(router.route(List.of(notification))).satisfiesExactly( + result -> assertThat(result.ruleNames()).containsOnly(rule.getName())); + } + + @Test + void shouldFailOpenOnFilterRuntimeError() { + // Create a rule with invalid filter expression, + // i.e. an expression accessing fields that are not available. + final var invalidRule = new NotificationRule(); + invalidRule.setName("expression-runtime-error"); + invalidRule.setScope(NotificationScope.PORTFOLIO); + invalidRule.setNotifyOn(Set.of(NotificationGroup.BOM_CONSUMED)); + invalidRule.setNotificationLevel(NotificationLevel.INFORMATIONAL); + invalidRule.setEnabled(true); + invalidRule.setTriggerType(NotificationTriggerType.EVENT); + invalidRule.setFilterExpression("subject.nonExistentField == \"whatever\""); + qm.persist(invalidRule); + + // Also create a rule with valid expression that evaluates to false, + // to confirm that fail-open is actually triggered. + final var validRule = new NotificationRule(); + validRule.setName("expression-valid-false"); + validRule.setScope(NotificationScope.PORTFOLIO); + validRule.setNotifyOn(Set.of(NotificationGroup.BOM_CONSUMED)); + validRule.setNotificationLevel(NotificationLevel.INFORMATIONAL); + validRule.setEnabled(true); + validRule.setTriggerType(NotificationTriggerType.EVENT); + validRule.setFilterExpression("subject.project.name == \"nonexistent\""); + qm.persist(validRule); + + final Notification notification = TestNotificationFactory.createBomConsumedTestNotification(); + + assertThat(router.route(List.of(notification))).satisfiesExactly( + result -> assertThat(result.ruleNames()).containsOnly(invalidRule.getName())); + } + + @Test + void shouldApplyProjectFilterBeforeFilterExpression() throws Exception { + final var project = new Project(); + project.setName("acme-app"); + qm.persist(project); + + final var otherProject = new Project(); + otherProject.setName("other-app"); + qm.persist(otherProject); + + // Rule is limited to otherProject, but expression would match. + final var rule = new NotificationRule(); + rule.setName("rule"); + rule.setScope(NotificationScope.PORTFOLIO); + rule.setNotifyOn(Set.of(NotificationGroup.BOM_CONSUMED)); + rule.setNotificationLevel(NotificationLevel.INFORMATIONAL); + rule.setEnabled(true); + rule.setTriggerType(NotificationTriggerType.EVENT); + rule.setProjects(List.of(otherProject)); + rule.setFilterExpression("true"); + qm.persist(rule); + + // Notification is for project, not otherProject. + final Notification.Builder notificationBuilder = + TestNotificationFactory.createBomConsumedTestNotification().toBuilder(); + final Notification notification = notificationBuilder + .setSubject(Any.pack(notificationBuilder.getSubject() + .unpack(BomConsumedOrProcessedSubject.class) + .toBuilder() + .setProject( + org.dependencytrack.notification.proto.v1.Project.newBuilder() + .setUuid(project.getUuid().toString()) + .setName(project.getName()) + .build()) + .build())) + .build(); + + assertThat(router.route(List.of(notification))).isEmpty(); + } + + @Test + void shouldNotApplyFilterExpressionWhenExpressionIsBlank() { + final var rule = new NotificationRule(); + rule.setName("rule"); + rule.setScope(NotificationScope.PORTFOLIO); + rule.setNotifyOn(Set.of(NotificationGroup.BOM_CONSUMED)); + rule.setNotificationLevel(NotificationLevel.INFORMATIONAL); + rule.setEnabled(true); + rule.setTriggerType(NotificationTriggerType.EVENT); + rule.setFilterExpression(" "); + qm.persist(rule); + + final Notification notification = TestNotificationFactory.createBomConsumedTestNotification(); + + assertThat(router.route(List.of(notification))).satisfiesExactly( + result -> assertThat(result.ruleNames()).containsOnly(rule.getName())); + } + + } + } \ No newline at end of file diff --git a/apiserver/src/test/java/org/dependencytrack/notification/ProcessScheduledNotificationRuleActivityTest.java b/apiserver/src/test/java/org/dependencytrack/notification/ProcessScheduledNotificationRuleActivityTest.java index 9d2b4a1104..6d5caa78a1 100644 --- a/apiserver/src/test/java/org/dependencytrack/notification/ProcessScheduledNotificationRuleActivityTest.java +++ b/apiserver/src/test/java/org/dependencytrack/notification/ProcessScheduledNotificationRuleActivityTest.java @@ -43,6 +43,7 @@ import org.dependencytrack.persistence.command.MakeViolationAnalysisCommand; import org.dependencytrack.proto.internal.workflow.v1.ProcessScheduledNotificationRuleArg; import org.dependencytrack.proto.internal.workflow.v1.PublishNotificationWorkflowArg; +import org.junit.jupiter.api.Nested; import org.junit.jupiter.api.Test; import org.mockito.ArgumentCaptor; @@ -523,4 +524,155 @@ void shouldDispatchNotificationWhenNoNewFindingsAndSkipUnchangedDisabled() { verify(dexEngine, times(2)).createRun(org.mockito.ArgumentMatchers.any()); } + @Nested + class FilterExpressionTest { + + @Test + void shouldDispatchWhenFilterExpressionMatchesNewVulnSummary() { + final Instant ruleLastFiredAt = Instant.now().minus(10, ChronoUnit.MINUTES); + final Instant afterRuleLastFiredAt = ruleLastFiredAt.plus(5, ChronoUnit.MINUTES); + + final var vuln = new Vulnerability(); + vuln.setVulnId("INT-001"); + vuln.setSource(Vulnerability.Source.INTERNAL); + vuln.setSeverity(Severity.CRITICAL); + qm.persist(vuln); + + final var project = new Project(); + project.setName("acme-app"); + qm.persist(project); + final var component = new Component(); + component.setProject(project); + component.setName("acme-lib"); + qm.persist(component); + qm.addVulnerability(vuln, component, "internal", + null, null, Date.from(afterRuleLastFiredAt)); + + final var publisher = qm.createNotificationPublisher( + "foo", null, "webhook", "template", "templateMimeType", false); + final NotificationRule rule = qm.createScheduledNotificationRule( + "foo", NotificationScope.PORTFOLIO, NotificationLevel.INFORMATIONAL, publisher); + rule.setNotifyOn(Set.of(NotificationGroup.NEW_VULNERABILITIES_SUMMARY)); + rule.setProjects(List.of(project)); + rule.setScheduleCron("* * * * *"); + rule.setScheduleLastTriggeredAt(Date.from(ruleLastFiredAt)); + rule.updateScheduleNextTriggerAt(); + rule.setEnabled(true); + rule.setFilterExpression("\"CRITICAL\" in subject.overview.new_vulnerabilities_count_by_severity"); + + final var dexEngine = mock(DexEngine.class); + doReturn(UUID.randomUUID()).when(dexEngine).createRun(any(CreateWorkflowRunRequest.class)); + + final var activity = new ProcessScheduledNotificationRuleActivity( + dexEngine, mock(FileStorage.class), Integer.MAX_VALUE); + activity.execute( + mock(ActivityContext.class), + ProcessScheduledNotificationRuleArg.newBuilder() + .setRuleName(rule.getName()) + .build()); + + verify(dexEngine).createRun(any(CreateWorkflowRunRequest.class)); + } + + @Test + void shouldNotDispatchWhenFilterExpressionDoesNotMatch() { + final Instant ruleLastFiredAt = Instant.now().minus(10, ChronoUnit.MINUTES); + final Instant afterRuleLastFiredAt = ruleLastFiredAt.plus(5, ChronoUnit.MINUTES); + + final var vuln = new Vulnerability(); + vuln.setVulnId("INT-001"); + vuln.setSource(Vulnerability.Source.INTERNAL); + vuln.setSeverity(Severity.LOW); + qm.persist(vuln); + + final var project = new Project(); + project.setName("acme-app"); + qm.persist(project); + final var component = new Component(); + component.setProject(project); + component.setName("acme-lib"); + qm.persist(component); + qm.addVulnerability(vuln, component, "internal", + null, null, Date.from(afterRuleLastFiredAt)); + + final var publisher = qm.createNotificationPublisher( + "foo", null, "webhook", "template", "templateMimeType", false); + final NotificationRule rule = qm.createScheduledNotificationRule( + "foo", NotificationScope.PORTFOLIO, NotificationLevel.INFORMATIONAL, publisher); + rule.setNotifyOn(Set.of(NotificationGroup.NEW_VULNERABILITIES_SUMMARY)); + rule.setProjects(List.of(project)); + rule.setScheduleCron("* * * * *"); + rule.setScheduleLastTriggeredAt(Date.from(ruleLastFiredAt)); + rule.updateScheduleNextTriggerAt(); + rule.setEnabled(true); + rule.setFilterExpression("\"CRITICAL\" in subject.overview.new_vulnerabilities_count_by_severity"); + + final var dexEngine = mock(DexEngine.class); + + final var activity = new ProcessScheduledNotificationRuleActivity( + dexEngine, mock(FileStorage.class), Integer.MAX_VALUE); + activity.execute( + mock(ActivityContext.class), + ProcessScheduledNotificationRuleArg.newBuilder() + .setRuleName(rule.getName()) + .build()); + + verifyNoInteractions(dexEngine); + + // Schedule should still be advanced. + qm.getPersistenceManager().evictAll(); + final NotificationRule updatedRule = qm.getObjectByUuid(NotificationRule.class, rule.getUuid()); + assertThat(updatedRule.getScheduleLastTriggeredAt()) + .isAfterOrEqualTo(Date.from(ruleLastFiredAt.truncatedTo(ChronoUnit.SECONDS))); + } + + @Test + void shouldFailOpenOnInvalidFilterExpression() { + final Instant ruleLastFiredAt = Instant.now().minus(10, ChronoUnit.MINUTES); + final Instant afterRuleLastFiredAt = ruleLastFiredAt.plus(5, ChronoUnit.MINUTES); + + final var vuln = new Vulnerability(); + vuln.setVulnId("INT-001"); + vuln.setSource(Vulnerability.Source.INTERNAL); + vuln.setSeverity(Severity.LOW); + qm.persist(vuln); + + final var project = new Project(); + project.setName("acme-app"); + qm.persist(project); + final var component = new Component(); + component.setProject(project); + component.setName("acme-lib"); + qm.persist(component); + qm.addVulnerability(vuln, component, "internal", + null, null, Date.from(afterRuleLastFiredAt)); + + final var publisher = qm.createNotificationPublisher( + "foo", null, "webhook", "template", "templateMimeType", false); + final NotificationRule rule = qm.createScheduledNotificationRule( + "foo", NotificationScope.PORTFOLIO, NotificationLevel.INFORMATIONAL, publisher); + rule.setNotifyOn(Set.of(NotificationGroup.NEW_VULNERABILITIES_SUMMARY)); + rule.setProjects(List.of(project)); + rule.setScheduleCron("* * * * *"); + rule.setScheduleLastTriggeredAt(Date.from(ruleLastFiredAt)); + rule.updateScheduleNextTriggerAt(); + rule.setEnabled(true); + rule.setFilterExpression("this is not valid CEL"); + + final var dexEngine = mock(DexEngine.class); + doReturn(UUID.randomUUID()).when(dexEngine).createRun(any(CreateWorkflowRunRequest.class)); + + final var activity = new ProcessScheduledNotificationRuleActivity( + dexEngine, mock(FileStorage.class), Integer.MAX_VALUE); + activity.execute( + mock(ActivityContext.class), + ProcessScheduledNotificationRuleArg.newBuilder() + .setRuleName(rule.getName()) + .build()); + + verify(dexEngine).createRun(any(CreateWorkflowRunRequest.class)); + } + + } + } diff --git a/apiserver/src/test/java/org/dependencytrack/resources/v1/NotificationRuleResourceTest.java b/apiserver/src/test/java/org/dependencytrack/resources/v1/NotificationRuleResourceTest.java index 18001bc033..69291615ff 100644 --- a/apiserver/src/test/java/org/dependencytrack/resources/v1/NotificationRuleResourceTest.java +++ b/apiserver/src/test/java/org/dependencytrack/resources/v1/NotificationRuleResourceTest.java @@ -750,4 +750,174 @@ void updateNotificationRuleWithTagsTest() { } """); } + + @Test + void shouldUpdateNotificationRuleWithValidFilterExpression() { + initializeWithPermissions( + Permissions.SYSTEM_CONFIGURATION_CREATE, + Permissions.SYSTEM_CONFIGURATION_UPDATE); + + Response response = jersey + .target(V1_NOTIFICATION_RULE) + .request() + .header(X_API_KEY, apiKey) + .put(Entity.json(/* language=JSON */ """ + { + "name": "Rule 1", + "notificationLevel": "INFORMATIONAL", + "scope": "PORTFOLIO", + "publisher": { + "uuid": "%s" + } + } + """.formatted(publisher.getUuid()))); + assertThat(response.getStatus()).isEqualTo(201); + + final JsonObjectBuilder ruleJson = Json.createObjectBuilder(parseJsonObject(response)); + ruleJson.add("filterExpression", "group == 1"); + + response = jersey + .target(V1_NOTIFICATION_RULE) + .request() + .header(X_API_KEY, apiKey) + .post(Entity.json(ruleJson.build().toString())); + assertThat(response.getStatus()).isEqualTo(200); + assertThatJson(getPlainTextBody(response)).isEqualTo(/* language=JSON */ """ + { + "name": "Rule 1", + "enabled": true, + "notifyChildren": true, + "logSuccessfulPublish": false, + "scope": "PORTFOLIO", + "notificationLevel": "INFORMATIONAL", + "projects": [], + "tags": [], + "teams": [], + "notifyOn": [], + "publisher": { + "name": "Slack", + "description": "description", + "extensionName": "slack", + "templateMimeType": "templateMimeType", + "defaultPublisher": true, + "uuid": "${json-unit.any-string}" + }, + "publisherConfig": "${json-unit.any-string}", + "triggerType": "EVENT", + "filterExpression": "group == 1", + "uuid": "${json-unit.any-string}" + } + """); + } + + @Test + void shouldReturnBadRequestWhenFilterExpressionIsInvalid() { + initializeWithPermissions( + Permissions.SYSTEM_CONFIGURATION_CREATE, + Permissions.SYSTEM_CONFIGURATION_UPDATE); + + Response response = jersey + .target(V1_NOTIFICATION_RULE) + .request() + .header(X_API_KEY, apiKey) + .put(Entity.json(/* language=JSON */ """ + { + "name": "Rule 1", + "notificationLevel": "INFORMATIONAL", + "scope": "PORTFOLIO", + "publisher": { + "uuid": "%s" + } + } + """.formatted(publisher.getUuid()))); + assertThat(response.getStatus()).isEqualTo(201); + + final JsonObjectBuilder ruleJson = Json.createObjectBuilder(parseJsonObject(response)); + ruleJson.add("filterExpression", "invalid %%% expression"); + + response = jersey + .target(V1_NOTIFICATION_RULE) + .request() + .header(X_API_KEY, apiKey) + .post(Entity.json(ruleJson.build().toString())); + assertThat(response.getStatus()).isEqualTo(400); + assertThatJson(getPlainTextBody(response)).isEqualTo(/* language=JSON */ """ + { + "status": 400, + "title": "Bad Request", + "detail": "Filter expression is invalid", + "errors": [ + { + "line": 1, + "column": 9, + "message": "${json-unit.any-string}" + }, + { + "line": 1, + "column": 10, + "message": "${json-unit.any-string}" + } + ] + } + """); + } + + @Test + void shouldUpdateNotificationRuleWithNullFilterExpression() { + initializeWithPermissions( + Permissions.SYSTEM_CONFIGURATION_CREATE, + Permissions.SYSTEM_CONFIGURATION_UPDATE); + + Response response = jersey + .target(V1_NOTIFICATION_RULE) + .request() + .header(X_API_KEY, apiKey) + .put(Entity.json(/* language=JSON */ """ + { + "name": "Rule 1", + "notificationLevel": "INFORMATIONAL", + "scope": "PORTFOLIO", + "publisher": { + "uuid": "%s" + } + } + """.formatted(publisher.getUuid()))); + assertThat(response.getStatus()).isEqualTo(201); + + final JsonObjectBuilder ruleJson = Json.createObjectBuilder(parseJsonObject(response)); + ruleJson.addNull("filterExpression"); + + response = jersey + .target(V1_NOTIFICATION_RULE) + .request() + .header(X_API_KEY, apiKey) + .post(Entity.json(ruleJson.build().toString())); + assertThat(response.getStatus()).isEqualTo(200); + assertThatJson(getPlainTextBody(response)).isEqualTo(/* language=JSON */ """ + { + "name": "Rule 1", + "enabled": true, + "notifyChildren": true, + "logSuccessfulPublish": false, + "scope": "PORTFOLIO", + "notificationLevel": "INFORMATIONAL", + "projects": [], + "tags": [], + "teams": [], + "notifyOn": [], + "publisher": { + "name": "Slack", + "description": "description", + "extensionName": "slack", + "templateMimeType": "templateMimeType", + "defaultPublisher": true, + "uuid": "${json-unit.any-string}" + }, + "publisherConfig": "${json-unit.any-string}", + "triggerType": "EVENT", + "uuid": "${json-unit.any-string}" + } + """); + } + } diff --git a/migration/src/main/resources/migration/changelog-v5.7.0.xml b/migration/src/main/resources/migration/changelog-v5.7.0.xml index a814452ef3..0fdeee7674 100644 --- a/migration/src/main/resources/migration/changelog-v5.7.0.xml +++ b/migration/src/main/resources/migration/changelog-v5.7.0.xml @@ -1939,4 +1939,10 @@ ALTER TABLE "VULNERABILITY_POLICY" DROP COLUMN "CONDITIONS"; + + + + + +