Skip to content

Commit 2696a44

Browse files
committed
Allow notifications to be filtered using CEL expressions
Enables users to filter notifications before they're dispatched. A common use case being to filter `NEW_VULNERABILITY` notifications for vulnerabilities whose severity are below a given threshold (e.g. `CRITICAL`). Implementing this using CEL expressions keeps the solution both simple to support, and easy to extend: Users can access all fields in the notification, and we don't need to make any changes when we add new notification fields. CEL evaluation is fast and safe, so this is a perfect match. Signed-off-by: nscuro <nscuro@protonmail.com>
1 parent 34813c7 commit 2696a44

17 files changed

Lines changed: 965 additions & 38 deletions

apiserver/src/main/java/org/dependencytrack/model/NotificationRule.java

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -209,6 +209,11 @@ public class NotificationRule implements Serializable {
209209
Must not be set for rules with trigger type EVENT.""")
210210
private Boolean scheduleSkipUnchanged;
211211

212+
@Persistent
213+
@Column(name = "FILTER_EXPRESSION", allowsNull = "true")
214+
@JsonDeserialize(using = TrimmedStringDeserializer.class)
215+
private String filterExpression;
216+
212217
@Persistent(defaultFetchGroup = "true", customValueStrategy = "uuid")
213218
@Unique(name = "NOTIFICATIONRULE_UUID_IDX")
214219
@Column(name = "UUID", sqlType = "UUID", allowsNull = "false")
@@ -342,6 +347,14 @@ public void setPublisherConfig(String publisherConfig) {
342347
this.publisherConfig = publisherConfig;
343348
}
344349

350+
public String getFilterExpression() {
351+
return filterExpression;
352+
}
353+
354+
public void setFilterExpression(String filterExpression) {
355+
this.filterExpression = filterExpression;
356+
}
357+
345358
@NotNull
346359
public UUID getUuid() {
347360
return uuid;
Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
/*
2+
* This file is part of Dependency-Track.
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*
16+
* SPDX-License-Identifier: Apache-2.0
17+
* Copyright (c) OWASP Foundation. All Rights Reserved.
18+
*/
19+
package org.dependencytrack.notification;
20+
21+
import org.projectnessie.cel.common.CELError;
22+
23+
import java.util.List;
24+
25+
/**
26+
* @since 5.7.0
27+
*/
28+
public final class InvalidNotificationFilterExpressionException extends RuntimeException {
29+
30+
public record Error(int line, int column, String message) {
31+
}
32+
33+
private final List<Error> errors;
34+
35+
public InvalidNotificationFilterExpressionException(String message, List<CELError> celErrors) {
36+
super(message);
37+
this.errors = celErrors.stream()
38+
.map(e -> new Error(e.getLocation().line(), e.getLocation().column(), e.getMessage()))
39+
.toList();
40+
}
41+
42+
public List<Error> getErrors() {
43+
return errors;
44+
}
45+
46+
}
Lines changed: 144 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,144 @@
1+
/*
2+
* This file is part of Dependency-Track.
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*
16+
* SPDX-License-Identifier: Apache-2.0
17+
* Copyright (c) OWASP Foundation. All Rights Reserved.
18+
*/
19+
package org.dependencytrack.notification;
20+
21+
import com.github.benmanes.caffeine.cache.Cache;
22+
import com.github.benmanes.caffeine.cache.Caffeine;
23+
import org.apache.commons.codec.digest.DigestUtils;
24+
import org.dependencytrack.notification.proto.v1.BomConsumedOrProcessedSubject;
25+
import org.dependencytrack.notification.proto.v1.BomProcessingFailedSubject;
26+
import org.dependencytrack.notification.proto.v1.BomValidationFailedSubject;
27+
import org.dependencytrack.notification.proto.v1.NewPolicyViolationsSummarySubject;
28+
import org.dependencytrack.notification.proto.v1.NewVulnerabilitiesSummarySubject;
29+
import org.dependencytrack.notification.proto.v1.NewVulnerabilitySubject;
30+
import org.dependencytrack.notification.proto.v1.NewVulnerableDependencySubject;
31+
import org.dependencytrack.notification.proto.v1.Notification;
32+
import org.dependencytrack.notification.proto.v1.PolicyViolationAnalysisDecisionChangeSubject;
33+
import org.dependencytrack.notification.proto.v1.PolicyViolationSubject;
34+
import org.dependencytrack.notification.proto.v1.ProjectVulnAnalysisCompleteSubject;
35+
import org.dependencytrack.notification.proto.v1.UserSubject;
36+
import org.dependencytrack.notification.proto.v1.VexConsumedOrProcessedSubject;
37+
import org.dependencytrack.notification.proto.v1.VulnerabilityAnalysisDecisionChangeSubject;
38+
import org.dependencytrack.notification.proto.v1.VulnerabilityRetractedSubject;
39+
import org.jspecify.annotations.Nullable;
40+
import org.projectnessie.cel.Env;
41+
import org.projectnessie.cel.Env.AstIssuesTuple;
42+
import org.projectnessie.cel.EnvOption;
43+
import org.projectnessie.cel.Library;
44+
import org.projectnessie.cel.Program;
45+
import org.projectnessie.cel.checker.Decls;
46+
import org.projectnessie.cel.common.types.Err;
47+
import org.projectnessie.cel.common.types.pb.ProtoTypeRegistry;
48+
import org.projectnessie.cel.common.types.ref.Val;
49+
import org.projectnessie.cel.extension.StringsLib;
50+
51+
import java.util.HashMap;
52+
import java.util.List;
53+
import java.util.concurrent.TimeUnit;
54+
55+
/**
56+
* @since 5.7.0
57+
*/
58+
public final class NotificationFilterScriptHost {
59+
60+
private static final NotificationFilterScriptHost INSTANCE = new NotificationFilterScriptHost();
61+
62+
private final Cache<String, Program> cache;
63+
private final Env environment;
64+
65+
private NotificationFilterScriptHost() {
66+
this.cache = Caffeine.newBuilder()
67+
.maximumSize(256)
68+
.expireAfterAccess(1, TimeUnit.HOURS)
69+
.build();
70+
this.environment = Env.newCustomEnv(
71+
ProtoTypeRegistry.newRegistry(
72+
Notification.getDefaultInstance(),
73+
BomConsumedOrProcessedSubject.getDefaultInstance(),
74+
BomProcessingFailedSubject.getDefaultInstance(),
75+
BomValidationFailedSubject.getDefaultInstance(),
76+
NewPolicyViolationsSummarySubject.getDefaultInstance(),
77+
NewVulnerabilitiesSummarySubject.getDefaultInstance(),
78+
NewVulnerabilitySubject.getDefaultInstance(),
79+
NewVulnerableDependencySubject.getDefaultInstance(),
80+
PolicyViolationSubject.getDefaultInstance(),
81+
PolicyViolationAnalysisDecisionChangeSubject.getDefaultInstance(),
82+
VulnerabilityAnalysisDecisionChangeSubject.getDefaultInstance(),
83+
ProjectVulnAnalysisCompleteSubject.getDefaultInstance(),
84+
VexConsumedOrProcessedSubject.getDefaultInstance(),
85+
VulnerabilityRetractedSubject.getDefaultInstance(),
86+
UserSubject.getDefaultInstance()),
87+
List.of(
88+
Library.StdLib(),
89+
Library.Lib(new StringsLib()),
90+
EnvOption.container("org.dependencytrack.notification.v1"),
91+
EnvOption.declarations(
92+
Decls.newVar("level", Decls.Int),
93+
Decls.newVar("scope", Decls.Int),
94+
Decls.newVar("group", Decls.Int),
95+
Decls.newVar("title", Decls.String),
96+
Decls.newVar("content", Decls.String),
97+
Decls.newVar("timestamp", Decls.Timestamp),
98+
Decls.newVar("subject", Decls.Dyn))));
99+
}
100+
101+
public static NotificationFilterScriptHost getInstance() {
102+
return INSTANCE;
103+
}
104+
105+
public Program compile(String expressionSrc) {
106+
return cache.get(DigestUtils.sha256Hex(expressionSrc), key -> {
107+
AstIssuesTuple astIssuesTuple = environment.parse(expressionSrc);
108+
if (astIssuesTuple.hasIssues()) {
109+
throw new InvalidNotificationFilterExpressionException(
110+
"Failed to parse expression",
111+
astIssuesTuple.getIssues().getErrors());
112+
}
113+
114+
astIssuesTuple = environment.check(astIssuesTuple.getAst());
115+
if (astIssuesTuple.hasIssues()) {
116+
throw new InvalidNotificationFilterExpressionException(
117+
"Failed to check expression",
118+
astIssuesTuple.getIssues().getErrors());
119+
}
120+
121+
return environment.program(astIssuesTuple.getAst());
122+
});
123+
}
124+
125+
public boolean evaluate(Program program, Notification notification, @Nullable Object subject) {
126+
final var args = new HashMap<String, @Nullable Object>(7);
127+
args.put("level", notification.getLevelValue());
128+
args.put("scope", notification.getScopeValue());
129+
args.put("group", notification.getGroupValue());
130+
args.put("title", notification.getTitle());
131+
args.put("content", notification.getContent());
132+
args.put("timestamp", notification.getTimestamp());
133+
args.put("subject", subject);
134+
135+
final Val result = program.eval(args).getVal();
136+
137+
if (Err.isError(result)) {
138+
throw new IllegalStateException("CEL evaluation failed: " + result);
139+
}
140+
141+
return result.convertToNative(Boolean.class);
142+
}
143+
144+
}

0 commit comments

Comments
 (0)