Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -156,6 +156,11 @@ public class NotificationRule implements Serializable {
@JsonDeserialize(using = TrimmedStringDeserializer.class)
private String publisherConfig;

@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")
Expand Down Expand Up @@ -289,6 +294,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;
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,140 @@
/*
* 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.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
*/
final class NotificationCelScriptHost {

private static final NotificationCelScriptHost INSTANCE = new NotificationCelScriptHost();

private final Cache<String, Program> cache;
private final Env environment;

NotificationCelScriptHost() {
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(),
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))));
}

static NotificationCelScriptHost getInstance() {
return INSTANCE;
}

Program compile(String expressionSrc) {
return cache.get(DigestUtils.sha256Hex(expressionSrc), key -> {
AstIssuesTuple astIssuesTuple = environment.parse(expressionSrc);
if (astIssuesTuple.hasIssues()) {
throw new IllegalStateException(
"Failed to parse expression: " + astIssuesTuple.getIssues());
}

astIssuesTuple = environment.check(astIssuesTuple.getAst());
if (astIssuesTuple.hasIssues()) {
throw new IllegalStateException(
"Failed to check expression: " + astIssuesTuple.getIssues());
}

return environment.program(astIssuesTuple.getAst());
});
}

boolean evaluate(Program program, Notification notification, @Nullable Object subject) {
final var args = new HashMap<String, Object>(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());
if (subject != null) {
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);
}

}
Original file line number Diff line number Diff line change
Expand Up @@ -33,13 +33,15 @@
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;
import org.jdbi.v3.core.Handle;
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;
Expand All @@ -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
Expand Down Expand Up @@ -156,7 +157,8 @@ public record RuleQueryResult(
String name,
boolean isNotifyChildProjects,
@Nullable Set<String> limitToProjectUuids,
@Nullable Set<String> limitToTagNames) {
@Nullable Set<String> limitToTagNames,
@Nullable String filterExpression) {

private boolean isLimitedToProjects() {
return limitToProjectUuids != null && !limitToProjectUuids.isEmpty();
Expand All @@ -166,6 +168,10 @@ private boolean isLimitedToTags() {
return limitToTagNames != null && !limitToTagNames.isEmpty();
}

private boolean hasFilterExpression() {
return filterExpression != null && !filterExpression.isBlank();
}

}

private Map<Notification, List<RuleQueryResult>> queryRules(Collection<Notification> notifications) {
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -239,16 +246,13 @@ AS t(index, scope, level, "group")
private List<RuleQueryResult> maybeFilterRules(
Notification notification,
List<RuleQueryResult> 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<RuleQueryResult>(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 {
Expand All @@ -260,19 +264,34 @@ private List<RuleQueryResult> 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());

Expand Down Expand Up @@ -312,46 +331,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) {
return true;
}

final var scriptHost = NotificationCelScriptHost.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 (IllegalStateException 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) {
Expand Down
Loading
Loading