diff --git a/docs/_docs/integrations/notifications.md b/docs/_docs/integrations/notifications.md index 28c94ed297..b7ac21f48f 100644 --- a/docs/_docs/integrations/notifications.md +++ b/docs/_docs/integrations/notifications.md @@ -61,16 +61,18 @@ multiple levels, while others can only ever have a single level. 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 : -| Publisher | Description | -|------------|-----------------------------------------------------| -| Slack | Publishes notifications to Slack channels | -| Teams | Publishes notifications to Microsoft Teams channels | -| Mattermost | Publishes notifications to Mattermost channels | -| WebEx | Publishes notifications to Cisco WebEx channels | -| Webhook | Publishes notifications to a configurable endpoint | -| Email | Sends notifications to an email address | -| Console | Displays notifications on the system console | -| Jira | Publishes notifications to Jira | +| Publisher | Description | +| ----------------- | ---------------------------------------------------------------------------------------------- | +| Slack | Publishes notifications to Slack channels | +| Teams | Publishes notifications to Microsoft Teams channels | +| Mattermost | Publishes notifications to Mattermost channels | +| WebEx | Publishes notifications to Cisco WebEx channels | +| Webhook | Publishes notifications to a configurable endpoint | +| Email | Sends notifications to an email address | +| Console | Displays notifications on the system console | +| Jira | Publishes notifications to Jira | +| Scheduled Email | Sends a summary of all subscribed events since last notification to an email address | +| Scheduled Console | Displays a slim summary of all subscribed events since last notification to the system console | ### Templating @@ -96,7 +98,7 @@ The template context is enhanced with the following variables : > subject will be present at all times. Some fields are optional since the underlying fields in the datamodel are optional. > The section below will describe the portfolio notifications in JSON format. -#### NEW_VULNERABILITY +#### NEW_VULNERABILITY (per event) This type of notification will always contain: * 1 component * 1 vulnerability @@ -160,6 +162,96 @@ This type of notification will always contain: > The `cwe` field is deprecated and will be removed in a later version. Please use `cwes` instead. +#### NEW_VULNERABILITY (scheduled summary) + +```json +{ + "notification": { + "level": "INFORMATIONAL", + "scope": "PORTFOLIO", + "group": "NEW_VULNERABILITY", + "timestamp": "2024-05-16T23:26:22.961", + "title": "123 new Vulnerabilities in 45 components in Scheduled Rule 'ABC'", + "content": "Find below a summary of new vulnerabilities since 2024-05-16T00:00:00Z in Scheduled Notification Rule 'ABC'.", + "subject": { + "overview": { + "affectedProjectsCount": 7, + "newVulnerabilitiesCount": 123, + "affectedComponentsCount": 45, + "suppressedNewVulnerabilitiesCount": 0, + "newVulnerabilitiesBySeverity": { + "CRITICAL": 13, + "HIGH": 24, + "MEDIUM": 56, + "LOW": 10, + "INFO": 17, + "UNASSIGNED": 3 + } + }, + "summary": { + "projectSummaries": [ + { + "project": { + "uuid": "6fb1820f-5280-4577-ac51-40124aabe307", + "name": "Acme Example", + "version": "1.0.0" + }, + "summary": { + "newVulnerabilitiesBySeverity": { + "CRITICAL": 3, + "HIGH": 4, + "LOW": 2, + "INFO": 7 + }, + "totalProjectVulnerabilitiesBySeverity": { + "CRITICAL": 35, + "HIGH": 57, + "MEDIUM": 13, + "LOW": 105, + "INFO": 23, + "UNASSIGNED": 13 + }, + "suppressedNewVulnerabilitiesBySeverity": { + "HIGH": 2, + "LOW": 5, + "INFO": 1 + } + } + } + ] + }, + "details": { + "projectDetails": [ + { + "project": { + "uuid": "6fb1820f-5280-4577-ac51-40124aabe307", + "name": "Acme Example", + "version": "1.0.0" + }, + "findings": [ + { + "componentUuid": "4d0da61c-b462-4895-b296-da0b4bb34744", + "componentName": "axis", + "componentVersion": "1.4", + "componentGroup": "apache", + "vulnerabilitySource": "NVD", + "vulnerabilityId": "CVE-2012-5784", + "vulnerabilitySeverity": "MEDIUM", + "analyzer": "OSSINDEX_ANALYZER", + "attributionReferenceUrl": "https://ossindex.sonatype.org/vulnerability/CVE-2012-5784", + "attributedOn": "2024-05-16T12:34:39Z", + "analysisState": "IN_TRIAGE", + "suppressed": false + } + ] + } + ] + } + } + } +} +``` + #### NEW_VULNERABLE_DEPENDENCY This type of notification will always contain: * 1 project @@ -324,7 +416,7 @@ This type of notification will always contain: } ``` -#### POLICY_VIOLATION +#### POLICY_VIOLATION (per event) ```json { @@ -368,6 +460,93 @@ This type of notification will always contain: } ``` +#### POLICY_VIOLATION (scheduled summary) + +```json +{ + "notification": { + "level": "INFORMATIONAL", + "scope": "PORTFOLIO", + "group": "POLICY_VIOLATION", + "timestamp": "2022-05-12T23:07:59.611303", + "title": "2 new Policy Violations in 2 components in Scheduled Rule 'Policy Guard'", + "content": "Find below a summary of new policy violations since 2022-05-12T00:00:00Z in Scheduled Notification Rule 'Policy Guard'.", + "subject": { + "overview": { + "affectedProjectsCount": 1, + "newViolationsCount": 2, + "affectedComponentsCount": 2, + "suppressedNewViolationsCount": 0, + "newViolationsByRiskType": { + "LICENSE": 0, + "SECURITY": 0, + "OPERATIONAL": 2 + } + }, + "summary": { + "affectedProjectSummaries": [ + { + "project": { + "uuid": "7a36e5c0-9f09-42dd-b401-360da56c2abe", + "name": "Acme Example", + "version": "1.0.0" + }, + "summary": { + "newViolationsByRiskType": { + "OPERATIONAL": 2 + }, + "totalProjectViolationsByRiskType": { + "LICENSE": 5, + "OPERATIONAL": 2 + }, + "suppressedNewViolationsByRiskType": { + } + } + } + ] + } + "details": { + "projectDetails": [ + { + "project": { + "uuid": "7a36e5c0-9f09-42dd-b401-360da56c2abe", + "name": "Acme Example", + "version": "1.0.0" + }, + "violations": [ + { + "component": { + "uuid": "4e04c695-9acd-46fc-9bf6-ed23d7eb551e", + "group": "apache", + "name": "axis", + "version": "1.4" + }, + "violation": { + "uuid": "c82fcb50-029a-4636-a657-96242b20680e", + "type": "OPERATIONAL", + "timestamp": "2022-05-12T20:34:46Z", + "policyCondition": { + "uuid": "8e5c0a5b-71fb-45c5-afac-6c6a99742cbe", + "subject": "COORDINATES", + "operator": "MATCHES", + "value": "{\"group\":\"apache\",\"name\":\"axis\",\"version\":\"*\"}", + "policy": { + "uuid": "6d4c7398-689a-4ec7-b5c5-9abb6b5393e9", + "name": "Banned Components", + "violationState": "FAIL" + } + } + } + } + ] + } + ] + } + } + } +} +``` + #### USER_CREATED ```json diff --git a/pom.xml b/pom.xml index 2c325d6b23..ed45be832d 100644 --- a/pom.xml +++ b/pom.xml @@ -100,6 +100,7 @@ 1.4.2 1.0.1 9.1.0 + 3.0.2 2.1.0 2.18.0 2.18.0 @@ -114,17 +115,20 @@ 3.2.2 4.28.3 2.2.0 + 2.2.25 2.1.22 1.19.0 1.20.3 2.35.2 7.0.0 + 4.13.2 1.1.1 2.1.1 4.5.14 5.4 2.0.16 1.323 + 1.4.0 12.8.1.jre11 8.2.0 @@ -216,6 +220,12 @@ provided + + jakarta.validation + jakarta.validation-api + ${lib.jakarta-validation.version} + + com.github.package-url packageurl-java @@ -411,6 +421,12 @@ ${lib.org-kohsuke-github-api.version} + + com.asahaf.javacron + javacron + ${lib.com-asahaf-javacron.version} + + junit diff --git a/src/main/java/org/dependencytrack/model/ConfigPropertyConstants.java b/src/main/java/org/dependencytrack/model/ConfigPropertyConstants.java index db989d7489..5482da61cb 100644 --- a/src/main/java/org/dependencytrack/model/ConfigPropertyConstants.java +++ b/src/main/java/org/dependencytrack/model/ConfigPropertyConstants.java @@ -98,6 +98,7 @@ public enum ConfigPropertyConstants { ACCESS_MANAGEMENT_ACL_ENABLED("access-management", "acl.enabled", "false", PropertyType.BOOLEAN, "Flag to enable/disable access control to projects in the portfolio", true), NOTIFICATION_TEMPLATE_BASE_DIR("notification", "template.baseDir", SystemUtils.getEnvironmentVariable("DEFAULT_TEMPLATES_OVERRIDE_BASE_DIRECTORY", System.getProperty("user.home")), PropertyType.STRING, "The base directory to use when searching for notification templates"), NOTIFICATION_TEMPLATE_DEFAULT_OVERRIDE_ENABLED("notification", "template.default.override.enabled", SystemUtils.getEnvironmentVariable("DEFAULT_TEMPLATES_OVERRIDE_ENABLED", "false"), PropertyType.BOOLEAN, "Flag to enable/disable override of default notification templates"), + NOTIFICATION_CRON_DEFAULT_EXPRESSION("notification", "cron.default.expression", SystemUtils.getEnvironmentVariable("DEFAULT_SCHEDULED_CRON_EXPRESSION", "0 12 * * *"), PropertyType.STRING, "The default interval of scheduled notifications as cron expression"), TASK_SCHEDULER_LDAP_SYNC_CADENCE("task-scheduler", "ldap.sync.cadence", "6", PropertyType.INTEGER, "Sync cadence (in hours) for LDAP"), TASK_SCHEDULER_GHSA_MIRROR_CADENCE("task-scheduler", "ghsa.mirror.cadence", "24", PropertyType.INTEGER, "Mirror cadence (in hours) for Github Security Advisories"), TASK_SCHEDULER_OSV_MIRROR_CADENCE("task-scheduler", "osv.mirror.cadence", "24", PropertyType.INTEGER, "Mirror cadence (in hours) for OSV database"), diff --git a/src/main/java/org/dependencytrack/model/Finding.java b/src/main/java/org/dependencytrack/model/Finding.java index 13867227b2..0a8475813a 100644 --- a/src/main/java/org/dependencytrack/model/Finding.java +++ b/src/main/java/org/dependencytrack/model/Finding.java @@ -104,6 +104,105 @@ public class Finding implements Serializable { AND (:includeSuppressed = :true OR "ANALYSIS"."SUPPRESSED" IS NULL OR "ANALYSIS"."SUPPRESSED" = :false) """; + // language=SQL + public static final String QUERY_SINCE_ATTRIBUTION = """ + SELECT "COMPONENT"."UUID" + , "COMPONENT"."NAME" + , "COMPONENT"."GROUP" + , "COMPONENT"."VERSION" + , "COMPONENT"."PURL" + , "COMPONENT"."CPE" + , "VULNERABILITY"."UUID" + , "VULNERABILITY"."SOURCE" + , "VULNERABILITY"."VULNID" + , "VULNERABILITY"."TITLE" + , "VULNERABILITY"."SUBTITLE" + , "VULNERABILITY"."DESCRIPTION" + , "VULNERABILITY"."RECOMMENDATION" + , "VULNERABILITY"."SEVERITY" + , "VULNERABILITY"."CVSSV2BASESCORE" + , "VULNERABILITY"."CVSSV3BASESCORE" + , "VULNERABILITY"."OWASPRRLIKELIHOODSCORE" + , "VULNERABILITY"."OWASPRRTECHNICALIMPACTSCORE" + , "VULNERABILITY"."OWASPRRBUSINESSIMPACTSCORE" + , "VULNERABILITY"."EPSSSCORE" + , "VULNERABILITY"."EPSSPERCENTILE" + , "VULNERABILITY"."CWES" + , "FINDINGATTRIBUTION"."ANALYZERIDENTITY" + , "FINDINGATTRIBUTION"."ATTRIBUTED_ON" + , "FINDINGATTRIBUTION"."ALT_ID" + , "FINDINGATTRIBUTION"."REFERENCE_URL" + , "ANALYSIS"."STATE" + , "ANALYSIS"."SUPPRESSED" + FROM "COMPONENT" + INNER JOIN "COMPONENTS_VULNERABILITIES" + ON "COMPONENT"."ID" = "COMPONENTS_VULNERABILITIES"."COMPONENT_ID" + INNER JOIN "VULNERABILITY" + ON "COMPONENTS_VULNERABILITIES"."VULNERABILITY_ID" = "VULNERABILITY"."ID" + INNER JOIN "FINDINGATTRIBUTION" + ON "COMPONENT"."ID" = "FINDINGATTRIBUTION"."COMPONENT_ID" + AND "VULNERABILITY"."ID" = "FINDINGATTRIBUTION"."VULNERABILITY_ID" + LEFT JOIN "ANALYSIS" + ON "COMPONENT"."ID" = "ANALYSIS"."COMPONENT_ID" + AND "VULNERABILITY"."ID" = "ANALYSIS"."VULNERABILITY_ID" + AND "COMPONENT"."PROJECT_ID" = "ANALYSIS"."PROJECT_ID" + WHERE "COMPONENT"."PROJECT_ID" = ? + AND (:includeSuppressed = :true OR "ANALYSIS"."SUPPRESSED" IS NULL OR "ANALYSIS"."SUPPRESSED" = :false) + AND "FINDINGATTRIBUTION"."ATTRIBUTED_ON" BETWEEN ? AND ? + """; + + // language=SQL + public static final String QUERY_ALL_FINDINGS_SINCE = """ + SELECT "COMPONENT"."UUID" + , "COMPONENT"."NAME" + , "COMPONENT"."GROUP" + , "COMPONENT"."VERSION" + , "COMPONENT"."PURL" + , "COMPONENT"."CPE" + , "VULNERABILITY"."UUID" + , "VULNERABILITY"."SOURCE" + , "VULNERABILITY"."VULNID" + , "VULNERABILITY"."TITLE" + , "VULNERABILITY"."SUBTITLE" + , "VULNERABILITY"."DESCRIPTION" + , "VULNERABILITY"."RECOMMENDATION" + , "VULNERABILITY"."SEVERITY" + , "VULNERABILITY"."CVSSV2BASESCORE" + , "VULNERABILITY"."CVSSV3BASESCORE" + , "VULNERABILITY"."OWASPRRLIKELIHOODSCORE" + , "VULNERABILITY"."OWASPRRTECHNICALIMPACTSCORE" + , "VULNERABILITY"."OWASPRRBUSINESSIMPACTSCORE" + , "VULNERABILITY"."EPSSSCORE" + , "VULNERABILITY"."EPSSPERCENTILE" + , "VULNERABILITY"."CWES" + , "FINDINGATTRIBUTION"."ANALYZERIDENTITY" + , "FINDINGATTRIBUTION"."ATTRIBUTED_ON" + , "FINDINGATTRIBUTION"."ALT_ID" + , "FINDINGATTRIBUTION"."REFERENCE_URL" + , "ANALYSIS"."STATE" + , "ANALYSIS"."SUPPRESSED" + , "VULNERABILITY"."PUBLISHED" + , "PROJECT"."UUID" + , "PROJECT"."NAME" + , "PROJECT"."VERSION" + FROM "COMPONENT" + INNER JOIN "COMPONENTS_VULNERABILITIES" + ON "COMPONENT"."ID" = "COMPONENTS_VULNERABILITIES"."COMPONENT_ID" + INNER JOIN "VULNERABILITY" + ON "COMPONENTS_VULNERABILITIES"."VULNERABILITY_ID" = "VULNERABILITY"."ID" + INNER JOIN "FINDINGATTRIBUTION" + ON "COMPONENT"."ID" = "FINDINGATTRIBUTION"."COMPONENT_ID" + AND "VULNERABILITY"."ID" = "FINDINGATTRIBUTION"."VULNERABILITY_ID" + LEFT JOIN "ANALYSIS" + ON "COMPONENT"."ID" = "ANALYSIS"."COMPONENT_ID" + AND "VULNERABILITY"."ID" = "ANALYSIS"."VULNERABILITY_ID" + AND "COMPONENT"."PROJECT_ID" = "ANALYSIS"."PROJECT_ID" + INNER JOIN "PROJECT" + ON "COMPONENT"."PROJECT_ID" = "PROJECT"."ID" + WHERE "FINDINGATTRIBUTION"."ATTRIBUTED_ON" BETWEEN :fromDateTime AND :toDateTime + AND (:includeSuppressed = :true OR "ANALYSIS"."SUPPRESSED" IS NULL OR "ANALYSIS"."SUPPRESSED" = :false) + """; + // language=SQL public static final String QUERY_ALL_FINDINGS = """ SELECT "COMPONENT"."UUID" @@ -154,7 +253,7 @@ public class Finding implements Serializable { ON "COMPONENT"."PROJECT_ID" = "PROJECT"."ID" """; - private final UUID project; + private final UUID projectUuid; private final Map component = new LinkedHashMap<>(); private final Map vulnerability = new LinkedHashMap<>(); private final Map analysis = new LinkedHashMap<>(); @@ -167,7 +266,7 @@ public class Finding implements Serializable { * @param o An array of values specific to an individual row returned from {@link #QUERY} or {@link #QUERY_ALL_FINDINGS} */ public Finding(UUID project, Object... o) { - this.project = project; + this.projectUuid = project; optValue(component, "uuid", o[0]); optValue(component, "name", o[1]); optValue(component, "group", o[2]); @@ -222,6 +321,10 @@ public Finding(UUID project, Object... o) { } } + public UUID getProjectUuid() { + return projectUuid; + } + public Map getComponent() { return component; } @@ -276,7 +379,7 @@ static List getCwes(final Object value) { } public String getMatrix() { - return project.toString() + ":" + component.get("uuid") + ":" + vulnerability.get("uuid"); + return projectUuid.toString() + ":" + component.get("uuid") + ":" + vulnerability.get("uuid"); } public void addVulnerabilityAliases(List aliases) { diff --git a/src/main/java/org/dependencytrack/model/NotificationPublisher.java b/src/main/java/org/dependencytrack/model/NotificationPublisher.java index d4016e3f39..689182cc5f 100644 --- a/src/main/java/org/dependencytrack/model/NotificationPublisher.java +++ b/src/main/java/org/dependencytrack/model/NotificationPublisher.java @@ -52,6 +52,7 @@ @Persistent(name = "template"), @Persistent(name = "templateMimeType"), @Persistent(name = "defaultPublisher"), + @Persistent(name = "publishScheduled"), @Persistent(name = "uuid"), }) }) @@ -108,6 +109,10 @@ public enum FetchGroup { @Column(name = "DEFAULT_PUBLISHER") private boolean defaultPublisher; + @Persistent(defaultFetchGroup = "true") + @Column(name = "PUBLISH_SCHEDULED") + private boolean publishScheduled; + @Persistent(defaultFetchGroup = "true", customValueStrategy = "uuid") @Unique(name = "NOTIFICATIONPUBLISHER_UUID_IDX") @Column(name = "UUID", jdbcType = "VARCHAR", length = 36, allowsNull = "false") @@ -173,6 +178,14 @@ public void setDefaultPublisher(boolean defaultPublisher) { this.defaultPublisher = defaultPublisher; } + public boolean isPublishScheduled(){ + return publishScheduled; + } + + public void setPublishScheduled(boolean publishScheduled){ + this.publishScheduled = publishScheduled; + } + @NotNull public UUID getUuid() { return uuid; diff --git a/src/main/java/org/dependencytrack/model/NotificationRule.java b/src/main/java/org/dependencytrack/model/NotificationRule.java index 9fdad4c536..5cdf76bfac 100644 --- a/src/main/java/org/dependencytrack/model/NotificationRule.java +++ b/src/main/java/org/dependencytrack/model/NotificationRule.java @@ -22,6 +22,7 @@ import alpine.model.Team; import alpine.notification.NotificationLevel; import alpine.server.json.TrimmedStringDeserializer; + import com.fasterxml.jackson.annotation.JsonIgnore; import com.fasterxml.jackson.annotation.JsonIgnoreProperties; import com.fasterxml.jackson.annotation.JsonInclude; @@ -61,7 +62,7 @@ @PersistenceCapable @JsonInclude(JsonInclude.Include.NON_NULL) @JsonIgnoreProperties(ignoreUnknown = true) -public class NotificationRule implements Serializable { +public class NotificationRule implements Rule, Serializable { private static final long serialVersionUID = 2534439091019367263L; diff --git a/src/main/java/org/dependencytrack/model/PublishTrigger.java b/src/main/java/org/dependencytrack/model/PublishTrigger.java new file mode 100644 index 0000000000..9e402e63ae --- /dev/null +++ b/src/main/java/org/dependencytrack/model/PublishTrigger.java @@ -0,0 +1,29 @@ +/* + * 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.model; + +/** + * Provides a list of available triggers for publishers to send notifications. + */ +public enum PublishTrigger { + ALL, + EVENT, + SCHEDULE, +} diff --git a/src/main/java/org/dependencytrack/model/Rule.java b/src/main/java/org/dependencytrack/model/Rule.java new file mode 100644 index 0000000000..e3ac5e3f81 --- /dev/null +++ b/src/main/java/org/dependencytrack/model/Rule.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.model; + +import java.util.List; +import java.util.Set; + +import org.dependencytrack.notification.NotificationGroup; +import org.dependencytrack.notification.NotificationScope; + +import alpine.model.Team; +import alpine.notification.NotificationLevel; + +public interface Rule { + public String getName(); + public boolean isEnabled(); + public boolean isNotifyChildren(); + public boolean isLogSuccessfulPublish(); + public NotificationScope getScope(); + public NotificationLevel getNotificationLevel(); + public NotificationPublisher getPublisher(); + public String getPublisherConfig(); + public Set getNotifyOn(); + public String getMessage(); + public List getProjects(); + public List getTeams(); +} diff --git a/src/main/java/org/dependencytrack/model/ScheduledNotificationRule.java b/src/main/java/org/dependencytrack/model/ScheduledNotificationRule.java new file mode 100644 index 0000000000..cca4238b20 --- /dev/null +++ b/src/main/java/org/dependencytrack/model/ScheduledNotificationRule.java @@ -0,0 +1,349 @@ +/* + * 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.model; + +import alpine.common.validation.RegexSequence; +import alpine.model.Team; +import alpine.notification.NotificationLevel; +import alpine.server.json.TrimmedStringDeserializer; + +import com.fasterxml.jackson.annotation.JsonIgnore; +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.databind.annotation.JsonDeserialize; +import com.fasterxml.jackson.databind.annotation.JsonSerialize; + +import javax.jdo.annotations.Column; +import javax.jdo.annotations.Element; +import javax.jdo.annotations.Extension; +import javax.jdo.annotations.IdGeneratorStrategy; +import javax.jdo.annotations.Join; +import javax.jdo.annotations.NotPersistent; +import javax.jdo.annotations.Order; +import javax.jdo.annotations.PersistenceCapable; +import javax.jdo.annotations.Persistent; +import javax.jdo.annotations.PrimaryKey; +import javax.jdo.annotations.Unique; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Pattern; +import jakarta.validation.constraints.Size; + +import org.apache.commons.collections4.CollectionUtils; +import org.dependencytrack.notification.NotificationGroup; +import org.dependencytrack.notification.NotificationScope; +import org.dependencytrack.resources.v1.serializers.Iso8601ZonedDateTimeSerializer; + +import java.io.Serializable; +import java.time.ZoneOffset; +import java.time.ZonedDateTime; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.Set; +import java.util.TreeSet; +import java.util.UUID; + +/** + * Defines a Model class for scheduled notification configurations. + */ +@PersistenceCapable +@JsonInclude(JsonInclude.Include.NON_NULL) +@JsonIgnoreProperties(ignoreUnknown = true) +public class ScheduledNotificationRule implements Rule, Serializable { + private static final long serialVersionUID = 3390485832822256096L; + + @PrimaryKey + @Persistent(valueStrategy = IdGeneratorStrategy.NATIVE) + @JsonIgnore + private long id; + + /** + * The String representation of the name of the notification. + */ + @Persistent + @Column(name = "NAME", allowsNull = "false") + @NotBlank + @Size(min = 1, max = 255) + @JsonDeserialize(using = TrimmedStringDeserializer.class) + @Pattern(regexp = RegexSequence.Definition.PRINTABLE_CHARS, message = "The name may only contain printable characters") + private String name; + + @Persistent + @Column(name = "ENABLED") + private boolean enabled; + + @Persistent + @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 + private NotificationScope scope; + + /* + * For standard notifications, this property is used to determine all + * notification rules with a level equal to or greater than the specified + * notification level. + * Only notification rules with the correct rule level with then be published. + * + * For scheduled notifications, this property is unnecessary because they're + * published on-demand or by cron triggers instead through the internal + * notification service, so no notification level will be provided for filtering. + */ + @JsonIgnore + @NotPersistent + private NotificationLevel notificationLevel; + + @Persistent(table = "SCHEDULED_NOTIFICATIONRULE_PROJECTS", defaultFetchGroup = "true") + @Join(column = "SCHEDULED_NOTIFICATIONRULE_ID") + @Element(column = "PROJECT_ID") + @Order(extensions = @Extension(vendorName = "datanucleus", key = "list-ordering", value = "name ASC, version ASC")) + private List projects; + + @Persistent(table = "SCHEDULED_NOTIFICATIONRULE_TEAMS", defaultFetchGroup = "true") + @Join(column = "SCHEDULED_NOTIFICATIONRULE_ID") + @Element(column = "TEAM_ID") + @Order(extensions = @Extension(vendorName = "datanucleus", key = "list-ordering", value = "name ASC")) + private List teams; + + @Persistent + @Column(name = "NOTIFY_ON", length = 1024) + private String notifyOn; + + @Persistent + @Column(name = "MESSAGE", length = 1024) + @Size(max = 1024) + @JsonDeserialize(using = TrimmedStringDeserializer.class) + @Pattern(regexp = RegexSequence.Definition.PRINTABLE_CHARS, message = "The message may only contain printable characters") + private String message; + + @Persistent(defaultFetchGroup = "true") + @Column(name = "PUBLISHER") + private NotificationPublisher publisher; + + @Persistent(defaultFetchGroup = "true") + @Column(name = "PUBLISHER_CONFIG", jdbcType = "CLOB") + @JsonDeserialize(using = TrimmedStringDeserializer.class) + private String publisherConfig; + + @Persistent(defaultFetchGroup = "true", customValueStrategy = "uuid") + @Unique(name = "SCHEDULED_NOTIFICATIONRULE_UUID_IDX") + @Column(name = "UUID", jdbcType = "VARCHAR", length = 36, allowsNull = "false") + @NotNull + private UUID uuid; + + @Persistent(defaultFetchGroup = "true") + @Column(name = "CRON_CONFIG") + @JsonDeserialize(using = TrimmedStringDeserializer.class) + private String cronConfig; + + @Persistent(defaultFetchGroup = "true") + @Column(name = "LAST_EXECUTION_TIME") + @JsonSerialize(using = Iso8601ZonedDateTimeSerializer.class) + private ZonedDateTime lastExecutionTime; + + @Persistent + @Column(name = "PUBLISH_ONLY_WITH_UPDATES") + private boolean publishOnlyWithUpdates; + + + public long getId() { + return id; + } + + public void setId(long id) { + this.id = id; + } + + @NotNull + public String getName() { + return name; + } + + public void setName(@NotNull String name) { + this.name = name; + } + + public boolean isEnabled() { + return enabled; + } + + public void setEnabled(boolean enabled) { + this.enabled = enabled; + } + + public boolean isNotifyChildren() { + return notifyChildren; + } + + 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; + } + + public void setScope(@NotNull NotificationScope scope) { + this.scope = scope; + } + + public NotificationLevel getNotificationLevel() { + return notificationLevel; + } + + public void setNotificationLevel(NotificationLevel notificationLevel) { + this.notificationLevel = notificationLevel; + } + + public List getProjects() { + return projects; + } + + public void setProjects(List projects) { + this.projects = projects; + } + + public List getTeams() { + return teams; + } + + public void setTeams(List teams) { + this.teams = teams; + } + + public String getMessage() { + return message; + } + + public void setMessage(String message) { + this.message = message; + } + + public Set getNotifyOn() { + Set result = new TreeSet<>(); + if (notifyOn != null) { + String[] groups = notifyOn.split(","); + for (String s: groups) { + result.add(NotificationGroup.valueOf(s.trim())); + } + } + return result; + } + + public void setNotifyOn(Set groups) { + if (CollectionUtils.isEmpty(groups)) { + this.notifyOn = null; + return; + } + StringBuilder sb = new StringBuilder(); + List list = new ArrayList<>(groups); + Collections.sort(list); + for (int i=0; i> affectedProjectViolations) { +} \ No newline at end of file diff --git a/src/main/java/org/dependencytrack/model/scheduled/policyviolations/PolicyViolationOverview.java b/src/main/java/org/dependencytrack/model/scheduled/policyviolations/PolicyViolationOverview.java new file mode 100644 index 0000000000..ef951566c3 --- /dev/null +++ b/src/main/java/org/dependencytrack/model/scheduled/policyviolations/PolicyViolationOverview.java @@ -0,0 +1,35 @@ +/* + * 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.model.scheduled.policyviolations; + +import java.util.Map; + +import org.dependencytrack.model.PolicyViolation; + +/* + * Part of the ScheduledPolicyViolationsIdentified Template Models. + * Contains several key metrics to provide an overview of the policy violations identified. + */ +public record PolicyViolationOverview( + Integer affectedProjectsCount, + Integer newViolationsCount, + Map newViolationsByRiskType, + Integer affectedComponentsCount, + Integer suppressedNewViolationsCount) { +} diff --git a/src/main/java/org/dependencytrack/model/scheduled/policyviolations/PolicyViolationSummary.java b/src/main/java/org/dependencytrack/model/scheduled/policyviolations/PolicyViolationSummary.java new file mode 100644 index 0000000000..582726885f --- /dev/null +++ b/src/main/java/org/dependencytrack/model/scheduled/policyviolations/PolicyViolationSummary.java @@ -0,0 +1,30 @@ +/* + * 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.model.scheduled.policyviolations; + +import java.util.Map; + +import org.dependencytrack.model.Project; + +/* + * Part of the ScheduledPolicyViolationsIdentified Template Models. + * Contains packed summarized informations about the identified policy violations, grouped by the affected projects. + */ +public record PolicyViolationSummary(Map affectedProjectSummaries) { +} \ No newline at end of file diff --git a/src/main/java/org/dependencytrack/model/scheduled/policyviolations/PolicyViolationSummaryInfo.java b/src/main/java/org/dependencytrack/model/scheduled/policyviolations/PolicyViolationSummaryInfo.java new file mode 100644 index 0000000000..aa90c6a8f3 --- /dev/null +++ b/src/main/java/org/dependencytrack/model/scheduled/policyviolations/PolicyViolationSummaryInfo.java @@ -0,0 +1,33 @@ +/* + * 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.model.scheduled.policyviolations; + +import java.util.Map; + +import org.dependencytrack.model.PolicyViolation; + +/* + * Part of the ScheduledPolicyViolationsIdentified Template Models. + * Contains detailed information about the amount of the identified policy violations grouped by their type. + */ +public record PolicyViolationSummaryInfo( + Map newViolationsByRiskType, + Map totalProjectViolationsByRiskType, + Map suppressedNewViolationsByRiskType) { +} \ No newline at end of file diff --git a/src/main/java/org/dependencytrack/model/scheduled/vulnerabilities/VulnerabilityDetails.java b/src/main/java/org/dependencytrack/model/scheduled/vulnerabilities/VulnerabilityDetails.java new file mode 100644 index 0000000000..70a5156851 --- /dev/null +++ b/src/main/java/org/dependencytrack/model/scheduled/vulnerabilities/VulnerabilityDetails.java @@ -0,0 +1,31 @@ +/* + * 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.model.scheduled.vulnerabilities; + +import java.util.List; +import java.util.Map; + +import org.dependencytrack.model.Project; + +/* + * Part of the ScheduledNewVulnerabilitiesIdentified Template Models. + * Contains packed summarized informations about the new vulnerabilities identified, grouped by the affected projects. + */ +public record VulnerabilityDetails(Map> affectedProjectFindings) { +} \ No newline at end of file diff --git a/src/main/java/org/dependencytrack/model/scheduled/vulnerabilities/VulnerabilityDetailsInfo.java b/src/main/java/org/dependencytrack/model/scheduled/vulnerabilities/VulnerabilityDetailsInfo.java new file mode 100644 index 0000000000..06aabe7d00 --- /dev/null +++ b/src/main/java/org/dependencytrack/model/scheduled/vulnerabilities/VulnerabilityDetailsInfo.java @@ -0,0 +1,60 @@ +/* + * 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.model.scheduled.vulnerabilities; + +import java.util.Date; +import org.dependencytrack.model.Finding; +import org.dependencytrack.util.ScheduledUtil; + +/* + * Part of the ScheduledPolicyViolationsIdentified Template Models. + * Contains detailed information about the vulnerability/finding it represents. + * This class helps to extract the relevant data from the Finding object, to make it easier to use in the template. + */ +public record VulnerabilityDetailsInfo( + String componentUuid, + String componentName, + String componentVersion, + String componentGroup, + String vulnerabilitySource, + String vulnerabilityId, + String vulnerabilitySeverity, + String analyzer, + String attributionReferenceUrl, + String attributedOn, + String analysisState, + Boolean suppressed) { + public VulnerabilityDetailsInfo(Finding finding) { + this( + ScheduledUtil.getValueOrEmptyIfNull(finding.getComponent().get("uuid")), + ScheduledUtil.getValueOrEmptyIfNull(finding.getComponent().get("name")), + ScheduledUtil.getValueOrEmptyIfNull(finding.getComponent().get("version")), + ScheduledUtil.getValueOrEmptyIfNull(finding.getComponent().get("group")), + ScheduledUtil.getValueOrEmptyIfNull(finding.getVulnerability().get("source")), + ScheduledUtil.getValueOrEmptyIfNull(finding.getVulnerability().get("vulnId")), + ScheduledUtil.getValueOrEmptyIfNull(finding.getVulnerability().get("severity")), + ScheduledUtil.getValueOrEmptyIfNull(finding.getAttribution().get("analyzerIdentity")), + ScheduledUtil.getValueOrEmptyIfNull(finding.getAttribution().get("referenceUrl")), + ScheduledUtil.getDateOrUnknownIfNull((Date) finding.getAttribution().get("attributedOn")), + ScheduledUtil.getValueOrEmptyIfNull(finding.getAnalysis().get("state")), + finding.getAnalysis().get("isSuppressed") instanceof Boolean + ? (Boolean) finding.getAnalysis().get("isSuppressed") + : false); + } +} diff --git a/src/main/java/org/dependencytrack/model/scheduled/vulnerabilities/VulnerabilityOverview.java b/src/main/java/org/dependencytrack/model/scheduled/vulnerabilities/VulnerabilityOverview.java new file mode 100644 index 0000000000..813428f5bd --- /dev/null +++ b/src/main/java/org/dependencytrack/model/scheduled/vulnerabilities/VulnerabilityOverview.java @@ -0,0 +1,34 @@ +/* + * 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.model.scheduled.vulnerabilities; + +import java.util.Map; +import org.dependencytrack.model.Severity; + +/* + * Part of the ScheduledNewVulnerabilitiesIdentified Template Models. + * Contains several key metrics to provide an overview of the new vulnerabilities identified. + */ +public record VulnerabilityOverview( + Integer affectedProjectsCount, + Integer newVulnerabilitiesCount, + Map newVulnerabilitiesBySeverity, + Integer affectedComponentsCount, + Integer suppressedNewVulnerabilitiesCount) { +} diff --git a/src/main/java/org/dependencytrack/model/scheduled/vulnerabilities/VulnerabilitySummary.java b/src/main/java/org/dependencytrack/model/scheduled/vulnerabilities/VulnerabilitySummary.java new file mode 100644 index 0000000000..5b7ffcfbec --- /dev/null +++ b/src/main/java/org/dependencytrack/model/scheduled/vulnerabilities/VulnerabilitySummary.java @@ -0,0 +1,30 @@ +/* + * 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.model.scheduled.vulnerabilities; + +import java.util.Map; + +import org.dependencytrack.model.Project; + +/* + * Part of the ScheduledNewVulnerabilitiesIdentified Template Models. + * Contains packed summarized informations about the identified new vulnerabilities, grouped by the affected projects. + */ +public record VulnerabilitySummary(Map affectedProjectSummaries) { +} \ No newline at end of file diff --git a/src/main/java/org/dependencytrack/model/scheduled/vulnerabilities/VulnerabilitySummaryInfo.java b/src/main/java/org/dependencytrack/model/scheduled/vulnerabilities/VulnerabilitySummaryInfo.java new file mode 100644 index 0000000000..abc9719108 --- /dev/null +++ b/src/main/java/org/dependencytrack/model/scheduled/vulnerabilities/VulnerabilitySummaryInfo.java @@ -0,0 +1,33 @@ +/* + * 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.model.scheduled.vulnerabilities; + +import java.util.Map; + +import org.dependencytrack.model.Severity; + +/* + * Part of the ScheduledNewVulnerabilitiesIdentified Template Models. + * Contains detailed information about the amount of the identified new vulnerabilities grouped by their severity. + */ +public record VulnerabilitySummaryInfo( + Map newVulnerabilitiesBySeverity, + Map totalProjectVulnerabilitiesBySeverity, + Map suppressedNewVulnerabilitiesBySeverity) { +} diff --git a/src/main/java/org/dependencytrack/notification/NotificationConstants.java b/src/main/java/org/dependencytrack/notification/NotificationConstants.java index 83e78c5339..f3f9165407 100644 --- a/src/main/java/org/dependencytrack/notification/NotificationConstants.java +++ b/src/main/java/org/dependencytrack/notification/NotificationConstants.java @@ -39,6 +39,7 @@ public static class Title { public static final String REPO_ERROR = "Repository Error"; public static final String ANALYZER_ERROR = "Analyzer Error"; public static final String INTEGRATION_ERROR = "Integration Error"; + public static final String NEW_POLICY_VIOLATION = "New Policy Violation Identified"; public static final String NEW_VULNERABILITY = "New Vulnerability Identified"; public static final String NEW_VULNERABLE_DEPENDENCY = "Vulnerable Dependency Introduced"; public static final String ANALYSIS_DECISION_EXPLOITABLE = "Analysis Decision: Exploitable"; diff --git a/src/main/java/org/dependencytrack/notification/ScheduledNotificationFactory.java b/src/main/java/org/dependencytrack/notification/ScheduledNotificationFactory.java new file mode 100644 index 0000000000..a7c7d98a93 --- /dev/null +++ b/src/main/java/org/dependencytrack/notification/ScheduledNotificationFactory.java @@ -0,0 +1,281 @@ +/* + * 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 java.time.ZoneOffset; +import java.time.ZonedDateTime; +import java.util.ArrayList; +import java.util.EnumMap; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.UUID; +import org.dependencytrack.model.Finding; +import org.dependencytrack.model.PolicyViolation; +import org.dependencytrack.model.Project; +import org.dependencytrack.model.ScheduledNotificationRule; +import org.dependencytrack.model.Severity; +import org.dependencytrack.model.scheduled.policyviolations.PolicyViolationDetails; +import org.dependencytrack.model.scheduled.policyviolations.PolicyViolationOverview; +import org.dependencytrack.model.scheduled.policyviolations.PolicyViolationSummary; +import org.dependencytrack.model.scheduled.policyviolations.PolicyViolationSummaryInfo; +import org.dependencytrack.model.scheduled.vulnerabilities.VulnerabilityDetails; +import org.dependencytrack.model.scheduled.vulnerabilities.VulnerabilityDetailsInfo; +import org.dependencytrack.model.scheduled.vulnerabilities.VulnerabilityOverview; +import org.dependencytrack.model.scheduled.vulnerabilities.VulnerabilitySummary; +import org.dependencytrack.model.scheduled.vulnerabilities.VulnerabilitySummaryInfo; +import org.dependencytrack.notification.vo.ScheduledNewVulnerabilitiesIdentified; +import org.dependencytrack.notification.vo.ScheduledPolicyViolationsIdentified; +import org.dependencytrack.persistence.QueryManager; + +public class ScheduledNotificationFactory { + public static ScheduledNewVulnerabilitiesIdentified CreateScheduledVulnerabilitySubject(ScheduledNotificationRule rule, ZonedDateTime lastExecution) { + Map> affectedProjectFindings = new LinkedHashMap<>(); + + try (var qm = new QueryManager()) { + var findings = qm.getAllFindingsSince(true, lastExecution.withZoneSameInstant(ZoneOffset.UTC)); + + for (Finding finding : findings) { + var findingProject = qm.getProject(finding.getProjectUuid().toString()); + + if (!checkIfProjectShallBeProcessed(findingProject, rule)) + continue; + + var entry = affectedProjectFindings.get(findingProject); + if (entry == null) { + ArrayList initial = new ArrayList(); + initial.add(finding); + affectedProjectFindings.put(findingProject, initial); + } else { + entry.add(finding); + } + } + } + + var overview = createVulnerabilityOverview(affectedProjectFindings); + var summary = createVulnerabilitySummary(affectedProjectFindings); + var details = createVulnerabilityDetails(affectedProjectFindings); + + return new ScheduledNewVulnerabilitiesIdentified(overview, summary, details); + } + + public static ScheduledPolicyViolationsIdentified CreateScheduledPolicyViolationSubject(ScheduledNotificationRule rule, ZonedDateTime lastExecution) { + Map> affectedProjectViolations = new LinkedHashMap<>(); + + try (var qm = new QueryManager()){ + var violations = qm.getAllPolicyViolationsSince(true, lastExecution); + + for (PolicyViolation violation : violations) { + if (!checkIfProjectShallBeProcessed(violation.getProject(), rule)) + continue; + + var entry = affectedProjectViolations.get(violation.getProject()); + if (entry == null) { + ArrayList initial = new ArrayList(); + initial.add(violation); + affectedProjectViolations.put(violation.getProject(), initial); + } else { + entry.add(violation); + } + } + } + + var overview = createPolicyOverview(affectedProjectViolations); + var summary = createPolicySummary(affectedProjectViolations); + var details = createPolicyDetails(affectedProjectViolations); + + return new ScheduledPolicyViolationsIdentified(overview, summary, details); + } + + private static boolean checkIfProjectShallBeProcessed(Project project, ScheduledNotificationRule rule) { + if (rule.getProjects() == null || rule.getProjects().isEmpty()) { + return true; + } + for (final Project ruleProject : rule.getProjects()) { + var projectIsMatch = project.getUuid().equals(ruleProject.getUuid()); + var considerChildren = Boolean.TRUE.equals(rule.isNotifyChildren() + && checkIfChildrenAreAffected(ruleProject, project.getUuid())); + if (projectIsMatch || considerChildren) { + return true; + } + } + return false; + } + + private static boolean checkIfChildrenAreAffected(Project parent, UUID uuid) { + boolean isChild = false; + if (parent.getChildren() == null || parent.getChildren().isEmpty()) { + return false; + } + for (Project child : parent.getChildren()) { + final boolean isChildActive = child.isActive() == null || child.isActive(); + if ((child.getUuid().equals(uuid) && isChildActive) || isChild) { + return true; + } + isChild = checkIfChildrenAreAffected(child, uuid); + } + return isChild; + } + + private static VulnerabilityOverview createVulnerabilityOverview(Map> affectedProjectFindings) { + Integer affectedProjectsCount; + Integer newVulnerabilitiesCount = 0; + Map newVulnerabilitiesBySeverity = new EnumMap<>(Severity.class); + Integer affectedComponentsCount = 0; + Integer suppressedNewVulnerabilitiesCount = 0; + + for (Severity severity : Severity.values()) { + newVulnerabilitiesBySeverity.put(severity, 0); + } + + try (var qm = new QueryManager()) { + for (var findings : affectedProjectFindings.values()) { + for (Finding finding : findings) { + if(finding.getComponent() != null) + affectedComponentsCount++; + + if (finding.getAnalysis().get("isSuppressed") instanceof Boolean suppressed) { + if (suppressed) { + suppressedNewVulnerabilitiesCount++; + } else { + newVulnerabilitiesCount++; + newVulnerabilitiesBySeverity.merge( + Enum.valueOf(Severity.class, finding.getVulnerability().get("severity").toString()), + 1, Integer::sum); + } + } + } + } + } + + affectedProjectsCount = affectedProjectFindings.size(); + + return new VulnerabilityOverview(affectedProjectsCount, newVulnerabilitiesCount, newVulnerabilitiesBySeverity, affectedComponentsCount, suppressedNewVulnerabilitiesCount); + } + + private static VulnerabilitySummary createVulnerabilitySummary(Map> affectedProjectFindings) { + Map projectSummaryInfoMap = new LinkedHashMap<>(); + try (var qm = new QueryManager()) { + for (var entry : affectedProjectFindings.entrySet()) { + var totalProjectFindings = qm.getFindings(entry.getKey()); + projectSummaryInfoMap.put(entry.getKey(), createVulnerabilitySummaryInfo(entry.getValue(), totalProjectFindings)); + + } + } + return new VulnerabilitySummary(projectSummaryInfoMap); + } + + private static VulnerabilitySummaryInfo createVulnerabilitySummaryInfo(List newFindings, List totalProjectFindings){ + Map newVulnerabilitiesBySeverity = new EnumMap<>(Severity.class); + Map totalProjectVulnerabilitiesBySeverity = new EnumMap<>(Severity.class); + Map suppressedNewVulnerabilitiesBySeverity = new EnumMap<>(Severity.class); + + try (var qm = new QueryManager()) { + for (Finding finding : newFindings) { + if (finding.getAnalysis().get("isSuppressed") instanceof Boolean suppressed) { + var severity = Enum.valueOf(Severity.class, finding.getVulnerability().get("severity").toString()); + if (suppressed) { + suppressedNewVulnerabilitiesBySeverity.merge(severity, 1, Integer::sum); + } else { + newVulnerabilitiesBySeverity.merge(severity, 1, Integer::sum); + } + } + } + for (Finding finding : totalProjectFindings) { + var severity = Enum.valueOf(Severity.class, finding.getVulnerability().get("severity").toString()); + totalProjectVulnerabilitiesBySeverity.merge(severity, 1, Integer::sum); + } + } + return new VulnerabilitySummaryInfo(newVulnerabilitiesBySeverity, totalProjectVulnerabilitiesBySeverity, suppressedNewVulnerabilitiesBySeverity); + } + + private static VulnerabilityDetails createVulnerabilityDetails(Map> affectedProjectFindings) { + Map> projectDetailsInfoMap = new LinkedHashMap<>(); + for (var entry : affectedProjectFindings.entrySet()) { + projectDetailsInfoMap.put(entry.getKey(), entry.getValue().stream().map(f -> createVulnerabilityDetailsInfo(f)).toList()); + } + return new VulnerabilityDetails(projectDetailsInfoMap); + } + + private static VulnerabilityDetailsInfo createVulnerabilityDetailsInfo(Finding finding) { + return new VulnerabilityDetailsInfo(finding); + } + + private static PolicyViolationOverview createPolicyOverview(Map> affectedProjectViolations) { + var affectedComponentsCount = 0; + var affectedProjectsCount = 0; + var newViolationsCount = 0; + var suppressedNewViolationsCount = 0; + Map newViolationsByRiskType = new EnumMap<>(PolicyViolation.Type.class); + + for (PolicyViolation.Type riskType : PolicyViolation.Type.values()) { + newViolationsByRiskType.put(riskType, 0); + } + + try (var qm = new QueryManager()) { + for (var violations : affectedProjectViolations.values()) { + affectedProjectsCount++; + for (PolicyViolation violation : violations) { + if(violation.getComponent() != null) + affectedComponentsCount++; + + var analysis = violation.getAnalysis(); + if (analysis != null && analysis.isSuppressed()) { + suppressedNewViolationsCount++; + } else { + newViolationsCount++; + newViolationsByRiskType.merge(violation.getType(), 1, Integer::sum); + } + } + } + } + + return new PolicyViolationOverview(affectedProjectsCount, newViolationsCount, newViolationsByRiskType, affectedComponentsCount, suppressedNewViolationsCount); + } + + private static PolicyViolationSummary createPolicySummary(Map> affectedProjectViolations) { + Map affectedProjectSummaries = new LinkedHashMap<>(); + for (var entry : affectedProjectViolations.entrySet()) { + affectedProjectSummaries.put(entry.getKey(), createPolicySummaryInfo(entry.getValue())); + } + return new PolicyViolationSummary(affectedProjectSummaries); + } + + private static PolicyViolationSummaryInfo createPolicySummaryInfo(List violations) { + Map newViolationsByRiskType = new EnumMap<>(PolicyViolation.Type.class); + Map totalProjectViolationsByRiskType = new EnumMap<>(PolicyViolation.Type.class); + Map suppressedNewViolationsByRiskType = new EnumMap<>(PolicyViolation.Type.class); + + for (PolicyViolation violation : violations) { + var analysis = violation.getAnalysis(); + if (analysis != null && analysis.isSuppressed()) { + suppressedNewViolationsByRiskType.merge(violation.getType(), 1, Integer::sum); + } else { + newViolationsByRiskType.merge(violation.getType(), 1, Integer::sum); + } + totalProjectViolationsByRiskType.merge(violation.getType(), 1, Integer::sum); + } + + return new PolicyViolationSummaryInfo(newViolationsByRiskType, totalProjectViolationsByRiskType, suppressedNewViolationsByRiskType); + } + + private static PolicyViolationDetails createPolicyDetails(Map> affectedProjectViolations) { + return new PolicyViolationDetails(affectedProjectViolations); + } +} diff --git a/src/main/java/org/dependencytrack/notification/ScheduledNotificationTaskInitializer.java b/src/main/java/org/dependencytrack/notification/ScheduledNotificationTaskInitializer.java new file mode 100644 index 0000000000..75755dd570 --- /dev/null +++ b/src/main/java/org/dependencytrack/notification/ScheduledNotificationTaskInitializer.java @@ -0,0 +1,63 @@ +/* + * 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 jakarta.servlet.ServletContextEvent; +import jakarta.servlet.ServletContextListener; + +import org.dependencytrack.model.ScheduledNotificationRule; +import org.dependencytrack.persistence.QueryManager; + +import com.asahaf.javacron.InvalidExpressionException; +import com.asahaf.javacron.Schedule; + +import alpine.common.logging.Logger; + +/* + * Initializes the scheduled notification task service. + * It schedules the task executions for each enabled scheduled notification rule on startup, based on their cron configuration. + */ +public class ScheduledNotificationTaskInitializer implements ServletContextListener { + private static final Logger LOGGER = Logger.getLogger(ScheduledNotificationTaskInitializer.class); + + @Override + public void contextInitialized(ServletContextEvent sce) { + LOGGER.info("Initializing scheduled notification task service"); + try (var qm = new QueryManager()) { + var scheduledRulesList = qm.getScheduledNotificationRules().getList(ScheduledNotificationRule.class); + for (var scheduledRule : scheduledRulesList) { + try { + if (scheduledRule.isEnabled()) { + ScheduledNotificationTaskManager.scheduleNextRuleTask( + scheduledRule.getUuid(), + Schedule.create(scheduledRule.getCronConfig())); + } + } catch (InvalidExpressionException e) { + LOGGER.error("Invalid cron expression in rule " + scheduledRule.getUuid() + ", no cron task could be created!"); + } + } + } + } + + @Override + public void contextDestroyed(ServletContextEvent sce) { + LOGGER.info("Shutting down scheduled notification task service"); + ScheduledNotificationTaskManager.cancelAllActiveRuleTasks(); + } +} diff --git a/src/main/java/org/dependencytrack/notification/ScheduledNotificationTaskManager.java b/src/main/java/org/dependencytrack/notification/ScheduledNotificationTaskManager.java new file mode 100644 index 0000000000..59fd184aa1 --- /dev/null +++ b/src/main/java/org/dependencytrack/notification/ScheduledNotificationTaskManager.java @@ -0,0 +1,75 @@ +/* + * 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 java.util.HashMap; +import java.util.UUID; +import java.util.concurrent.Executors; +import java.util.concurrent.ScheduledFuture; +import java.util.concurrent.TimeUnit; + +import org.dependencytrack.tasks.ActionOnDoneFutureTask; +import org.dependencytrack.tasks.SendScheduledNotificationTask; + +import com.asahaf.javacron.Schedule; + +/* + * Holds all scheduled notification tasks to be able to cancel them if needed. + * This may be necessary if a rule is deleted or disabled. + */ +public final class ScheduledNotificationTaskManager { + private static final HashMap> SCHEDULED_NOTIFY_TASKS = new HashMap>(); + + public static void scheduleNextRuleTask(UUID ruleUuid, Schedule schedule, long customDelay, TimeUnit delayUnit) { + scheduleNextRuleTask(ruleUuid, schedule, customDelay, delayUnit, () -> scheduleNextRuleTask(ruleUuid, schedule)); + } + + public static void scheduleNextRuleTask(UUID ruleUuid, Schedule schedule) { + scheduleNextRuleTask(ruleUuid, schedule, schedule.nextDuration(TimeUnit.MILLISECONDS), TimeUnit.MILLISECONDS); + } + + public static void scheduleNextRuleTaskOnce(UUID ruleUuid, long customDelay, TimeUnit delayUnit){ + scheduleNextRuleTask(ruleUuid, null, customDelay, delayUnit, () -> cancelActiveRuleTask(ruleUuid)); + } + + private static void scheduleNextRuleTask(UUID ruleUuid, Schedule schedule, long customDelay, TimeUnit delayUnit, Runnable actionAfterTaskCompletion){ + var scheduledExecutor = Executors.newSingleThreadScheduledExecutor(); + var futureTask = new ActionOnDoneFutureTask(new SendScheduledNotificationTask(ruleUuid), actionAfterTaskCompletion); + + var future = scheduledExecutor.schedule( + futureTask, + customDelay, + TimeUnit.MILLISECONDS); + SCHEDULED_NOTIFY_TASKS.put(ruleUuid, future); + } + + public static void cancelActiveRuleTask(UUID ruleUuid) { + if (SCHEDULED_NOTIFY_TASKS.containsKey(ruleUuid)) { + SCHEDULED_NOTIFY_TASKS.get(ruleUuid).cancel(true); + SCHEDULED_NOTIFY_TASKS.remove(ruleUuid); + } + } + + public static void cancelAllActiveRuleTasks(){ + for (var future : SCHEDULED_NOTIFY_TASKS.values()) { + future.cancel(true); + } + SCHEDULED_NOTIFY_TASKS.clear(); + } +} diff --git a/src/main/java/org/dependencytrack/notification/publisher/DefaultNotificationPublishers.java b/src/main/java/org/dependencytrack/notification/publisher/DefaultNotificationPublishers.java index 3c5ee7c262..218a3a75c9 100644 --- a/src/main/java/org/dependencytrack/notification/publisher/DefaultNotificationPublishers.java +++ b/src/main/java/org/dependencytrack/notification/publisher/DefaultNotificationPublishers.java @@ -25,8 +25,10 @@ public enum DefaultNotificationPublishers { SLACK("Slack", "Publishes notifications to a Slack channel", SlackPublisher.class, "/templates/notification/publisher/slack.peb", MediaType.APPLICATION_JSON, true), MS_TEAMS("Microsoft Teams", "Publishes notifications to a Microsoft Teams channel", MsTeamsPublisher.class, "/templates/notification/publisher/msteams.peb", MediaType.APPLICATION_JSON, true), MATTERMOST("Mattermost", "Publishes notifications to a Mattermost channel", MattermostPublisher.class, "/templates/notification/publisher/mattermost.peb", MediaType.APPLICATION_JSON, true), - EMAIL("Email", "Sends notifications to an email address", SendMailPublisher.class, "/templates/notification/publisher/email.peb", "text/plain; charset=utf-8", true), + EMAIL("Email", "Sends notifications to an email address", SendMailPublisher.class, "/templates/notification/publisher/email.peb", MediaType.TEXT_PLAIN, true), + SCHEDULED_EMAIL("Scheduled Email", "Sends summarized notifications to an email address in a defined schedule", SendMailPublisher.class, "/templates/notification/publisher/scheduled_email_summary.peb", MediaType.TEXT_HTML, true, true), CONSOLE("Console", "Displays notifications on the system console", ConsolePublisher.class, "/templates/notification/publisher/console.peb", MediaType.TEXT_PLAIN, true), + SCHEDULED_CONSOLE("Scheduled Console", "Displays summarized notifications on the system console in a defined schedule", ConsolePublisher.class, "/templates/notification/publisher/scheduled_console.peb", MediaType.TEXT_PLAIN, true, true), WEBHOOK("Outbound Webhook", "Publishes notifications to a configurable endpoint", WebhookPublisher.class, "/templates/notification/publisher/webhook.peb", MediaType.APPLICATION_JSON, true), 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); @@ -37,15 +39,22 @@ public enum DefaultNotificationPublishers { private final String templateFile; private final String templateMimeType; private final boolean defaultPublisher; + private final boolean publishScheduled; DefaultNotificationPublishers(final String name, final String description, final Class publisherClass, - final String templateFile, final String templateMimeType, final boolean defaultPublisher) { + final String templateFile, final String templateMimeType, final boolean defaultPublisher, final boolean publishScheduled) { this.name = name; this.description = description; this.publisherClass = publisherClass; this.templateFile = templateFile; this.templateMimeType = templateMimeType; this.defaultPublisher = defaultPublisher; + this.publishScheduled = publishScheduled; + } + + DefaultNotificationPublishers(final String name, final String description, final Class publisherClass, + final String templateFile, final String templateMimeType, final boolean defaultPublisher) { + this(name, description, publisherClass, templateFile, templateMimeType, defaultPublisher, false); } public String getPublisherName() { @@ -71,4 +80,8 @@ public String getTemplateMimeType() { public boolean isDefaultPublisher() { return defaultPublisher; } + + public boolean isPublishScheduled() { + return publishScheduled; + } } \ No newline at end of file diff --git a/src/main/java/org/dependencytrack/notification/publisher/PublishContext.java b/src/main/java/org/dependencytrack/notification/publisher/PublishContext.java index e60a1e9287..3e26f86113 100644 --- a/src/main/java/org/dependencytrack/notification/publisher/PublishContext.java +++ b/src/main/java/org/dependencytrack/notification/publisher/PublishContext.java @@ -21,12 +21,15 @@ import alpine.notification.Notification; import com.google.common.base.MoreObjects; import org.dependencytrack.model.NotificationRule; +import org.dependencytrack.model.Rule; 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.ScheduledNewVulnerabilitiesIdentified; +import org.dependencytrack.notification.vo.ScheduledPolicyViolationsIdentified; import org.dependencytrack.notification.vo.VexConsumedOrProcessed; import org.dependencytrack.notification.vo.ViolationAnalysisDecisionChange; @@ -101,6 +104,10 @@ public static PublishContext from(final Notification notification) { notificationSubjects.put(SUBJECT_VULNERABILITY, Vulnerability.convert(subject.getVulnerability())); } else if (notification.getSubject() instanceof final VexConsumedOrProcessed subject) { notificationSubjects.put(SUBJECT_PROJECT, Project.convert(subject.getProject())); + } else if (notification.getSubject() instanceof final ScheduledNewVulnerabilitiesIdentified subject) { + notificationSubjects.put(SUBJECT_PROJECTS, subject.summary().affectedProjectSummaries().keySet().stream().map(Project::convert).toList()); + } else if (notification.getSubject() instanceof final ScheduledPolicyViolationsIdentified subject) { + notificationSubjects.put(SUBJECT_PROJECTS, subject.summary().affectedProjectSummaries().keySet().stream().map(Project::convert).toList()); } return new PublishContext(notification.getGroup(), Optional.ofNullable(notification.getLevel()).map(Enum::name).orElse(null), @@ -114,7 +121,7 @@ public static PublishContext from(final Notification notification) { * @param rule The applicable {@link NotificationRule} * @return This {@link PublishContext} */ - public PublishContext withRule(final NotificationRule rule) { + public PublishContext withRule(final Rule rule) { return new PublishContext(this.notificationGroup, this.notificationLevel, this.notificationScope, this.notificationTimestamp, this.notificationSubjects, rule.getName(), rule.getScope().name(), rule.getNotificationLevel().name(), rule.isLogSuccessfulPublish()); } diff --git a/src/main/java/org/dependencytrack/notification/publisher/Publisher.java b/src/main/java/org/dependencytrack/notification/publisher/Publisher.java index 50a319d92f..c69eade286 100644 --- a/src/main/java/org/dependencytrack/notification/publisher/Publisher.java +++ b/src/main/java/org/dependencytrack/notification/publisher/Publisher.java @@ -34,6 +34,8 @@ import org.dependencytrack.notification.vo.NewVulnerabilityIdentified; import org.dependencytrack.notification.vo.NewVulnerableDependency; import org.dependencytrack.notification.vo.PolicyViolationIdentified; +import org.dependencytrack.notification.vo.ScheduledNewVulnerabilitiesIdentified; +import org.dependencytrack.notification.vo.ScheduledPolicyViolationsIdentified; import org.dependencytrack.notification.vo.VexConsumedOrProcessed; import org.dependencytrack.notification.vo.ViolationAnalysisDecisionChange; import org.dependencytrack.persistence.QueryManager; @@ -130,6 +132,12 @@ default String prepareTemplate(final Notification notification, final PebbleTemp } else if (notification.getSubject() instanceof final PolicyViolationIdentified subject) { context.put("subject", subject); context.put("subjectJson", NotificationUtil.toJson(subject)); + } else if (notification.getSubject() instanceof final ScheduledNewVulnerabilitiesIdentified subject) { + context.put("subject", subject); + context.put("subjectJson", NotificationUtil.toJson(subject)); + } else if (notification.getSubject() instanceof final ScheduledPolicyViolationsIdentified subject) { + context.put("subject", subject); + context.put("subjectJson", NotificationUtil.toJson(subject)); } } else if (NotificationScope.SYSTEM.name().equals(notification.getScope())) { if (notification.getSubject() instanceof final UserPrincipal subject) { diff --git a/src/main/java/org/dependencytrack/notification/publisher/SendMailPublisher.java b/src/main/java/org/dependencytrack/notification/publisher/SendMailPublisher.java index c83c38fb65..e318f70f8c 100644 --- a/src/main/java/org/dependencytrack/notification/publisher/SendMailPublisher.java +++ b/src/main/java/org/dependencytrack/notification/publisher/SendMailPublisher.java @@ -60,6 +60,8 @@ 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(); + private static final String TEXT_HTML_UTF8 = "text/html; charset=UTF-8"; + private static final String TEXT_PLAIN_UTF8 = "text/plain; charset=UTF-8"; public void inform(final PublishContext ctx, final Notification notification, final JsonObject config) { if (config == null) { @@ -147,7 +149,7 @@ private void sendNotification(final PublishContext ctx, Notification notificatio .to(destinations) .subject(emailSubjectPrefix + " " + notification.getTitle()) .body(mimeType == MediaType.TEXT_HTML ? StringEscapeUtils.escapeHtml4(unescapedContent): unescapedContent) - .bodyMimeType(mimeType) + .bodyMimeType(mimeType == MediaType.TEXT_HTML ? TEXT_HTML_UTF8 : TEXT_PLAIN_UTF8) .host(smtpHostname) .port(smtpPort) .username(smtpUser) diff --git a/src/main/java/org/dependencytrack/notification/vo/ScheduledNewVulnerabilitiesIdentified.java b/src/main/java/org/dependencytrack/notification/vo/ScheduledNewVulnerabilitiesIdentified.java new file mode 100644 index 0000000000..2adea78718 --- /dev/null +++ b/src/main/java/org/dependencytrack/notification/vo/ScheduledNewVulnerabilitiesIdentified.java @@ -0,0 +1,34 @@ +/* + * 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.vo; + +import org.dependencytrack.model.scheduled.vulnerabilities.VulnerabilityDetails; +import org.dependencytrack.model.scheduled.vulnerabilities.VulnerabilityOverview; +import org.dependencytrack.model.scheduled.vulnerabilities.VulnerabilitySummary; + +/** + * Main part of the ScheduledNewVulnerabilitiesIdentified Template Models. + * Contains the separate parts used in the template to display the new + * vulnerabilities identified since the last notification. + */ +public record ScheduledNewVulnerabilitiesIdentified( + VulnerabilityOverview overview, + VulnerabilitySummary summary, + VulnerabilityDetails details) { +} diff --git a/src/main/java/org/dependencytrack/notification/vo/ScheduledPolicyViolationsIdentified.java b/src/main/java/org/dependencytrack/notification/vo/ScheduledPolicyViolationsIdentified.java new file mode 100644 index 0000000000..2932f5beb5 --- /dev/null +++ b/src/main/java/org/dependencytrack/notification/vo/ScheduledPolicyViolationsIdentified.java @@ -0,0 +1,34 @@ +/* + * 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.vo; + +import org.dependencytrack.model.scheduled.policyviolations.PolicyViolationDetails; +import org.dependencytrack.model.scheduled.policyviolations.PolicyViolationOverview; +import org.dependencytrack.model.scheduled.policyviolations.PolicyViolationSummary; + +/** + * Main part of the ScheduledPolicyViolationsIdentified Template Models. + * Contains the separate parts used in the template to display the new policy + * violations identified since the last notification. + */ +public record ScheduledPolicyViolationsIdentified( + PolicyViolationOverview overview, + PolicyViolationSummary summary, + PolicyViolationDetails details) { +} \ No newline at end of file diff --git a/src/main/java/org/dependencytrack/persistence/FindingsQueryManager.java b/src/main/java/org/dependencytrack/persistence/FindingsQueryManager.java index d56394f2d1..53134c5504 100644 --- a/src/main/java/org/dependencytrack/persistence/FindingsQueryManager.java +++ b/src/main/java/org/dependencytrack/persistence/FindingsQueryManager.java @@ -37,11 +37,15 @@ import javax.jdo.PersistenceManager; import javax.jdo.Query; + +import java.time.ZoneOffset; +import java.time.ZonedDateTime; import java.util.Collections; import java.util.Date; import java.util.List; import java.util.Map; import java.util.Objects; +import java.util.UUID; import java.util.stream.Collectors; public class FindingsQueryManager extends QueryManager implements IQueryManager { @@ -256,9 +260,8 @@ void deleteAnalysisTrail(Project project) { * @param project the project to retrieve findings for * @return a List of Finding objects */ - @SuppressWarnings("unchecked") public List getFindings(Project project) { - return getFindings(project, false); + return getFindings(project, false, null); } /** @@ -267,22 +270,126 @@ public List getFindings(Project project) { * @param includeSuppressed determines if suppressed vulnerabilities should be included or not * @return a List of Finding objects */ - @SuppressWarnings("unchecked") public List getFindings(Project project, boolean includeSuppressed) { - final Query query = pm.newQuery(Query.SQL, Finding.QUERY); + return getFindings(project, includeSuppressed, null); + } + + /** + * Returns a List of Finding objects for the specified project. + * @param project the project to retrieve findings for + * @param includeSuppressed determines if suppressed vulnerabilities should be included or not + * @param sinceAttributedOn determines the time since when the findings should be included by their attributedOn field + * @return a List of Finding objects + */ + @SuppressWarnings("unchecked") + public List getFindings(Project project, boolean includeSuppressed, ZonedDateTime sinceAttributedOn) { + final Query query; + if (sinceAttributedOn == null) { + query = pm.newQuery(Query.SQL, Finding.QUERY); + query.setNamedParameters(Map.ofEntries( + Map.entry("projectId", project.getId()), + Map.entry("includeSuppressed", includeSuppressed), + // NB: These are required for MSSQL, apparently it doesn't have + // a native boolean type, or DataNucleus maps booleans to a type + // that doesn't have boolean semantics. Fun! + Map.entry("false", false), + Map.entry("true", true))); + } else { + query = pm.newQuery(Query.SQL, Finding.QUERY_SINCE_ATTRIBUTION); + query.setNamedParameters(Map.ofEntries( + Map.entry("projectId", project.getId()), + Map.entry("includeSuppressed", includeSuppressed), + Map.entry("fromDateTime", sinceAttributedOn.withZoneSameInstant(ZoneOffset.UTC)), + Map.entry("toDateTime", ZonedDateTime.now(ZoneOffset.UTC)), + // NB: These are required for MSSQL, apparently it doesn't have + // a native boolean type, or DataNucleus maps booleans to a type + // that doesn't have boolean semantics. Fun! + Map.entry("false", false), + Map.entry("true", true))); + } + + final List queryResultRows = executeAndCloseList(query); + + final List findings = queryResultRows.stream() + .map(row -> new Finding(project.getUuid(), row)) + .toList(); + + final Map> findingsByVulnIdAndSource = findings.stream() + .collect(Collectors.groupingBy( + finding -> new VulnIdAndSource( + (String) finding.getVulnerability().get("vulnId"), + (String) finding.getVulnerability().get("source") + ) + )); + final Map> aliasesByVulnIdAndSource = + getVulnerabilityAliases(findingsByVulnIdAndSource.keySet()); + for (final VulnIdAndSource vulnIdAndSource : findingsByVulnIdAndSource.keySet()) { + final List affectedFindings = findingsByVulnIdAndSource.get(vulnIdAndSource); + final List aliases = aliasesByVulnIdAndSource.getOrDefault(vulnIdAndSource, Collections.emptyList()); + + for (final Finding finding : affectedFindings) { + finding.addVulnerabilityAliases(aliases); + } + } + + final Map> findingsByMetaComponentSearch = findings.stream() + .filter(finding -> finding.getComponent().get("purl") != null) + .map(finding -> { + final PackageURL purl = PurlUtil.silentPurl((String) finding.getComponent().get("purl")); + if (purl == null) { + return null; + } + + final var repositoryType = RepositoryType.resolve(purl); + if (repositoryType == RepositoryType.UNSUPPORTED) { + return null; + } + + final var search = new RepositoryMetaComponentSearch(repositoryType, purl.getNamespace(), purl.getName()); + return Map.entry(search, finding); + }) + .filter(Objects::nonNull) + .collect(Collectors.groupingBy( + Map.Entry::getKey, + Collectors.mapping(Map.Entry::getValue, Collectors.toList()) + )); + getRepositoryMetaComponents(List.copyOf(findingsByMetaComponentSearch.keySet())) + .forEach(metaComponent -> { + final var search = new RepositoryMetaComponentSearch(metaComponent.getRepositoryType(), metaComponent.getNamespace(), metaComponent.getName()); + final List affectedFindings = findingsByMetaComponentSearch.get(search); + for (final Finding finding : affectedFindings) { + finding.getComponent().put("latestVersion", metaComponent.getLatestVersion()); + } + }); + + return findings; + } + + /** + * Returns a List of Finding objects that occured since a given time. + * @param includeSuppressed determines if suppressed vulnerabilities should be included or not + * @param sinceAttributedOn determines the time since when the findings should be included by their attributedOn field + * @return a List of Finding objects + */ + @SuppressWarnings("unchecked") + public List getFindings(boolean includeSuppressed, ZonedDateTime sinceAttributedOn) { + final Query query; + query = pm.newQuery(Query.SQL, Finding.QUERY_ALL_FINDINGS_SINCE); query.setNamedParameters(Map.ofEntries( - Map.entry("projectId", project.getId()), Map.entry("includeSuppressed", includeSuppressed), + Map.entry("fromDateTime", sinceAttributedOn.withZoneSameInstant(ZoneOffset.UTC)), + Map.entry("toDateTime", ZonedDateTime.now(ZoneOffset.UTC)), // NB: These are required for MSSQL, apparently it doesn't have // a native boolean type, or DataNucleus maps booleans to a type // that doesn't have boolean semantics. Fun! Map.entry("false", false), - Map.entry("true", true) - )); + Map.entry("true", true))); + final List queryResultRows = executeAndCloseList(query); final List findings = queryResultRows.stream() - .map(row -> new Finding(project.getUuid(), row)) + .filter(row -> row[29] != null && row[29] instanceof String) // Filter out findings without a project UUID + .map(row -> new Finding(UUID.fromString((String) row[29]), row)) .toList(); final Map> findingsByVulnIdAndSource = findings.stream() @@ -335,4 +442,4 @@ public List getFindings(Project project, boolean includeSuppressed) { return findings; } -} +} \ No newline at end of file diff --git a/src/main/java/org/dependencytrack/persistence/NotificationQueryManager.java b/src/main/java/org/dependencytrack/persistence/NotificationQueryManager.java index 3f4b6e71ad..fd86fc3d3b 100644 --- a/src/main/java/org/dependencytrack/persistence/NotificationQueryManager.java +++ b/src/main/java/org/dependencytrack/persistence/NotificationQueryManager.java @@ -22,17 +22,25 @@ import alpine.notification.NotificationLevel; import alpine.persistence.PaginatedResult; import alpine.resources.AlpineRequest; + +import org.dependencytrack.model.ConfigPropertyConstants; import org.dependencytrack.model.NotificationPublisher; import org.dependencytrack.model.NotificationRule; import org.dependencytrack.model.Project; import org.dependencytrack.model.Tag; +import org.dependencytrack.model.PublishTrigger; +import org.dependencytrack.model.ScheduledNotificationRule; import org.dependencytrack.notification.NotificationScope; +import org.dependencytrack.notification.publisher.DefaultNotificationPublishers; import org.dependencytrack.notification.publisher.Publisher; import javax.jdo.PersistenceManager; import javax.jdo.Query; import java.util.ArrayList; import java.util.Collection; + +import java.time.ZoneOffset; +import java.time.ZonedDateTime; import java.util.List; import static org.dependencytrack.util.PersistenceUtil.assertPersistent; @@ -80,6 +88,29 @@ public NotificationRule createNotificationRule(String name, NotificationScope sc }); } + /** + * Creates a new ScheduledNotificationRule. + * @param name the name of the rule + * @param scope the scope + * @param level the level + * @param publisher the publisher + * @return a new ScheduledNotificationRule + */ + public ScheduledNotificationRule createScheduledNotificationRule(String name, NotificationScope scope, NotificationPublisher publisher) { + final ScheduledNotificationRule rule = new ScheduledNotificationRule(); + rule.setName(name); + rule.setScope(scope); + rule.setNotificationLevel(NotificationLevel.INFORMATIONAL); + rule.setPublisher(publisher); + rule.setEnabled(true); + rule.setNotifyChildren(true); + rule.setLogSuccessfulPublish(false); + rule.setCronConfig(ConfigPropertyConstants.NOTIFICATION_CRON_DEFAULT_EXPRESSION.getDefaultPropertyValue()); + rule.setLastExecutionTime(ZonedDateTime.now(ZoneOffset.UTC)); + rule.setPublishOnlyWithUpdates(false); + return persist(rule); + } + /** * Updated an existing NotificationRule. * @param transientRule the rule to update @@ -99,6 +130,30 @@ public NotificationRule updateNotificationRule(NotificationRule transientRule) { return persist(rule); }); } + + /** + * Updated an existing ScheduledNotificationRule. + * @param transientRule the rule to update + * @return a ScheduledNotificationRule + */ + public ScheduledNotificationRule updateScheduledNotificationRule(ScheduledNotificationRule transientRule) { + final ScheduledNotificationRule rule = getObjectByUuid(ScheduledNotificationRule.class, transientRule.getUuid()); + rule.setName(transientRule.getName()); + rule.setEnabled(transientRule.isEnabled()); + rule.setNotifyChildren(transientRule.isNotifyChildren()); + rule.setLogSuccessfulPublish(transientRule.isLogSuccessfulPublish()); + rule.setPublisherConfig(transientRule.getPublisherConfig()); + rule.setNotifyOn(transientRule.getNotifyOn()); + rule.setCronConfig(transientRule.getCronConfig()); + rule.setPublishOnlyWithUpdates(transientRule.getPublishOnlyWithUpdates()); + return persist(rule); + } + + public ScheduledNotificationRule updateScheduledNotificationRuleLastExecutionTimeToNowUtc(ScheduledNotificationRule transientRule) { + final ScheduledNotificationRule rule = getObjectByUuid(ScheduledNotificationRule.class, transientRule.getUuid()); + rule.setLastExecutionTime(ZonedDateTime.now(ZoneOffset.UTC)); + return persist(rule); + } /** * Returns a paginated list of all notification rules. @@ -116,18 +171,55 @@ public PaginatedResult getNotificationRules() { } return execute(query); } + + /** + * Returns a paginated list of all scheduled notification rules. + * @return a paginated list of ScheduledNotificationRules + */ + public PaginatedResult getScheduledNotificationRules() { + final Query query = pm.newQuery(ScheduledNotificationRule.class); + if (orderBy == null) { + query.setOrdering("name asc"); + } + if (filter != null) { + query.setFilter("name.toLowerCase().matches(:name) || publisher.name.toLowerCase().matches(:name)"); + final String filterString = ".*" + filter.toLowerCase() + ".*"; + return execute(query, filterString); + } + return execute(query); + } /** * Retrieves all NotificationPublishers. * This method if designed NOT to provide paginated results. * @return list of all NotificationPublisher objects */ - @SuppressWarnings("unchecked") public List getAllNotificationPublishers() { + return getAllNotificationPublishersOfType(PublishTrigger.ALL); + } + + /** + * Retrieves all NotificationPublishers matching the corresponding trigger type. + * This methoid is designed NOT to provide paginated results. + * @param trigger + * @return list of all matching NotificationPublisher objects + */ + public List getAllNotificationPublishersOfType(PublishTrigger trigger) { final Query query = pm.newQuery(NotificationPublisher.class); query.getFetchPlan().addGroup(NotificationPublisher.FetchGroup.ALL.name()); query.setOrdering("name asc"); - return (List)query.execute(); + switch (trigger) { + case SCHEDULE: + query.setFilter("publishScheduled == :publishScheduled"); + query.setParameters(true); + return List.copyOf(query.executeList()); + case EVENT: + query.setFilter("publishScheduled == :publishScheduled || publishScheduled == null"); + query.setParameters(false); + return List.copyOf(query.executeList()); + default: + return List.copyOf(query.executeList()); + } } /** @@ -146,8 +238,8 @@ public NotificationPublisher getNotificationPublisher(final String name) { * @param clazz The Class of the NotificationPublisher * @return a NotificationPublisher */ - public NotificationPublisher getDefaultNotificationPublisher(final Class clazz) { - return getDefaultNotificationPublisher(clazz.getCanonicalName()); + public NotificationPublisher getDefaultNotificationPublisher(final DefaultNotificationPublishers defaultPublisher) { + return getDefaultNotificationPublisher(defaultPublisher.getPublisherName(), defaultPublisher.getPublisherClass().getCanonicalName()); } /** @@ -155,11 +247,11 @@ public NotificationPublisher getDefaultNotificationPublisher(final Class query = pm.newQuery(NotificationPublisher.class, "publisherClass == :publisherClass && defaultPublisher == true"); + private NotificationPublisher getDefaultNotificationPublisher(final String publisherName, final String clazz) { + final Query query = pm.newQuery(NotificationPublisher.class, "name == :name && publisherClass == :publisherClass && defaultPublisher == true"); query.getFetchPlan().addGroup(NotificationPublisher.FetchGroup.ALL.name()); query.setRange(0, 1); - return singleResult(query.execute(clazz)); + return singleResult(query.execute(publisherName, clazz)); } /** @@ -169,17 +261,20 @@ private NotificationPublisher getDefaultNotificationPublisher(final String clazz */ public NotificationPublisher createNotificationPublisher(final String name, final String description, final Class publisherClass, final String templateContent, - final String templateMimeType, final boolean defaultPublisher) { - return callInTransaction(() -> { - final NotificationPublisher publisher = new NotificationPublisher(); - publisher.setName(name); - publisher.setDescription(description); - publisher.setPublisherClass(publisherClass.getName()); - publisher.setTemplate(templateContent); - publisher.setTemplateMimeType(templateMimeType); - publisher.setDefaultPublisher(defaultPublisher); - return pm.makePersistent(publisher); - }); + final String templateMimeType, final boolean defaultPublisher, final boolean publishScheduled) { + pm.currentTransaction().begin(); + final NotificationPublisher publisher = new NotificationPublisher(); + publisher.setName(name); + publisher.setDescription(description); + publisher.setPublisherClass(publisherClass.getName()); + publisher.setTemplate(templateContent); + publisher.setTemplateMimeType(templateMimeType); + publisher.setDefaultPublisher(defaultPublisher); + publisher.setPublishScheduled(publishScheduled); + pm.makePersistent(publisher); + pm.currentTransaction().commit(); + pm.getFetchPlan().addGroup(NotificationPublisher.FetchGroup.ALL.name()); + return getObjectById(NotificationPublisher.class, publisher.getId()); } /** @@ -191,7 +286,7 @@ public NotificationPublisher updateNotificationPublisher(NotificationPublisher t if (transientPublisher.getId() > 0) { publisher = getObjectById(NotificationPublisher.class, transientPublisher.getId()); } else if (transientPublisher.isDefaultPublisher()) { - publisher = getDefaultNotificationPublisher(transientPublisher.getPublisherClass()); + publisher = getDefaultNotificationPublisher(transientPublisher.getName(), transientPublisher.getPublisherClass()); } if (publisher != null) { publisher.setName(transientPublisher.getName()); diff --git a/src/main/java/org/dependencytrack/persistence/PolicyQueryManager.java b/src/main/java/org/dependencytrack/persistence/PolicyQueryManager.java index 3aa3b07190..d59a4dc8f6 100644 --- a/src/main/java/org/dependencytrack/persistence/PolicyQueryManager.java +++ b/src/main/java/org/dependencytrack/persistence/PolicyQueryManager.java @@ -23,6 +23,7 @@ import alpine.model.UserPrincipal; import alpine.persistence.PaginatedResult; import alpine.resources.AlpineRequest; + import org.dependencytrack.model.Component; import org.dependencytrack.model.ConfigPropertyConstants; import org.dependencytrack.model.License; @@ -36,9 +37,11 @@ import org.dependencytrack.model.ViolationAnalysisComment; import org.dependencytrack.model.ViolationAnalysisState; import org.dependencytrack.util.DateUtil; - import javax.jdo.PersistenceManager; import javax.jdo.Query; + +import java.time.ZoneOffset; +import java.time.ZonedDateTime; import java.util.ArrayList; import java.util.Collection; import java.util.Date; @@ -395,9 +398,9 @@ public List getAllPolicyViolations(final Project project) { /** * Returns a List of all Policy violations for a specific project. * @param project the project to retrieve violations for + * @param includeSuppressed Whether to include suppressed violations or not * @return a List of all Policy violations */ - @SuppressWarnings("unchecked") public PaginatedResult getPolicyViolations(final Project project, boolean includeSuppressed) { PaginatedResult result; final String queryFilter = includeSuppressed ? "project.id == :pid" : "project.id == :pid && (analysis.suppressed == false || analysis.suppressed == null)"; @@ -421,12 +424,63 @@ public PaginatedResult getPolicyViolations(final Project project, boolean includ return result; } + /** + * Returns a List of all Policy violations for a specific project. + * @param project the project to retrieve violations for + * @param includeSuppressed Whether to include suppressed violations or not + * @param since the date to retrieve violations since + * @return a List of all Policy violations + */ + public PaginatedResult getPolicyViolations(final Project project, boolean includeSuppressed, ZonedDateTime since) { + PaginatedResult result; + final String queryFilter = includeSuppressed + ? "project.id == :pid && timestamp >= :since" + : "project.id == :pid && (analysis.suppressed == false || analysis.suppressed == null) && timestamp >= :since"; + final Query query = pm.newQuery(PolicyViolation.class); + if (orderBy == null) { + query.setOrdering("timestamp desc, component.name, component.version"); + } + if (filter != null) { + query.setFilter(queryFilter + " && (policyCondition.policy.name.toLowerCase().matches(:filter) || component.name.toLowerCase().matches(:filter))"); + final String filterString = ".*" + filter.toLowerCase() + ".*"; + result = execute(query, project.getId(), Date.from(since.withZoneSameInstant(ZoneOffset.UTC).toInstant()), filterString); + } else { + query.setFilter(queryFilter); + result = execute(query, project.getId(), Date.from(since.withZoneSameInstant(ZoneOffset.UTC).toInstant())); + } + for (final PolicyViolation violation: result.getList(PolicyViolation.class)) { + violation.getPolicyCondition().getPolicy(); // force policy to ne included since its not the default + violation.getComponent().getResolvedLicense(); // force resolved license to ne included since its not the default + violation.setAnalysis(getViolationAnalysis(violation.getComponent(), violation)); // Include the violation analysis by default + } + return result; + } + + /** + * Returns a List of all Policy violations since a specified time. + * @param includeSuppressed Whether to include suppressed violations or not + * @param since the date to retrieve violations since + * @return a List of all Policy violations + */ + public List getAllPolicyViolations(boolean includeSuppressed, ZonedDateTime since) { + final Query query = pm.newQuery(PolicyViolation.class); + if(includeSuppressed){ + query.setFilter("timestamp >= :since"); + } else { + query.setFilter("(analysis.suppressed == false || analysis.suppressed == null) && timestamp >= :since"); + } + if (orderBy == null) { + query.setOrdering("timestamp desc, project.name, project.version, component.name, component.version"); + } + query.setParameters(Date.from(since.withZoneSameInstant(ZoneOffset.UTC).toInstant())); + return query.executeList(); + } + /** * Returns a List of all Policy violations for a specific component. * @param component the component to retrieve violations for * @return a List of all Policy violations */ - @SuppressWarnings("unchecked") public PaginatedResult getPolicyViolations(final Component component, boolean includeSuppressed) { final Query query = pm.newQuery(PolicyViolation.class); if (includeSuppressed) { @@ -450,7 +504,6 @@ public PaginatedResult getPolicyViolations(final Component component, boolean in * Returns a List of all Policy violations for the entire portfolio filtered by ACL and other optional filters. * @return a List of all Policy violations */ - @SuppressWarnings("unchecked") public PaginatedResult getPolicyViolations(boolean includeSuppressed, boolean showInactive, Map filters) { final PaginatedResult result; final Query query = pm.newQuery(PolicyViolation.class); diff --git a/src/main/java/org/dependencytrack/persistence/QueryManager.java b/src/main/java/org/dependencytrack/persistence/QueryManager.java index ac7db84931..3c494bf6b5 100644 --- a/src/main/java/org/dependencytrack/persistence/QueryManager.java +++ b/src/main/java/org/dependencytrack/persistence/QueryManager.java @@ -63,9 +63,11 @@ import org.dependencytrack.model.Project; import org.dependencytrack.model.ProjectMetrics; import org.dependencytrack.model.ProjectProperty; +import org.dependencytrack.model.PublishTrigger; import org.dependencytrack.model.Repository; import org.dependencytrack.model.RepositoryMetaComponent; import org.dependencytrack.model.RepositoryType; +import org.dependencytrack.model.ScheduledNotificationRule; import org.dependencytrack.model.ServiceComponent; import org.dependencytrack.model.Tag; import org.dependencytrack.model.Vex; @@ -78,6 +80,7 @@ import org.dependencytrack.model.VulnerabilityMetrics; import org.dependencytrack.model.VulnerableSoftware; import org.dependencytrack.notification.NotificationScope; +import org.dependencytrack.notification.publisher.DefaultNotificationPublishers; import org.dependencytrack.notification.publisher.Publisher; import org.dependencytrack.resources.v1.vo.AffectedProject; import org.dependencytrack.resources.v1.vo.DependencyGraphResponse; @@ -88,6 +91,7 @@ import javax.jdo.PersistenceManager; import javax.jdo.Query; import java.security.Principal; +import java.time.ZonedDateTime; import java.util.ArrayList; import java.util.Collection; import java.util.Collections; @@ -717,10 +721,18 @@ public List getAllPolicyViolations(final Project project) { return getPolicyQueryManager().getAllPolicyViolations(project); } + public Map> getNewPolicyViolationsForProjectsSince(ZonedDateTime zonedDateTime, List projectIds){ + return getPolicyQueryManager().getNewPolicyViolationsForProjectsSince(zonedDateTime, projectIds); + } + public PaginatedResult getPolicyViolations(final Project project, boolean includeSuppressed) { return getPolicyQueryManager().getPolicyViolations(project, includeSuppressed); } + public PaginatedResult getPolicyViolationsSince(final Project project, boolean includeSuppressed, ZonedDateTime sinceOccurred) { + return getPolicyQueryManager().getPolicyViolations(project, includeSuppressed, sinceOccurred); + } + public PaginatedResult getPolicyViolations(final Component component, boolean includeSuppressed) { return getPolicyQueryManager().getPolicyViolations(component, includeSuppressed); } @@ -729,6 +741,10 @@ public PaginatedResult getPolicyViolations(boolean includeSuppressed, boolean sh return getPolicyQueryManager().getPolicyViolations(includeSuppressed, showInactive, filters); } + public List getAllPolicyViolationsSince(boolean includeSuppressed, ZonedDateTime sinceOccurred) { + return getPolicyQueryManager().getAllPolicyViolations(includeSuppressed, sinceOccurred); + } + public ViolationAnalysis getViolationAnalysis(Component component, PolicyViolation policyViolation) { return getPolicyQueryManager().getViolationAnalysis(component, policyViolation); } @@ -814,6 +830,10 @@ public Vulnerability getVulnerabilityByVulnId(Vulnerability.Source source, Strin return getVulnerabilityQueryManager().getVulnerabilityByVulnId(source, vulnId, includeVulnerableSoftware); } + public Map> getNewVulnerabilitiesForProjectsSince(ZonedDateTime zonedDateTime, List projectIds){ + return getVulnerabilityQueryManager().getNewVulnerabilitiesForProjectsSince(zonedDateTime, projectIds); + } + public void addVulnerability(Vulnerability vulnerability, Component component, AnalyzerIdentity analyzerIdentity) { getVulnerabilityQueryManager().addVulnerability(vulnerability, component, analyzerIdentity); } @@ -1145,6 +1165,14 @@ public List getFindings(Project project, boolean includeSuppressed) { return getFindingsQueryManager().getFindings(project, includeSuppressed); } + public List getFindingsSince(Project project, boolean includeSuppressed, ZonedDateTime sinceAttributedOn) { + return getFindingsQueryManager().getFindings(project, includeSuppressed, sinceAttributedOn); + } + + public List getAllFindingsSince(boolean includeSuppressed, ZonedDateTime sinceAttributedOn) { + return getFindingsQueryManager().getFindings(includeSuppressed, sinceAttributedOn); + } + public PaginatedResult getAllFindings(final Map filters, final boolean showSuppressed, final boolean showInactive) { return getFindingsSearchQueryManager().getAllFindings(filters, showSuppressed, showInactive); } @@ -1257,18 +1285,28 @@ public List getAllNotificationPublishers() { return getNotificationQueryManager().getAllNotificationPublishers(); } + public List getAllNotificationPublishersOfType(PublishTrigger trigger) { + return getNotificationQueryManager().getAllNotificationPublishersOfType(trigger); + } + public NotificationPublisher getNotificationPublisher(final String name) { return getNotificationQueryManager().getNotificationPublisher(name); } - public NotificationPublisher getDefaultNotificationPublisher(final Class clazz) { - return getNotificationQueryManager().getDefaultNotificationPublisher(clazz); + public NotificationPublisher getDefaultNotificationPublisher(final DefaultNotificationPublishers defaultPublisher) { + return getNotificationQueryManager().getDefaultNotificationPublisher(defaultPublisher); } public NotificationPublisher createNotificationPublisher(final String name, final String description, final Class publisherClass, final String templateContent, final String templateMimeType, final boolean defaultPublisher) { - return getNotificationQueryManager().createNotificationPublisher(name, description, publisherClass, templateContent, templateMimeType, defaultPublisher); + return createNotificationPublisher(name, description, publisherClass, templateContent, templateMimeType, defaultPublisher, false); + } + + public NotificationPublisher createNotificationPublisher(final String name, final String description, + final Class publisherClass, final String templateContent, + final String templateMimeType, final boolean defaultPublisher, final boolean publishScheduled) { + return getNotificationQueryManager().createNotificationPublisher(name, description, publisherClass, templateContent, templateMimeType, defaultPublisher, publishScheduled); } public NotificationPublisher updateNotificationPublisher(NotificationPublisher transientPublisher) { @@ -1287,6 +1325,22 @@ public void removeTeamFromNotificationRules(final Team team) { getNotificationQueryManager().removeTeamFromNotificationRules(team); } + public ScheduledNotificationRule createScheduledNotificationRule(String name, NotificationScope scope, NotificationPublisher publisher) { + return getNotificationQueryManager().createScheduledNotificationRule(name, scope, publisher); + } + + public ScheduledNotificationRule updateScheduledNotificationRule(ScheduledNotificationRule transientRule) { + return getNotificationQueryManager().updateScheduledNotificationRule(transientRule); + } + + public ScheduledNotificationRule updateScheduledNotificationRuleLastExecutionTimeToNowUtc(ScheduledNotificationRule transientRule) { + return getNotificationQueryManager().updateScheduledNotificationRuleLastExecutionTimeToNowUtc(transientRule); + } + + public PaginatedResult getScheduledNotificationRules() { + return getNotificationQueryManager().getScheduledNotificationRules(); + } + /** * Determines if a config property is enabled or not. * @param configPropertyConstants the property to query diff --git a/src/main/java/org/dependencytrack/persistence/VulnerabilityQueryManager.java b/src/main/java/org/dependencytrack/persistence/VulnerabilityQueryManager.java index 4594e539d8..47c6cb6b4c 100644 --- a/src/main/java/org/dependencytrack/persistence/VulnerabilityQueryManager.java +++ b/src/main/java/org/dependencytrack/persistence/VulnerabilityQueryManager.java @@ -22,6 +22,7 @@ import alpine.persistence.PaginatedResult; import alpine.resources.AlpineRequest; import org.apache.commons.lang3.StringUtils; +import org.datanucleus.api.jdo.JDOQuery; import org.dependencytrack.event.IndexEvent; import org.dependencytrack.model.AffectedVersionAttribution; import org.dependencytrack.model.Analysis; @@ -37,6 +38,9 @@ import javax.jdo.PersistenceManager; import javax.jdo.Query; + +import java.time.ZoneOffset; +import java.time.ZonedDateTime; import java.util.ArrayList; import java.util.Collection; import java.util.Collections; @@ -180,6 +184,50 @@ public Vulnerability getVulnerabilityByVulnId(Vulnerability.Source source, Strin return getVulnerabilityByVulnId(source.name(), vulnId, includeVulnerableSoftware); } + /** + * Returns vulnerabilities for the specified npm module + * @param module the NPM module to query on + * @return a list of Vulnerability objects + */ + @Deprecated + @SuppressWarnings("unchecked") + //todo: determine if this is needed and delete + public List getVulnerabilitiesForNpmModule(String module) { + final Query query = pm.newQuery(Vulnerability.class, "source == :source && subtitle == :module"); + query.getFetchPlan().addGroup(Vulnerability.FetchGroup.COMPONENTS.name()); + return (List) query.execute(Vulnerability.Source.NPM.name(), module); + } + + @SuppressWarnings("unchecked") + public Map> getNewVulnerabilitiesForProjectsSince(ZonedDateTime lastExecution, List projectIds){ + String queryString = "SELECT PROJECT_ID, VULNERABILITY_ID " + + "FROM FINDINGATTRIBUTION " + + "WHERE ATTRIBUTED_ON BETWEEN ? AND ? "; + boolean hasProjectLimitation = projectIds != null && !projectIds.isEmpty(); + if(hasProjectLimitation){ + queryString.concat("AND PROJECT_ID IN ? "); + } + queryString.concat("ORDER BY PROJECT_ID ASC, VULNERABILITY_ID ASC"); + final Query query = pm.newQuery(JDOQuery.SQL_QUERY_LANGUAGE, queryString); + final List totalList = hasProjectLimitation + ? (List) query.execute(lastExecution, ZonedDateTime.now(ZoneOffset.UTC), projectIds) + : (List) query.execute(lastExecution, ZonedDateTime.now(ZoneOffset.UTC)); + Map> projectVulnerabilities = new HashMap<>(); + for(Object[] obj : totalList){ + Project project = getObjectById(Project.class, obj[0]); + Vulnerability vulnerability = getObjectById(Vulnerability.class, obj[1]); + if(project == null || vulnerability == null){ + continue; + } + var detachedVulnerability = pm.detachCopy(vulnerability); + if(!projectVulnerabilities.containsKey(project)){ + projectVulnerabilities.put(project, new ArrayList<>()); + } + projectVulnerabilities.get(project).add(detachedVulnerability); + } + return projectVulnerabilities; + } + /** * Adds a vulnerability to a component. * @param vulnerability the vulnerability to add diff --git a/src/main/java/org/dependencytrack/resources/v1/NotificationPublisherResource.java b/src/main/java/org/dependencytrack/resources/v1/NotificationPublisherResource.java index 105c660c96..5085ee5b50 100644 --- a/src/main/java/org/dependencytrack/resources/v1/NotificationPublisherResource.java +++ b/src/main/java/org/dependencytrack/resources/v1/NotificationPublisherResource.java @@ -39,13 +39,14 @@ import org.dependencytrack.model.ConfigPropertyConstants; import org.dependencytrack.model.NotificationPublisher; import org.dependencytrack.model.NotificationRule; +import org.dependencytrack.model.PublishTrigger; import org.dependencytrack.model.validation.ValidUuid; import org.dependencytrack.notification.NotificationConstants; import org.dependencytrack.notification.NotificationGroup; import org.dependencytrack.notification.NotificationScope; +import org.dependencytrack.notification.publisher.DefaultNotificationPublishers; import org.dependencytrack.notification.publisher.PublishContext; import org.dependencytrack.notification.publisher.Publisher; -import org.dependencytrack.notification.publisher.SendMailPublisher; import org.dependencytrack.persistence.QueryManager; import org.dependencytrack.util.NotificationUtil; @@ -107,6 +108,47 @@ public Response getAllNotificationPublishers() { } } + @GET + @Path("/event") + @Produces(MediaType.APPLICATION_JSON) + @Operation( + summary = "Returns a list of all event-driven notification publishers", + description = "

Requires permission SYSTEM_CONFIGURATION

" + ) + @ApiResponses(value = { + @ApiResponse(responseCode = "401", description = "Unauthorized") + }) + @PermissionRequired(Permissions.Constants.SYSTEM_CONFIGURATION) + public Response getAllEventNotificationPublishers() { + try (QueryManager qm = new QueryManager()) { + final List publishers = qm.getAllNotificationPublishersOfType(PublishTrigger.EVENT); + return Response.ok(publishers).build(); + } + } + + @GET + @Path("/scheduled") + @Produces(MediaType.APPLICATION_JSON) + @Operation( + summary = "Returns a list of all scheduled notification publishers", + description = "

Requires permission SYSTEM_CONFIGURATION

" + ) + @ApiResponses(value = { + @ApiResponse( + responseCode = "200", + description = "A list of all notification publishers", + content = @Content(array = @ArraySchema(schema = @Schema(implementation = NotificationPublisher.class))) + ), + @ApiResponse(responseCode = "401", description = "Unauthorized") + }) + @PermissionRequired(Permissions.Constants.SYSTEM_CONFIGURATION) + public Response getAllScheduledNotificationPublishers() { + try (QueryManager qm = new QueryManager()) { + final List publishers = qm.getAllNotificationPublishersOfType(PublishTrigger.SCHEDULE); + return Response.ok(publishers).build(); + } + } + @PUT @Consumes(MediaType.APPLICATION_JSON) @Produces(MediaType.APPLICATION_JSON) @@ -152,7 +194,8 @@ public Response createNotificationPublisher(NotificationPublisher jsonNotificati publisherClass, jsonNotificationPublisher.getTemplate(), jsonNotificationPublisher.getTemplateMimeType(), - jsonNotificationPublisher.isDefaultPublisher() + jsonNotificationPublisher.isDefaultPublisher(), + jsonNotificationPublisher.isPublishScheduled() ); return Response.status(Response.Status.CREATED).entity(notificationPublisherCreated).build(); } catch (ClassCastException e) { @@ -304,9 +347,9 @@ public Response restoreDefaultTemplates() { @PermissionRequired(Permissions.Constants.SYSTEM_CONFIGURATION) public Response testSmtpPublisherConfig(@FormParam("destination") String destination) { try(QueryManager qm = new QueryManager()) { - Class defaultEmailPublisherClass = SendMailPublisher.class; - NotificationPublisher emailNotificationPublisher = qm.getDefaultNotificationPublisher(defaultEmailPublisherClass); - final Publisher emailPublisher = defaultEmailPublisherClass.getDeclaredConstructor().newInstance(); + DefaultNotificationPublishers defaultEmailPublisher = DefaultNotificationPublishers.EMAIL; + NotificationPublisher emailNotificationPublisher = qm.getDefaultNotificationPublisher(defaultEmailPublisher); + final Publisher emailPublisher = (Publisher) defaultEmailPublisher.getPublisherClass().getDeclaredConstructor().newInstance(); final JsonObject config = Json.createObjectBuilder() .add(Publisher.CONFIG_DESTINATION, destination) .add(Publisher.CONFIG_TEMPLATE_KEY, emailNotificationPublisher.getTemplate()) diff --git a/src/main/java/org/dependencytrack/resources/v1/NotificationRuleResource.java b/src/main/java/org/dependencytrack/resources/v1/NotificationRuleResource.java index 3b11cdb59c..f229109c2c 100644 --- a/src/main/java/org/dependencytrack/resources/v1/NotificationRuleResource.java +++ b/src/main/java/org/dependencytrack/resources/v1/NotificationRuleResource.java @@ -18,7 +18,6 @@ */ package org.dependencytrack.resources.v1; -import alpine.common.logging.Logger; import alpine.model.Team; import alpine.persistence.PaginatedResult; import alpine.server.auth.PermissionRequired; @@ -72,8 +71,6 @@ }) public class NotificationRuleResource extends AlpineResource { - private static final Logger LOGGER = Logger.getLogger(NotificationRuleResource.class); - @GET @Produces(MediaType.APPLICATION_JSON) @Operation( @@ -112,7 +109,7 @@ public Response getAllNotificationRules() { content = @Content(schema = @Schema(implementation = NotificationRule.class)) ), @ApiResponse(responseCode = "401", description = "Unauthorized"), - @ApiResponse(responseCode = "404", description = "The UUID of the notification publisher could not be found") + @ApiResponse(responseCode = "404", description = "The UUID of the notification rule could not be found") }) @PermissionRequired(Permissions.Constants.SYSTEM_CONFIGURATION) public Response createNotificationRule(NotificationRule jsonRule) { @@ -127,7 +124,7 @@ public Response createNotificationRule(NotificationRule jsonRule) { publisher =qm.getObjectByUuid(NotificationPublisher.class, jsonRule.getPublisher().getUuid()); } if (publisher == null) { - return Response.status(Response.Status.NOT_FOUND).entity("The UUID of the notification publisher could not be found.").build(); + return Response.status(Response.Status.NOT_FOUND).entity("The UUID of the notification rule could not be found.").build(); } final NotificationRule rule = qm.createNotificationRule( StringUtils.trimToNull(jsonRule.getName()), diff --git a/src/main/java/org/dependencytrack/resources/v1/ScheduledNotificationRuleResource.java b/src/main/java/org/dependencytrack/resources/v1/ScheduledNotificationRuleResource.java new file mode 100644 index 0000000000..628f55c08d --- /dev/null +++ b/src/main/java/org/dependencytrack/resources/v1/ScheduledNotificationRuleResource.java @@ -0,0 +1,461 @@ +/* + * 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; + +import java.util.List; +import java.util.concurrent.TimeUnit; + +import org.apache.commons.lang3.StringUtils; +import org.dependencytrack.auth.Permissions; +import org.dependencytrack.model.NotificationPublisher; +import org.dependencytrack.model.Project; +import org.dependencytrack.model.ScheduledNotificationRule; +import org.dependencytrack.model.validation.ValidUuid; +import org.dependencytrack.notification.NotificationScope; +import org.dependencytrack.notification.ScheduledNotificationTaskManager; +import org.dependencytrack.notification.publisher.SendMailPublisher; +import org.dependencytrack.persistence.QueryManager; +import org.dependencytrack.resources.v1.openapi.PaginatedApi; + +import com.asahaf.javacron.InvalidExpressionException; +import com.asahaf.javacron.Schedule; + +import alpine.common.logging.Logger; +import alpine.model.Team; +import alpine.persistence.PaginatedResult; +import alpine.server.auth.PermissionRequired; +import alpine.server.resources.AlpineResource; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.headers.Header; +import io.swagger.v3.oas.annotations.media.ArraySchema; +import io.swagger.v3.oas.annotations.media.Content; +import io.swagger.v3.oas.annotations.media.Schema; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.responses.ApiResponses; +import io.swagger.v3.oas.annotations.security.SecurityRequirement; +import io.swagger.v3.oas.annotations.security.SecurityRequirements; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.Validator; +import jakarta.ws.rs.Consumes; +import jakarta.ws.rs.DELETE; +import jakarta.ws.rs.GET; +import jakarta.ws.rs.POST; +import jakarta.ws.rs.PUT; +import jakarta.ws.rs.Path; +import jakarta.ws.rs.PathParam; +import jakarta.ws.rs.Produces; +import jakarta.ws.rs.core.MediaType; +import jakarta.ws.rs.core.Response; + +/** + * JAX-RS resources for processing scheduled notification rules. + */ +@Path("/v1/schedulednotification/rule") +@Tag(name = "schedulednotification") +@SecurityRequirements({ + @SecurityRequirement(name = "ApiKeyAuth"), + @SecurityRequirement(name = "BearerAuth") +}) +public class ScheduledNotificationRuleResource extends AlpineResource { + + private static final Logger LOGGER = Logger.getLogger(ScheduledNotificationRuleResource.class); + + @GET + @Produces(MediaType.APPLICATION_JSON) + @Operation( + summary = "Returns a list of all scheduled notification rules", + description = "

Requires permission SYSTEM_CONFIGURATION

" + ) + @PaginatedApi + @ApiResponses(value = { + @ApiResponse( + responseCode = "401", + description = "Unauthorized", + headers = @Header(name = TOTAL_COUNT_HEADER, description = "The total number of scheduled notification rules", schema = @Schema(format = "integer")), + content = @Content(array = @ArraySchema(schema = @Schema(implementation = ScheduledNotificationRule.class))) + ) + }) + @PermissionRequired(Permissions.Constants.SYSTEM_CONFIGURATION) + public Response getAllScheduledNotificationRules() { + try (QueryManager qm = new QueryManager(getAlpineRequest())) { + final PaginatedResult result = qm.getScheduledNotificationRules(); + return Response.ok(result.getObjects()).header(TOTAL_COUNT_HEADER, result.getTotal()).build(); + } + } + + @PUT + @Consumes(MediaType.APPLICATION_JSON) + @Produces(MediaType.APPLICATION_JSON) + @Operation( + summary = "Creates a new scheduled notification rule", + description = "

Requires permission SYSTEM_CONFIGURATION

" + ) + @ApiResponses(value = { + @ApiResponse( + responseCode = "201", + description = "The created scheduled notification rule", + content = @Content(schema = @Schema(implementation = ScheduledNotificationRule.class)) + ), + @ApiResponse(responseCode = "401", description = "Unauthorized"), + @ApiResponse(responseCode = "404", description = "The UUID of the scheduled notification rule could not be found") + }) + @PermissionRequired(Permissions.Constants.SYSTEM_CONFIGURATION) + public Response createScheduledNotificationRule(ScheduledNotificationRule jsonRule) { + final Validator validator = super.getValidator(); + failOnValidationError( + validator.validateProperty(jsonRule, "name"), + validator.validateProperty(jsonRule, "cronConfig"), + validator.validateProperty(jsonRule, "lastExecutionTime") + ); + + try (QueryManager qm = new QueryManager()) { + NotificationPublisher publisher = null; + if (jsonRule.getPublisher() != null) { + publisher =qm.getObjectByUuid(NotificationPublisher.class, jsonRule.getPublisher().getUuid()); + } + if (publisher == null) { + return Response.status(Response.Status.NOT_FOUND).entity("The UUID of the scheduled notification rule could not be found.").build(); + } + final ScheduledNotificationRule rule = qm.createScheduledNotificationRule( + StringUtils.trimToNull(jsonRule.getName()), + jsonRule.getScope(), + publisher + ); + + if(rule.isEnabled()) { + Schedule schedule; + try { + schedule = Schedule.create(jsonRule.getCronConfig()); + ScheduledNotificationTaskManager.scheduleNextRuleTask(rule.getUuid(), schedule); + } catch (InvalidExpressionException e) { + LOGGER.error("Cron expression is invalid: " + jsonRule.getCronConfig()); + return Response.status(Response.Status.BAD_REQUEST).entity("Invalid cron expression").build(); + } + } + + return Response.status(Response.Status.CREATED).entity(rule).build(); + } + } + + @POST + @Consumes(MediaType.APPLICATION_JSON) + @Produces(MediaType.APPLICATION_JSON) + @Operation( + summary = "Updates a scheduled notification rule", + description = "

Requires permission SYSTEM_CONFIGURATION

" + ) + @ApiResponses(value = { + @ApiResponse( + responseCode = "200", + description = "The updated scheduled notification rule", + content = @Content(schema = @Schema(implementation = ScheduledNotificationRule.class)) + ), + @ApiResponse(responseCode = "401", description = "Unauthorized"), + @ApiResponse(responseCode = "404", description = "The UUID of the scheduled notification rule could not be found") + }) + @PermissionRequired(Permissions.Constants.SYSTEM_CONFIGURATION) + public Response updateScheduledNotificationRule(ScheduledNotificationRule jsonRule) { + final Validator validator = super.getValidator(); + failOnValidationError( + validator.validateProperty(jsonRule, "name"), + validator.validateProperty(jsonRule, "publisherConfig"), + validator.validateProperty(jsonRule, "cronConfig"), + validator.validateProperty(jsonRule, "lastExecutionTime") + ); + + try (QueryManager qm = new QueryManager()) { + ScheduledNotificationRule rule = qm.getObjectByUuid(ScheduledNotificationRule.class, jsonRule.getUuid()); + if (rule != null) { + jsonRule.setName(StringUtils.trimToNull(jsonRule.getName())); + rule = qm.updateScheduledNotificationRule(jsonRule); + + try { + ScheduledNotificationTaskManager.cancelActiveRuleTask(jsonRule.getUuid()); + if (rule.isEnabled()) { + var schedule = Schedule.create(jsonRule.getCronConfig()); + ScheduledNotificationTaskManager.scheduleNextRuleTask(jsonRule.getUuid(), schedule); + } + } catch (InvalidExpressionException e) { + LOGGER.error("Cron expression is invalid: " + jsonRule.getCronConfig()); + return Response.status(Response.Status.BAD_REQUEST).entity("Invalid cron expression").build(); + } + + return Response.ok(rule).build(); + } else { + return Response.status(Response.Status.NOT_FOUND).entity("The UUID of the scheduled notification rule could not be found.").build(); + } + } + } + + @DELETE + @Consumes(MediaType.APPLICATION_JSON) + @Produces(MediaType.APPLICATION_JSON) + @Operation( + summary = "Deletes a scheduled notification rule", + description = "

Requires permission SYSTEM_CONFIGURATION

" + ) + @ApiResponses(value = { + @ApiResponse(responseCode = "204", description = "Scheduled notification rule removed successfully"), + @ApiResponse(responseCode = "401", description = "Unauthorized"), + @ApiResponse(responseCode = "404", description = "The UUID of the scheduled notification rule could not be found") + }) + @PermissionRequired(Permissions.Constants.SYSTEM_CONFIGURATION) + public Response deleteScheduledNotificationRule(ScheduledNotificationRule jsonRule) { + try (QueryManager qm = new QueryManager()) { + final ScheduledNotificationRule rule = qm.getObjectByUuid(ScheduledNotificationRule.class, jsonRule.getUuid()); + if (rule != null) { + qm.delete(rule); + + ScheduledNotificationTaskManager.cancelActiveRuleTask(jsonRule.getUuid()); + + return Response.status(Response.Status.NO_CONTENT).build(); + } else { + return Response.status(Response.Status.NOT_FOUND).entity("The UUID of the scheduled notification rule could not be found.").build(); + } + } + } + + @POST + @Path("/{ruleUuid}/project/{projectUuid}") + @Consumes(MediaType.APPLICATION_JSON) + @Produces(MediaType.APPLICATION_JSON) + @Operation( + summary = "Adds a project to a scheduled notification rule", + description = "

Requires permission SYSTEM_CONFIGURATION

" + ) + @ApiResponses(value = { + @ApiResponse( + responseCode = "200", + description = "The updated scheduled notification rule", + content = @Content(schema = @Schema(implementation = ScheduledNotificationRule.class)) + ), + @ApiResponse(responseCode = "304", description = "The rule already has the specified project assigned"), + @ApiResponse(responseCode = "401", description = "Unauthorized"), + @ApiResponse(responseCode = "404", description = "The scheduled notification rule or project could not be found") + }) + @PermissionRequired(Permissions.Constants.SYSTEM_CONFIGURATION) + public Response addProjectToRule( + @Parameter(description = "The UUID of the rule to add a project to", schema = @Schema(type = "string", format = "uuid"), required = true) + @PathParam("ruleUuid") @ValidUuid String ruleUuid, + @Parameter(description = "The UUID of the project to add to the rule", schema = @Schema(type = "string", format = "uuid"), required = true) + @PathParam("projectUuid") @ValidUuid String projectUuid) { + try (QueryManager qm = new QueryManager()) { + final ScheduledNotificationRule rule = qm.getObjectByUuid(ScheduledNotificationRule.class, ruleUuid); + if (rule == null) { + return Response.status(Response.Status.NOT_FOUND).entity("The scheduled notification rule could not be found.").build(); + } + if (rule.getScope() != NotificationScope.PORTFOLIO) { + return Response.status(Response.Status.NOT_ACCEPTABLE).entity("Project limitations are only possible on scheduled notification rules with PORTFOLIO scope.").build(); + } + final Project project = qm.getObjectByUuid(Project.class, projectUuid); + if (project == null) { + return Response.status(Response.Status.NOT_FOUND).entity("The project could not be found.").build(); + } + final List projects = rule.getProjects(); + if (projects != null && !projects.contains(project)) { + rule.getProjects().add(project); + qm.persist(rule); + return Response.ok(rule).build(); + } + return Response.status(Response.Status.NOT_MODIFIED).build(); + } + } + + @DELETE + @Path("/{ruleUuid}/project/{projectUuid}") + @Consumes(MediaType.APPLICATION_JSON) + @Produces(MediaType.APPLICATION_JSON) + @Operation( + summary = "Removes a project from a scheduled notification rule", + description = "

Requires permission SYSTEM_CONFIGURATION

" + ) + @ApiResponses(value = { + @ApiResponse( + responseCode = "200", + description = "The deleted scheduled notification rule", + content = @Content(schema = @Schema(implementation = ScheduledNotificationRule.class)) + ), + @ApiResponse(responseCode = "304", description = "The rule does not have the specified project assigned"), + @ApiResponse(responseCode = "401", description = "Unauthorized"), + @ApiResponse(responseCode = "404", description = "The scheduled notification rule or project could not be found") + }) + @PermissionRequired(Permissions.Constants.SYSTEM_CONFIGURATION) + public Response removeProjectFromRule( + @Parameter(description = "The UUID of the rule to remove the project from", schema = @Schema(type = "string", format = "uuid"), required = true) + @PathParam("ruleUuid") @ValidUuid String ruleUuid, + @Parameter(description = "The UUID of the project to remove from the rule", schema = @Schema(type = "string", format = "uuid"), required = true) + @PathParam("projectUuid") @ValidUuid String projectUuid) { + try (QueryManager qm = new QueryManager()) { + final ScheduledNotificationRule rule = qm.getObjectByUuid(ScheduledNotificationRule.class, ruleUuid); + if (rule == null) { + return Response.status(Response.Status.NOT_FOUND).entity("The scheduled notification rule could not be found.").build(); + } + if (rule.getScope() != NotificationScope.PORTFOLIO) { + return Response.status(Response.Status.NOT_ACCEPTABLE).entity("Project limitations are only possible on scheduled notification rules with PORTFOLIO scope.").build(); + } + final Project project = qm.getObjectByUuid(Project.class, projectUuid); + if (project == null) { + return Response.status(Response.Status.NOT_FOUND).entity("The project could not be found.").build(); + } + final List projects = rule.getProjects(); + if (projects != null && projects.contains(project)) { + rule.getProjects().remove(project); + qm.persist(rule); + return Response.ok(rule).build(); + } + return Response.status(Response.Status.NOT_MODIFIED).build(); + } + } + + @POST + @Path("/{ruleUuid}/team/{teamUuid}") + @Consumes(MediaType.APPLICATION_JSON) + @Produces(MediaType.APPLICATION_JSON) + @Operation( + summary = "Adds a team to a scheduled scheduled notification rule", + description = "

Requires permission SYSTEM_CONFIGURATION

" + ) + @ApiResponses(value = { + @ApiResponse( + responseCode = "200", + description = "The updated scheduled notification rule", + content = @Content(schema = @Schema(implementation = ScheduledNotificationRule.class)) + ), + @ApiResponse(responseCode = "304", description = "The rule already has the specified team assigned"), + @ApiResponse(responseCode = "401", description = "Unauthorized"), + @ApiResponse(responseCode = "404", description = "The scheduled notification rule or team could not be found") + }) + @PermissionRequired(Permissions.Constants.SYSTEM_CONFIGURATION) + public Response addTeamToRule( + @Parameter(description = "The UUID of the rule to add a team to", schema = @Schema(type = "string", format = "uuid"), required = true) + @PathParam("ruleUuid") @ValidUuid String ruleUuid, + @Parameter(description = "The UUID of the team to add to the rule", schema = @Schema(type = "string", format = "uuid"), required = true) + @PathParam("teamUuid") @ValidUuid String teamUuid) { + try (QueryManager qm = new QueryManager()) { + final ScheduledNotificationRule rule = qm.getObjectByUuid(ScheduledNotificationRule.class, ruleUuid); + if (rule == null) { + return Response.status(Response.Status.NOT_FOUND).entity("The scheduled notification rule could not be found.").build(); + } + if (!rule.getPublisher().getPublisherClass().equals(SendMailPublisher.class.getName())) { + return Response.status(Response.Status.NOT_ACCEPTABLE).entity("Team subscriptions are only possible on scheduled notification rules with EMAIL publisher.").build(); + } + final Team team = qm.getObjectByUuid(Team.class, teamUuid); + if (team == null) { + return Response.status(Response.Status.NOT_FOUND).entity("The team could not be found.").build(); + } + final List teams = rule.getTeams(); + if (teams != null && !teams.contains(team)) { + rule.getTeams().add(team); + qm.persist(rule); + return Response.ok(rule).build(); + } + return Response.status(Response.Status.NOT_MODIFIED).build(); + } + } + + @POST + @Path("/execute") + @Consumes(MediaType.APPLICATION_JSON) + @Produces(MediaType.APPLICATION_JSON) + @Operation( + summary = "Executes a scheduled notification rule instantly ignoring the cron expression", + description = "

Requires permission SYSTEM_CONFIGURATION

" + ) + @ApiResponses(value = { + @ApiResponse( + responseCode = "200", + description = "The updated scheduled notification rule", + content = @Content(schema = @Schema(implementation = ScheduledNotificationRule.class)) + ), + @ApiResponse(responseCode = "401", description = "Unauthorized"), + @ApiResponse(responseCode = "404", description = "The UUID of the scheduled notification rule could not be found") + }) + @PermissionRequired(Permissions.Constants.SYSTEM_CONFIGURATION) + public Response executeScheduledNotificationRuleNow(ScheduledNotificationRule jsonRule) { + try (QueryManager qm = new QueryManager()) { + ScheduledNotificationRule rule = qm.getObjectByUuid(ScheduledNotificationRule.class, jsonRule.getUuid()); + if (rule != null) { + try { + ScheduledNotificationTaskManager.cancelActiveRuleTask(rule.getUuid()); + if (rule.isEnabled()) { + // schedule must be passed too, to schedule the next execution according to cron expression again + var schedule = Schedule.create(rule.getCronConfig()); + ScheduledNotificationTaskManager.scheduleNextRuleTask(rule.getUuid(), schedule, 0, TimeUnit.MILLISECONDS); + } else { + ScheduledNotificationTaskManager.scheduleNextRuleTaskOnce(rule.getUuid(), 0, TimeUnit.MILLISECONDS); + } + } catch (InvalidExpressionException e) { + LOGGER.error("Cron expression is invalid: " + rule.getCronConfig()); + return Response.status(Response.Status.BAD_REQUEST).entity("Invalid cron expression").build(); + } + + return Response.ok(rule).build(); + } else { + return Response.status(Response.Status.NOT_FOUND).entity("The UUID of the scheduled notification rule could not be found.").build(); + } + } + } + + @DELETE + @Path("/{ruleUuid}/team/{teamUuid}") + @Consumes(MediaType.APPLICATION_JSON) + @Produces(MediaType.APPLICATION_JSON) + @Operation( + summary = "Removes a team from a scheduled notification rule", + description = "

Requires permission SYSTEM_CONFIGURATION

" + ) + @ApiResponses(value = { + @ApiResponse( + responseCode = "200", + description = "The deleted scheduled notification rule", + content = @Content(schema = @Schema(implementation = ScheduledNotificationRule.class)) + ), + @ApiResponse(responseCode = "304", description = "The rule does not have the specified team assigned"), + @ApiResponse(responseCode = "401", description = "Unauthorized"), + @ApiResponse(responseCode = "404", description = "The scheduled notification rule or team could not be found") + }) + @PermissionRequired(Permissions.Constants.SYSTEM_CONFIGURATION) + public Response removeTeamFromRule( + @Parameter(description = "The UUID of the rule to remove the project from", schema = @Schema(type = "string", format = "uuid"), required = true) + @PathParam("ruleUuid") @ValidUuid String ruleUuid, + @Parameter(description = "The UUID of the project to remove from the rule", schema = @Schema(type = "string", format = "uuid"), required = true) + @PathParam("teamUuid") @ValidUuid String teamUuid) { + try (QueryManager qm = new QueryManager()) { + final ScheduledNotificationRule rule = qm.getObjectByUuid(ScheduledNotificationRule.class, ruleUuid); + if (rule == null) { + return Response.status(Response.Status.NOT_FOUND).entity("The scheduled notification rule could not be found.").build(); + } + if (!rule.getPublisher().getPublisherClass().equals(SendMailPublisher.class.getName())) { + return Response.status(Response.Status.NOT_ACCEPTABLE).entity("Team subscriptions are only possible on scheduled notification rules with EMAIL publisher.").build(); + } + final Team team = qm.getObjectByUuid(Team.class, teamUuid); + if (team == null) { + return Response.status(Response.Status.NOT_FOUND).entity("The team could not be found.").build(); + } + final List teams = rule.getTeams(); + if (teams != null && teams.contains(team)) { + rule.getTeams().remove(team); + qm.persist(rule); + return Response.ok(rule).build(); + } + return Response.status(Response.Status.NOT_MODIFIED).build(); + } + } +} \ No newline at end of file diff --git a/src/main/java/org/dependencytrack/resources/v1/serializers/Iso8601ZonedDateTimeSerializer.java b/src/main/java/org/dependencytrack/resources/v1/serializers/Iso8601ZonedDateTimeSerializer.java new file mode 100644 index 0000000000..0716a7d1c9 --- /dev/null +++ b/src/main/java/org/dependencytrack/resources/v1/serializers/Iso8601ZonedDateTimeSerializer.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.serializers; + +import java.io.IOException; +import java.time.ZonedDateTime; +import org.dependencytrack.util.ZonedDateTimeUtil; + +import com.fasterxml.jackson.core.JsonGenerator; +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.SerializerProvider; +import com.fasterxml.jackson.databind.ser.std.StdSerializer; + +public class Iso8601ZonedDateTimeSerializer extends StdSerializer { + + public Iso8601ZonedDateTimeSerializer() { + this(null); + } + + public Iso8601ZonedDateTimeSerializer(Class t) { + super(t); + } + + @Override + public void serialize(ZonedDateTime value, JsonGenerator gen, SerializerProvider arg2) + throws IOException, JsonProcessingException { + gen.writeString(ZonedDateTimeUtil.toISO8601(value)); + } + +} diff --git a/src/main/java/org/dependencytrack/tasks/ActionOnDoneFutureTask.java b/src/main/java/org/dependencytrack/tasks/ActionOnDoneFutureTask.java new file mode 100644 index 0000000000..f926fa11b9 --- /dev/null +++ b/src/main/java/org/dependencytrack/tasks/ActionOnDoneFutureTask.java @@ -0,0 +1,44 @@ +/* + * 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.tasks; + +import java.util.concurrent.FutureTask; + +import alpine.common.logging.Logger; + +public class ActionOnDoneFutureTask extends FutureTask { + private static final Logger LOGGER = Logger.getLogger(ActionOnDoneFutureTask.class); + private final Runnable action; + + public ActionOnDoneFutureTask(Runnable runnable, Runnable actionOnDone) { + super(runnable, null); + this.action = actionOnDone; + } + + @Override + protected void done() { + super.done(); + try { + this.action.run(); + } catch (Exception e) { + // just catch and log, do not interfere with completion + LOGGER.warn(e.toString()); + } + } +} diff --git a/src/main/java/org/dependencytrack/tasks/SendScheduledNotificationTask.java b/src/main/java/org/dependencytrack/tasks/SendScheduledNotificationTask.java new file mode 100644 index 0000000000..16d22dc5fb --- /dev/null +++ b/src/main/java/org/dependencytrack/tasks/SendScheduledNotificationTask.java @@ -0,0 +1,166 @@ +/* + * 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.tasks; + +import java.io.StringReader; +import java.lang.reflect.InvocationTargetException; +import java.time.ZoneId; +import java.time.ZonedDateTime; +import java.time.format.DateTimeFormatter; +import java.util.UUID; +import jakarta.json.Json; +import jakarta.json.JsonObject; +import jakarta.json.JsonReader; +import org.dependencytrack.exception.PublisherException; +import org.dependencytrack.model.NotificationPublisher; +import org.dependencytrack.model.ScheduledNotificationRule; +import org.dependencytrack.notification.NotificationGroup; +import org.dependencytrack.notification.ScheduledNotificationFactory; +import org.dependencytrack.notification.publisher.PublishContext; +import org.dependencytrack.notification.publisher.Publisher; +import org.dependencytrack.notification.publisher.SendMailPublisher; +import org.dependencytrack.notification.vo.ScheduledNewVulnerabilitiesIdentified; +import org.dependencytrack.notification.vo.ScheduledPolicyViolationsIdentified; +import org.dependencytrack.persistence.QueryManager; + +import alpine.common.logging.Logger; +import alpine.notification.Notification; +import alpine.notification.NotificationLevel; +import static org.dependencytrack.notification.publisher.Publisher.CONFIG_TEMPLATE_KEY; +import static org.dependencytrack.notification.publisher.Publisher.CONFIG_TEMPLATE_MIME_TYPE_KEY; + +/* + * The scheduled notification task is responsible for processing scheduled notifications and publishing them with the configured publisher. + * This task must be executed by the scheduler at the defined cron interval of the referenced scheduled notification rule. + */ +public class SendScheduledNotificationTask implements Runnable { + private UUID scheduledNotificationRuleUuid; + private static final Logger LOGGER = Logger.getLogger(SendScheduledNotificationTask.class); + + public SendScheduledNotificationTask(UUID scheduledNotificationRuleUuid) { + this.scheduledNotificationRuleUuid = scheduledNotificationRuleUuid; + } + + @Override + public void run() { + try (var qm = new QueryManager()) { + var rule = qm.getObjectByUuid(ScheduledNotificationRule.class, scheduledNotificationRuleUuid); + rule.setNotificationLevel(NotificationLevel.INFORMATIONAL); // not persistent, set manually to avoid null reference exception in PublishContext + Boolean errorsDuringExecution = false; + Boolean atLeastOneSuccessfulPublish = false; + + LOGGER.info("Processing notification publishing for scheduled notification rule " + rule.getUuid()); + final ZonedDateTime lastExecutionTime = rule.getLastExecutionTime(); + + for (NotificationGroup group : rule.getNotifyOn()) { + final Notification notificationProxy = new Notification() + .scope(rule.getScope()) + .group(group) + .level(NotificationLevel.INFORMATIONAL); + + switch (group) { + case NEW_VULNERABILITY: + ScheduledNewVulnerabilitiesIdentified vulnSubject = ScheduledNotificationFactory.CreateScheduledVulnerabilitySubject(rule, lastExecutionTime); + if(vulnSubject.overview().newVulnerabilitiesCount() == 0 && rule.getPublishOnlyWithUpdates()) + continue; + notificationProxy + .title(vulnSubject.overview().newVulnerabilitiesCount() + " new Vulnerabilitie(s) in " + vulnSubject.overview().affectedComponentsCount() + " component(s) in Scheduled Rule '" + rule.getName() + "'") + .content("Find below a summary of new vulnerabilities since " + + lastExecutionTime.withZoneSameInstant(ZoneId.systemDefault()).format(DateTimeFormatter.ISO_LOCAL_DATE_TIME) + + " in Scheduled Notification Rule '" + rule.getName() + "'.") + .subject(vulnSubject); + break; + case POLICY_VIOLATION: + ScheduledPolicyViolationsIdentified policySubject = ScheduledNotificationFactory.CreateScheduledPolicyViolationSubject(rule, lastExecutionTime); + if(policySubject.overview().newViolationsCount() == 0 && rule.getPublishOnlyWithUpdates()) + continue; + notificationProxy + .title(policySubject.overview().newViolationsCount() + " new Policy Violation(s) in " + policySubject.overview().affectedComponentsCount() + " component(s) in Scheduled Rule '" + rule.getName() + "'") + .content("Find below a summary of new policy violations since " + + lastExecutionTime.withZoneSameInstant(ZoneId.systemDefault()).format(DateTimeFormatter.ISO_LOCAL_DATE_TIME) + + " in Scheduled Notification Rule '" + rule.getName() + "'.") + .subject(policySubject); + break; + default: + LOGGER.warn(group.name() + " is not a supported notification group for scheduled publishing"); + errorsDuringExecution |= true; + continue; + } + + final PublishContext ctx = PublishContext.from(notificationProxy); + final PublishContext ruleCtx =ctx.withRule(rule); + + // Not all publishers need configuration (i.e. ConsolePublisher) + JsonObject config = Json.createObjectBuilder().build(); + if (rule.getPublisherConfig() != null) { + try (StringReader stringReader = new StringReader(rule.getPublisherConfig()); + 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 (%s)".formatted(ruleCtx), e); + errorsDuringExecution |= true; + } + } + try { + NotificationPublisher notificationPublisher = rule.getPublisher(); + final Class publisherClass = Class.forName(notificationPublisher.getPublisherClass()); + if (Publisher.class.isAssignableFrom(publisherClass)) { + final Publisher publisher = (Publisher) publisherClass.getDeclaredConstructor().newInstance(); + JsonObject notificationPublisherConfig = Json.createObjectBuilder() + .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, notificationProxy, notificationPublisherConfig); + } else { + ((SendMailPublisher) publisher).inform(ruleCtx, notificationProxy, notificationPublisherConfig, rule.getTeams()); + } + atLeastOneSuccessfulPublish |= true; + } else { + LOGGER.error("The defined notification publisher is not assignable from " + Publisher.class.getCanonicalName() + " (%s)".formatted(ruleCtx)); + errorsDuringExecution |= true; + } + } catch (ClassNotFoundException | NoSuchMethodException | InstantiationException + | InvocationTargetException | IllegalAccessException e) { + LOGGER.error("An error occurred while instantiating a notification publisher (%s)".formatted(ruleCtx), e); + errorsDuringExecution |= true; + } catch (PublisherException publisherException) { + LOGGER.error("An error occurred during the publication of the notification (%s)".formatted(ruleCtx), publisherException); + errorsDuringExecution |= true; + } + } + if (!errorsDuringExecution || atLeastOneSuccessfulPublish) { + /* + * Update last execution time after successful operation (even without + * publishing) to avoid duplicate notifications in the next run and signalize + * user indirectly, that operation has ended without failure + */ + qm.updateScheduledNotificationRuleLastExecutionTimeToNowUtc(rule); + LOGGER.info("Successfuly processed notification publishing for scheduled notification rule " + scheduledNotificationRuleUuid); + } + else { + LOGGER.error("Errors occured while processing notification publishing for scheduled notification rule " + scheduledNotificationRuleUuid); + } + } + catch (Exception e) { + LOGGER.error("An error occurred while processing scheduled notification rule " + scheduledNotificationRuleUuid, e); + } + } +} diff --git a/src/main/java/org/dependencytrack/util/JsonUtil.java b/src/main/java/org/dependencytrack/util/JsonUtil.java index 5bf7e3c370..afaeade08d 100644 --- a/src/main/java/org/dependencytrack/util/JsonUtil.java +++ b/src/main/java/org/dependencytrack/util/JsonUtil.java @@ -38,6 +38,13 @@ public static JsonObjectBuilder add(final JsonObjectBuilder builder, final Strin return builder; } + public static JsonObjectBuilder add(final JsonObjectBuilder builder, final String key, final Integer value) { + if (value != null) { + builder.add(key, value); + } + return builder; + } + public static JsonObjectBuilder add(final JsonObjectBuilder builder, final String key, final BigInteger value) { if (value != null) { builder.add(key, value); @@ -51,6 +58,13 @@ public static JsonObjectBuilder add(final JsonObjectBuilder builder, final Strin } return builder; } + + public static JsonObjectBuilder add(final JsonObjectBuilder builder, final String key, final Boolean value) { + if (value != null) { + builder.add(key, value); + } + return builder; + } public static JsonObjectBuilder add(final JsonObjectBuilder builder, final String key, final Enum value) { if (value != null) { diff --git a/src/main/java/org/dependencytrack/util/NotificationUtil.java b/src/main/java/org/dependencytrack/util/NotificationUtil.java index 32a74388bf..6a6eb6ebc5 100644 --- a/src/main/java/org/dependencytrack/util/NotificationUtil.java +++ b/src/main/java/org/dependencytrack/util/NotificationUtil.java @@ -36,6 +36,7 @@ import org.dependencytrack.model.PolicyCondition.Operator; import org.dependencytrack.model.PolicyViolation; import org.dependencytrack.model.Project; +import org.dependencytrack.model.Rule; import org.dependencytrack.model.Severity; import org.dependencytrack.model.Tag; import org.dependencytrack.model.Vex; @@ -43,6 +44,15 @@ import org.dependencytrack.model.ViolationAnalysisState; import org.dependencytrack.model.Vulnerability; import org.dependencytrack.model.VulnerabilityAnalysisLevel; +import org.dependencytrack.model.scheduled.policyviolations.PolicyViolationDetails; +import org.dependencytrack.model.scheduled.policyviolations.PolicyViolationOverview; +import org.dependencytrack.model.scheduled.policyviolations.PolicyViolationSummary; +import org.dependencytrack.model.scheduled.policyviolations.PolicyViolationSummaryInfo; +import org.dependencytrack.model.scheduled.vulnerabilities.VulnerabilityDetails; +import org.dependencytrack.model.scheduled.vulnerabilities.VulnerabilityDetailsInfo; +import org.dependencytrack.model.scheduled.vulnerabilities.VulnerabilityOverview; +import org.dependencytrack.model.scheduled.vulnerabilities.VulnerabilitySummary; +import org.dependencytrack.model.scheduled.vulnerabilities.VulnerabilitySummaryInfo; import org.dependencytrack.notification.NotificationConstants; import org.dependencytrack.notification.NotificationGroup; import org.dependencytrack.notification.NotificationScope; @@ -54,6 +64,8 @@ import org.dependencytrack.notification.vo.NewVulnerabilityIdentified; import org.dependencytrack.notification.vo.NewVulnerableDependency; import org.dependencytrack.notification.vo.PolicyViolationIdentified; +import org.dependencytrack.notification.vo.ScheduledNewVulnerabilitiesIdentified; +import org.dependencytrack.notification.vo.ScheduledPolicyViolationsIdentified; import org.dependencytrack.notification.vo.VexConsumedOrProcessed; import org.dependencytrack.notification.vo.ViolationAnalysisDecisionChange; import org.dependencytrack.parser.common.resolver.CweResolver; @@ -65,11 +77,15 @@ import jakarta.json.JsonObject; import jakarta.json.JsonObjectBuilder; import javax.jdo.FetchPlan; +import jakarta.json.JsonValue; + import java.io.File; import java.io.IOException; import java.net.URLDecoder; import java.nio.file.Path; import java.util.Date; +import java.time.ZonedDateTime; +import java.time.temporal.ChronoUnit; import java.util.HashMap; import java.util.HashSet; import java.util.List; @@ -564,6 +580,172 @@ public static JsonObject toJson(final Policy policy) { return builder.build(); } + public static JsonObject toJson(final ScheduledNewVulnerabilitiesIdentified vo) { + final JsonObjectBuilder builder = Json.createObjectBuilder(); + builder.add("overview", toJson(vo.overview())); + builder.add("summary", toJson(vo.summary())); + builder.add("details", toJson(vo.details())); + return builder.build(); + } + + public static JsonObject toJson(final VulnerabilityOverview overview) { + final JsonObjectBuilder builder = Json.createObjectBuilder(); + JsonUtil.add(builder, "affectedProjectsCount", overview.affectedProjectsCount()); + JsonUtil.add(builder, "newVulnerabilitiesCount", overview.newVulnerabilitiesCount()); + JsonUtil.add(builder, "affectedComponentsCount", overview.affectedComponentsCount()); + JsonUtil.add(builder, "suppressedNewVulnerabilitiesCount", overview.suppressedNewVulnerabilitiesCount()); + final JsonObjectBuilder newVulnerabilitiesBySeverityBuilder = Json.createObjectBuilder(); + for (final Map.Entry entry : overview.newVulnerabilitiesBySeverity().entrySet()) { + JsonUtil.add(newVulnerabilitiesBySeverityBuilder, entry.getKey().name(), entry.getValue()); + } + builder.add("newVulnerabilitiesBySeverity", newVulnerabilitiesBySeverityBuilder.build()); + return builder.build(); + } + + public static JsonObject toJson(final VulnerabilitySummary summary){ + final JsonObjectBuilder builder = Json.createObjectBuilder(); + final JsonArrayBuilder summaryBuilder = Json.createArrayBuilder(); + for (final Map.Entry entry : summary.affectedProjectSummaries().entrySet()) { + summaryBuilder.add(Json.createObjectBuilder() + .add("project", toJson(entry.getKey())) + .add("summary", toJson(entry.getValue())) + .build()); + } + builder.add("projectSummaries", summaryBuilder.build()); + return builder.build(); + } + + private static JsonValue toJson(VulnerabilitySummaryInfo info) { + final JsonObjectBuilder builder = Json.createObjectBuilder(); + + final JsonObjectBuilder newVulnerabilitiesBySeverityBuilder = Json.createObjectBuilder(); + for (final Map.Entry entry : info.newVulnerabilitiesBySeverity().entrySet()) { + JsonUtil.add(newVulnerabilitiesBySeverityBuilder, entry.getKey().name(), entry.getValue()); + } + builder.add("newVulnerabilitiesBySeverity", newVulnerabilitiesBySeverityBuilder.build()); + + final JsonObjectBuilder totalProjectVulnerabilitiesBySeverityBuilder = Json.createObjectBuilder(); + for (final Map.Entry entry : info.totalProjectVulnerabilitiesBySeverity().entrySet()) { + JsonUtil.add(totalProjectVulnerabilitiesBySeverityBuilder, entry.getKey().name(), entry.getValue()); + } + builder.add("totalProjectVulnerabilitiesBySeverity", totalProjectVulnerabilitiesBySeverityBuilder.build()); + + final JsonObjectBuilder suppressedNewVulnerabilitiesBySeverityBuilder = Json.createObjectBuilder(); + for (final Map.Entry entry : info.suppressedNewVulnerabilitiesBySeverity().entrySet()) { + JsonUtil.add(suppressedNewVulnerabilitiesBySeverityBuilder, entry.getKey().name(), entry.getValue()); + } + builder.add("suppressedNewVulnerabilitiesBySeverity", suppressedNewVulnerabilitiesBySeverityBuilder.build()); + + return builder.build(); + } + + private static JsonObject toJson(VulnerabilityDetails details) { + final JsonObjectBuilder builder = Json.createObjectBuilder(); + final JsonArrayBuilder affectedProjectFindingsBuilder = Json.createArrayBuilder(); + for (final Map.Entry> entry : details.affectedProjectFindings().entrySet()) { + final JsonArrayBuilder findingsBuilder = Json.createArrayBuilder(); + for (final VulnerabilityDetailsInfo detailInfo : entry.getValue()) { + var findingBuilder = Json.createObjectBuilder(); + JsonUtil.add(findingBuilder, "componentUuid", detailInfo.componentUuid()); + JsonUtil.add(findingBuilder, "componentName", detailInfo.componentName()); + JsonUtil.add(findingBuilder, "componentVersion", detailInfo.componentVersion()); + JsonUtil.add(findingBuilder, "componentGroup", detailInfo.componentGroup()); + JsonUtil.add(findingBuilder, "vulnerabilitySource", detailInfo.vulnerabilitySource()); + JsonUtil.add(findingBuilder, "vulnerabilityId", detailInfo.vulnerabilityId()); + JsonUtil.add(findingBuilder, "vulnerabilitySeverity", detailInfo.vulnerabilitySeverity()); + JsonUtil.add(findingBuilder, "analyzer", detailInfo.analyzer()); + JsonUtil.add(findingBuilder, "attributionReferenceUrl", detailInfo.attributionReferenceUrl()); + JsonUtil.add(findingBuilder, "attributedOn", detailInfo.attributedOn()); + JsonUtil.add(findingBuilder, "analysisState", detailInfo.analysisState()); + JsonUtil.add(findingBuilder, "suppressed", detailInfo.suppressed()); + findingsBuilder.add(findingBuilder.build()); + } + affectedProjectFindingsBuilder.add(Json.createObjectBuilder() + .add("project", toJson(entry.getKey())) + .add("findings", findingsBuilder.build())); + } + builder.add("projectDetails", affectedProjectFindingsBuilder.build()); + return builder.build(); + } + + public static JsonObject toJson(ScheduledPolicyViolationsIdentified vo) { + final JsonObjectBuilder builder = Json.createObjectBuilder(); + builder.add("overview", toJson(vo.overview())); + builder.add("summary", toJson(vo.summary())); + builder.add("details", toJson(vo.details())); + return builder.build(); + } + + public static JsonObject toJson(final PolicyViolationOverview overview) { + final JsonObjectBuilder builder = Json.createObjectBuilder(); + JsonUtil.add(builder, "affectedProjectsCount", overview.affectedProjectsCount()); + JsonUtil.add(builder, "newViolationsCount", overview.newViolationsCount()); + JsonUtil.add(builder, "affectedComponentsCount", overview.affectedComponentsCount()); + JsonUtil.add(builder, "suppressedNewViolationsCount", overview.suppressedNewViolationsCount()); + final JsonObjectBuilder newViolationsByRiskTypeBuilder = Json.createObjectBuilder(); + for (final Map.Entry entry : overview.newViolationsByRiskType().entrySet()) { + JsonUtil.add(newViolationsByRiskTypeBuilder, entry.getKey().name(), entry.getValue()); + } + builder.add("newViolationsByRiskType", newViolationsByRiskTypeBuilder.build()); + return builder.build(); + } + + public static JsonObject toJson(final PolicyViolationSummary summary){ + final JsonObjectBuilder builder = Json.createObjectBuilder(); + final JsonArrayBuilder affectedProjectSummariesBuilder = Json.createArrayBuilder(); + for (final Map.Entry entry : summary.affectedProjectSummaries().entrySet()) { + affectedProjectSummariesBuilder.add(Json.createObjectBuilder() + .add("project", toJson(entry.getKey())) + .add("summary", toJson(entry.getValue())) + .build()); + } + builder.add("affectedProjectSummaries", affectedProjectSummariesBuilder.build()); + return builder.build(); + } + + private static JsonValue toJson(PolicyViolationSummaryInfo info) { + final JsonObjectBuilder builder = Json.createObjectBuilder(); + + final JsonObjectBuilder newViolationsByRiskTypeBuilder = Json.createObjectBuilder(); + for (final Map.Entry entry : info.newViolationsByRiskType().entrySet()) { + JsonUtil.add(newViolationsByRiskTypeBuilder, entry.getKey().name(), entry.getValue()); + } + builder.add("newViolationsByRiskType", newViolationsByRiskTypeBuilder.build()); + + final JsonObjectBuilder totalProjectViolationsByRiskTypeBuilder = Json.createObjectBuilder(); + for (final Map.Entry entry : info.totalProjectViolationsByRiskType().entrySet()) { + JsonUtil.add(totalProjectViolationsByRiskTypeBuilder, entry.getKey().name(), entry.getValue()); + } + builder.add("totalProjectViolationsByRiskType", totalProjectViolationsByRiskTypeBuilder.build()); + + final JsonObjectBuilder suppressedNewViolationsByRiskTypeBuilder = Json.createObjectBuilder(); + for (final Map.Entry entry : info.suppressedNewViolationsByRiskType().entrySet()) { + JsonUtil.add(suppressedNewViolationsByRiskTypeBuilder, entry.getKey().name(), entry.getValue()); + } + builder.add("suppressedNewViolationsByRiskType", suppressedNewViolationsByRiskTypeBuilder.build()); + + return builder.build(); + } + + private static JsonObject toJson(PolicyViolationDetails details) { + final JsonObjectBuilder builder = Json.createObjectBuilder(); + final JsonArrayBuilder affectedProjectViolationsBuilder = Json.createArrayBuilder(); + for (final Map.Entry> entry : details.affectedProjectViolations().entrySet()) { + final JsonArrayBuilder violationsBuilder = Json.createArrayBuilder(); + for (final PolicyViolation violation : entry.getValue()) { + violationsBuilder.add(Json.createObjectBuilder() + .add("component", toJson(violation.getComponent())) + .add("violation", toJson(violation))); + } + affectedProjectViolationsBuilder.add(Json.createObjectBuilder() + .add("project", toJson(entry.getKey())) + .add("violations", violationsBuilder.build()) + .build()); + } + builder.add("projectDetails", affectedProjectViolationsBuilder.build()); + return builder.build(); + } + public static void loadDefaultNotificationPublishers(QueryManager qm) throws IOException { for (final DefaultNotificationPublishers publisher : DefaultNotificationPublishers.values()) { File templateFile = new File(URLDecoder.decode(NotificationUtil.class.getResource(publisher.getPublisherTemplateFile()).getFile(), UTF_8.name())); @@ -578,12 +760,12 @@ public static void loadDefaultNotificationPublishers(QueryManager qm) throws IOE } } final String templateContent = FileUtils.readFileToString(templateFile, UTF_8); - final NotificationPublisher existingPublisher = qm.getDefaultNotificationPublisher(publisher.getPublisherClass()); + final NotificationPublisher existingPublisher = qm.getDefaultNotificationPublisher(publisher); if (existingPublisher == null) { qm.createNotificationPublisher( publisher.getPublisherName(), publisher.getPublisherDescription(), publisher.getPublisherClass(), templateContent, publisher.getTemplateMimeType(), - publisher.isDefaultPublisher() + publisher.isDefaultPublisher(), publisher.isPublishScheduled() ); } else { existingPublisher.setName(publisher.getPublisherName()); @@ -592,6 +774,7 @@ public static void loadDefaultNotificationPublishers(QueryManager qm) throws IOE existingPublisher.setTemplate(templateContent); existingPublisher.setTemplateMimeType(publisher.getTemplateMimeType()); existingPublisher.setDefaultPublisher(publisher.isDefaultPublisher()); + existingPublisher.setPublishScheduled(publisher.isPublishScheduled()); qm.updateNotificationPublisher(existingPublisher); } } @@ -635,6 +818,30 @@ private static String generateNotificationContent(final ViolationAnalysis violat return "An violation analysis decision was made to a policy violation affecting a project"; } + public static String generateVulnerabilityScheduledNotificationContent(final Rule rule, final List vulnerabilities, final List projects, final ZonedDateTime lastExecutionTime) { + final String content; + + if (vulnerabilities.isEmpty()) { + content = "No new vulnerabilities found."; + } else { + content = "In total, " + vulnerabilities.size() + " new vulnerabilities in " + projects.size() + " projects were found since " + lastExecutionTime.toLocalDateTime().truncatedTo(ChronoUnit.SECONDS) + "."; + } + + return content; + } + + public static String generatePolicyScheduledNotificationContent(final Rule rule, final List policyViolations, final List projects, final ZonedDateTime lastExecutionTime) { + final String content; + + if (policyViolations.isEmpty()) { + content = "No new policy violations found."; + } else { + content = "In total, " + policyViolations.size() + " new policy violations in " + projects.size() + " projects were found since " + lastExecutionTime.toLocalDateTime().truncatedTo(ChronoUnit.SECONDS) + "."; + } + + return content; + } + public static String generateNotificationTitle(String messageType, Project project) { if (project != null) { return messageType + " on Project: [" + project.toString() + "]"; @@ -642,6 +849,29 @@ public static String generateNotificationTitle(String messageType, Project proje return messageType; } + public static String generateNotificationTitle(NotificationGroup notificationGroup, List projects) { + String messageType; + + switch (notificationGroup) { + case NEW_VULNERABILITY: + messageType = NotificationConstants.Title.NEW_VULNERABILITY; + break; + case POLICY_VIOLATION: + messageType = NotificationConstants.Title.POLICY_VIOLATION; + break; + default: + return notificationGroup.name(); + } + + if (projects != null) { + if (projects.size() == 1) { + return generateNotificationTitle(messageType, projects.get(0)); + } + } + + return messageType + " on " + projects.size() + " projects"; + } + public static Object generateSubject(String group) { final Project project = createProject(); final Vulnerability vuln = createVulnerability(); diff --git a/src/main/java/org/dependencytrack/util/ScheduledUtil.java b/src/main/java/org/dependencytrack/util/ScheduledUtil.java new file mode 100644 index 0000000000..24f2867572 --- /dev/null +++ b/src/main/java/org/dependencytrack/util/ScheduledUtil.java @@ -0,0 +1,36 @@ +/* + * 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.util; + +import java.util.Date; + +/* + * Helper class for scheduled notifications to provide more human-friendly output in the templates. + * This class is mainly used to provide default values for null objects, which may be common in the Finding objects + * used in the scheduled notification for new vulnerabilities. + */ +public class ScheduledUtil { + public static String getValueOrEmptyIfNull(Object value) { + return value == null ? "" : value.toString(); + } + + public static String getDateOrUnknownIfNull(Date date) { + return date == null ? "Unknown" : DateUtil.toISO8601(date); + } +} \ No newline at end of file diff --git a/src/main/java/org/dependencytrack/util/ZonedDateTimeUtil.java b/src/main/java/org/dependencytrack/util/ZonedDateTimeUtil.java new file mode 100644 index 0000000000..2998de5bd1 --- /dev/null +++ b/src/main/java/org/dependencytrack/util/ZonedDateTimeUtil.java @@ -0,0 +1,36 @@ +/* + * 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.util; + +import java.time.ZoneId; +import java.time.ZonedDateTime; +import java.time.format.DateTimeFormatter; + +public class ZonedDateTimeUtil { + public static String toISO8601(final ZonedDateTime date) { + return date.withZoneSameInstant(ZoneId.of("UTC")).format(DateTimeFormatter.ISO_OFFSET_DATE_TIME); + } + + public static ZonedDateTime fromISO8601(final String dateString) { + if (dateString == null) { + return null; + } + return ZonedDateTime.parse(dateString, DateTimeFormatter.ISO_OFFSET_DATE_TIME); + } +} diff --git a/src/main/resources/META-INF/persistence.xml b/src/main/resources/META-INF/persistence.xml index 54ea30d611..26da474e71 100644 --- a/src/main/resources/META-INF/persistence.xml +++ b/src/main/resources/META-INF/persistence.xml @@ -34,6 +34,7 @@ org.dependencytrack.model.LicenseGroup org.dependencytrack.model.NotificationPublisher org.dependencytrack.model.NotificationRule + org.dependencytrack.model.ScheduledNotificationRule org.dependencytrack.model.Policy org.dependencytrack.model.PolicyCondition org.dependencytrack.model.PolicyViolation diff --git a/src/main/resources/templates/notification/publisher/scheduled_console.peb b/src/main/resources/templates/notification/publisher/scheduled_console.peb new file mode 100644 index 0000000000..a3ba5fcba5 --- /dev/null +++ b/src/main/resources/templates/notification/publisher/scheduled_console.peb @@ -0,0 +1,10 @@ +-------------------------------------------------------------------------------- +Notification + -- timestamp: {{ timestamp }} + -- level: {{ notification.level }} + -- scope: {{ notification.scope }} + -- group: {{ notification.group }} + -- title: {{ notification.title }}{% if subject.overview.newVulnerabilitiesCount > 0 %} + -- details:{% for entry in subject.summary.affectedProjectSummaries %}{% if entry.value.newVulnerabilitiesBySeverity|length > 0 %} + -- project: {{ entry.key.name }} {% if entry.key.version %}[{{ entry.key.version }}]{% endif %}{% for newVulnEntry in entry.value.newVulnerabilitiesBySeverity %}{% if newVulnEntry.value > 0 %} + -- {{ newVulnEntry.key }}: {{ newVulnEntry.value }}{% endif %}{% endfor %}{% endif %}{% endfor %}{% endif %} diff --git a/src/main/resources/templates/notification/publisher/scheduled_email_summary.peb b/src/main/resources/templates/notification/publisher/scheduled_email_summary.peb new file mode 100644 index 0000000000..0e6db63c7a --- /dev/null +++ b/src/main/resources/templates/notification/publisher/scheduled_email_summary.peb @@ -0,0 +1,331 @@ + + + + + +

{{ notification.title }}

+

-------------

+

{{ notification.content }}

+ + {% if notification.group == "NEW_VULNERABILITY" %} +

Overview

+
+ + + + + + + + + + + {% if subject.overview.newVulnerabilitiesBySeverity|length > 0 %}{% + for entry in subject.overview.newVulnerabilitiesBySeverity %}{% if entry.value > 0 %} + + + + + {% endif %}{% endfor %}{% endif %} + + + + + + + + + +
Projects included in this rule{{ subject.overview.affectedProjectsCount }}
Total new vulnerabilities{{ subject.overview.newVulnerabilitiesCount }}
New vulnerabilities ({{ entry.key }}){{ entry.value }}
Components affected by new vulnerabilities{{ subject.overview.affectedComponentsCount }}
Suppressed new vulnerabilities (not included above){{ subject.overview.suppressedNewVulnerabilitiesCount }}
+
+ +

Summary per project in this rule

+
+ + + + + + + + + + + + {% for entry in subject.summary.affectedProjectSummaries %} + + + + + + + + {% endfor %} + +
Project NameVersionNew VulnerabilitiesAll VulnerabilitiesSuppressed New Vulnerabilities
+ + {{ entry.key.name }} + + {{ entry.key.version }} + {% for newVulnEntry in entry.value.newVulnerabilitiesBySeverity %} + {{ newVulnEntry.key }}: {{ newVulnEntry.value }}
+ {% endfor %} +
+ {% for projVulnEntry in + entry.value.totalProjectVulnerabilitiesBySeverity %} + {{ projVulnEntry.key }}: {{ projVulnEntry.value }}
+ {% endfor %} +
+ {% for supprVulnEntry in + entry.value.suppressedNewVulnerabilitiesBySeverity %} + {{ supprVulnEntry.key }}: {{ supprVulnEntry.value }}
+ {% endfor %} +
+
{% if subject.overview.newVulnerabilitiesCount > 0 %} + +

New vulnerabilities per project

+ + {% if subject.details.affectedProjectFindings|length > 0 %}{% for + affProjEntry in subject.details.affectedProjectFindings %}{% if affProjEntry.value|length > 0 %} +

+ Project "{{ affProjEntry.key.name }}"{% if affProjEntry.key.version %} [Version: + {{ affProjEntry.key.version }}]{% endif %} +

+
+ + + + + + + + + + + + + + + + {% for vulnerableComponent in affProjEntry.value %} + + + + + + + + + + + + {% endfor %} + +
ComponentVersionGroupVulnerabilitySeverityAnalyzerAttributed OnAnalysisSuppressed
+ {% if vulnerableComponent.componentUuid is empty %} + {{ vulnerableComponent.componentName }} + {% else %} + + {{ vulnerableComponent.componentName }} + + {% endif %} + + {{ vulnerableComponent.componentVersion }} + + {{ vulnerableComponent.componentGroup }} + + {% if vulnerableComponent.vulnerabilityId is empty %} + {{ vulnerableComponent.vulnerabilityId }} + {% else %} + + {{ vulnerableComponent.vulnerabilityId }} + + {% endif %} + + {{ vulnerableComponent.vulnerabilitySeverity }} + + {% if vulnerableComponent.attributionReferenceUrl is empty %} + {{ vulnerableComponent.analyzer }} + {% else %} + + {{ vulnerableComponent.analyzer }} + + {% endif %} + + {{ vulnerableComponent.attributedOn }} + + {{ vulnerableComponent.analysisState }} + + {% if vulnerableComponent.isSuppressed %}Yes{% else %}No{% endif %} +
+
+ {% endif %}{% endfor %}{% endif %}{% endif %} + + {% elseif notification.group == "POLICY_VIOLATION" %} +

Overview

+
+ + + + + + + + + + + {% if subject.overview.newViolationsByRiskType|length > 0 %}{% + for entry in subject.overview.newViolationsByRiskType %}{% if entry.value > 0 %} + + + + + {% endif %}{% endfor %}{% endif %} + + + + + + + + + +
Projects included in this rule{{ subject.overview.affectedProjectsCount }}
Total new policy violations{{ subject.overview.newViolationsCount }}
New policy violations ({{ entry.key }}){{ entry.value }}
Components affected by new policy violations{{ subject.overview.affectedComponentsCount }}
Suppressed new policy violations (not included above){{ subject.overview.suppressedNewViolationsCount }}
+
+ +

Summary per project in this rule

+
+ + + + + + + + + + + + {% for entry in subject.summary.affectedProjectSummaries %} + + + + + + + + {% endfor %} + +
Project NameVersionNew Policy ViolationAll Policy ViolationsSuppressed Policy Violations
+ + {{ entry.key.name }} + + {{ entry.key.version }} + {% for newVulnEntry in entry.value.newViolationsByRiskType %} + {{ newVulnEntry.key }}: {{ newVulnEntry.value }}
+ {% endfor %} +
+ {% for projVulnEntry in + entry.value.totalProjectViolationsByRiskType %} + {{ projVulnEntry.key }}: {{ projVulnEntry.value }}
+ {% endfor %} +
+ {% for supprVulnEntry in + entry.value.suppressedNewViolationsByRiskType %} + {{ supprVulnEntry.key }}: {{ supprVulnEntry.value }}
+ {% endfor %} +
+
{% if subject.overview.newViolationsCount > 0 %} + +

New policy violations per project

+ + {% if subject.details.affectedProjectViolations|length > 0 %}{% for + affProjEntry in subject.details.affectedProjectViolations %}{% if affProjEntry.value|length > 0 %} +

+ Project "{{ affProjEntry.key.name }}"{% if affProjEntry.key.version %} [Version: + {{ affProjEntry.key.version }}]{% endif %} +

+
+ + + + + + + + + + + + + + + {% for violation in affProjEntry.value %} + + + + + + + + + + + {% endfor %} + +
StateRisk TypePolicy NameComponentComponent VersionOccurred onAnalysisSuppressed
+ {{ violation.policyCondition.policy.violationState }} + + {{ violation.type }} + + {{ violation.policyCondition.policy.name }} + + {% if violation.component.uuid is empty %} + {{ violation.component.name }} + {% else %} + + {{ violation.component.name }} + + {% endif %} + + {{ violation.component.version }} + + {{ violation.timestamp }} + + {{ violation.analysis.analysisState }} + + {% if violation.analysis.isSuppressed %}Yes{% else %}No{% endif %} +
+
+ {% endif %}{% endfor %}{% endif %}{% endif %} + {% endif %} + +
+

Executed: {{ timestamp }}

+
+ diff --git a/src/main/webapp/WEB-INF/web.xml b/src/main/webapp/WEB-INF/web.xml index 339838a5f3..ad86cd00d5 100644 --- a/src/main/webapp/WEB-INF/web.xml +++ b/src/main/webapp/WEB-INF/web.xml @@ -57,6 +57,9 @@ org.dependencytrack.persistence.H2WebConsoleInitializer + + org.dependencytrack.notification.ScheduledNotificationTaskInitializer + WhitelistUrlFilter diff --git a/src/test/java/org/dependencytrack/ResourceTest.java b/src/test/java/org/dependencytrack/ResourceTest.java index 4177c1e0ba..c62ec564a6 100644 --- a/src/test/java/org/dependencytrack/ResourceTest.java +++ b/src/test/java/org/dependencytrack/ResourceTest.java @@ -30,6 +30,12 @@ import org.junit.Before; import org.junit.BeforeClass; +import com.fasterxml.jackson.databind.json.JsonMapper; +import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule; + +import com.fasterxml.jackson.databind.json.JsonMapper; +import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule; + import jakarta.json.Json; import jakarta.json.JsonArray; import jakarta.json.JsonObject; @@ -55,7 +61,10 @@ public abstract class ResourceTest { protected final String V1_LICENSE = "/v1/license"; protected final String V1_METRICS = "/v1/metrics"; protected final String V1_NOTIFICATION_PUBLISHER = "/v1/notification/publisher"; + protected final String V1_NOTIFICATION_PUBLISHER_EVENT = "/v1/notification/publisher/event"; + protected final String V1_NOTIFICATION_PUBLISHER_SCHEDULED = "/v1/notification/publisher/scheduled"; protected final String V1_NOTIFICATION_RULE = "/v1/notification/rule"; + protected final String V1_SCHEDULED_NOTIFICATION_RULE = "/v1/schedulednotification/rule"; protected final String V1_OIDC = "/v1/oidc"; protected final String V1_PERMISSION = "/v1/permission"; protected final String V1_OSV_ECOSYSTEM = "/v1/integration/osv/ecosystem"; @@ -89,6 +98,14 @@ public abstract class ResourceTest { protected QueryManager qm; protected Team team; protected String apiKey; + protected JsonMapper jsonMapper; + + public ResourceTest() { + // needed to deserialize Java time objects in tests + jsonMapper = JsonMapper.builder() + .addModule(new JavaTimeModule()) + .build(); + } @BeforeClass public static void init() { diff --git a/src/test/java/org/dependencytrack/integrations/defectdojo/DefectDojoUploaderTest.java b/src/test/java/org/dependencytrack/integrations/defectdojo/DefectDojoUploaderTest.java index e3cc101014..24a0500c8d 100644 --- a/src/test/java/org/dependencytrack/integrations/defectdojo/DefectDojoUploaderTest.java +++ b/src/test/java/org/dependencytrack/integrations/defectdojo/DefectDojoUploaderTest.java @@ -42,6 +42,7 @@ 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.get; +import static com.github.tomakehurst.wiremock.client.WireMock.getAllServeEvents; import static com.github.tomakehurst.wiremock.client.WireMock.getRequestedFor; import static com.github.tomakehurst.wiremock.client.WireMock.matching; import static com.github.tomakehurst.wiremock.client.WireMock.post; @@ -225,7 +226,8 @@ public void testUpload() { "analysis": { "isSuppressed": false }, - "matrix": "${json-unit.any-string}" + "matrix": "${json-unit.any-string}", + "projectUuid": "${json-unit.any-string}" } ] } @@ -478,7 +480,8 @@ public void testUploadWithGlobalReimport() { "analysis": { "isSuppressed": false }, - "matrix": "${json-unit.any-string}" + "matrix": "${json-unit.any-string}", + "projectUuid": "${json-unit.any-string}" } ] } diff --git a/src/test/java/org/dependencytrack/model/NotificationPublisherTest.java b/src/test/java/org/dependencytrack/model/NotificationPublisherTest.java index 72bcd1b68f..e0a239fbd1 100644 --- a/src/test/java/org/dependencytrack/model/NotificationPublisherTest.java +++ b/src/test/java/org/dependencytrack/model/NotificationPublisherTest.java @@ -74,6 +74,13 @@ public void testDefaultPublisher() { Assert.assertTrue(publisher.isDefaultPublisher()); } + @Test + public void testPublishScheduled() { + NotificationPublisher publisher = new NotificationPublisher(); + publisher.setPublishScheduled(true); + Assert.assertTrue(publisher.isPublishScheduled()); + } + @Test public void testUuid() { UUID uuid = UUID.randomUUID(); diff --git a/src/test/java/org/dependencytrack/model/ScheduledNotificationRuleTest.java b/src/test/java/org/dependencytrack/model/ScheduledNotificationRuleTest.java new file mode 100644 index 0000000000..fca1b4c1df --- /dev/null +++ b/src/test/java/org/dependencytrack/model/ScheduledNotificationRuleTest.java @@ -0,0 +1,214 @@ +/* + * 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.model; + +import java.time.ZoneOffset; +import java.time.ZonedDateTime; +import java.util.ArrayList; +import java.util.HashSet; +import java.util.List; +import java.util.Set; +import java.util.UUID; + +import org.dependencytrack.notification.NotificationGroup; +import org.dependencytrack.notification.NotificationScope; +import org.junit.Assert; +import org.junit.Test; + +import alpine.model.LdapUser; +import alpine.model.ManagedUser; +import alpine.model.OidcUser; +import alpine.model.Team; +import alpine.notification.NotificationLevel; + +public class ScheduledNotificationRuleTest { + @Test + public void testId() { + ScheduledNotificationRule rule = new ScheduledNotificationRule(); + rule.setId(111L); + Assert.assertEquals(111L, rule.getId()); + } + + @Test + public void testName() { + ScheduledNotificationRule rule = new ScheduledNotificationRule(); + rule.setName("Test Name"); + Assert.assertEquals("Test Name", rule.getName()); + } + + @Test + public void testEnabled() { + ScheduledNotificationRule rule = new ScheduledNotificationRule(); + rule.setEnabled(true); + Assert.assertTrue(rule.isEnabled()); + } + + @Test + public void testScope() { + ScheduledNotificationRule rule = new ScheduledNotificationRule(); + rule.setScope(NotificationScope.PORTFOLIO); + Assert.assertEquals(NotificationScope.PORTFOLIO, rule.getScope()); + } + + @Test + public void testNotificationLevel() { + ScheduledNotificationRule rule = new ScheduledNotificationRule(); + rule.setNotificationLevel(NotificationLevel.WARNING); + Assert.assertEquals(NotificationLevel.WARNING, rule.getNotificationLevel()); + } + + @Test + public void testProjects() { + List projects = new ArrayList<>(); + Project project = new Project(); + projects.add(project); + ScheduledNotificationRule rule = new ScheduledNotificationRule(); + rule.setProjects(projects); + Assert.assertEquals(1, rule.getProjects().size()); + Assert.assertEquals(project, rule.getProjects().get(0)); + } + + @Test + public void testMessage() { + ScheduledNotificationRule rule = new ScheduledNotificationRule(); + rule.setMessage("Test Message"); + Assert.assertEquals("Test Message", rule.getMessage()); + } + + @Test + public void testNotifyOn() { + Set groups = new HashSet<>(); + groups.add(NotificationGroup.POLICY_VIOLATION); + groups.add(NotificationGroup.NEW_VULNERABILITY); + ScheduledNotificationRule rule = new ScheduledNotificationRule(); + rule.setNotifyOn(groups); + Assert.assertEquals(2, rule.getNotifyOn().size()); + } + + @Test + public void testPublisher() { + NotificationPublisher publisher = new NotificationPublisher(); + ScheduledNotificationRule rule = new ScheduledNotificationRule(); + rule.setPublisher(publisher); + Assert.assertEquals(publisher, rule.getPublisher()); + } + + @Test + public void testPublisherConfig() { + ScheduledNotificationRule rule = new ScheduledNotificationRule(); + rule.setPublisherConfig("{ \"config\": \"configured\" }"); + Assert.assertEquals("{ \"config\": \"configured\" }", rule.getPublisherConfig()); + } + + @Test + public void testUuid() { + UUID uuid = UUID.randomUUID(); + ScheduledNotificationRule rule = new ScheduledNotificationRule(); + rule.setUuid(uuid); + Assert.assertEquals(uuid.toString(), rule.getUuid().toString()); + } + + @Test + public void testTeams(){ + List teams = new ArrayList<>(); + Team team = new Team(); + teams.add(team); + ScheduledNotificationRule rule = new ScheduledNotificationRule(); + rule.setTeams(teams); + Assert.assertEquals(1, rule.getTeams().size()); + Assert.assertEquals(team, rule.getTeams().get(0)); + } + + @Test + public void testManagedUsers(){ + List teams = new ArrayList<>(); + Team team = new Team(); + List managedUsers = new ArrayList<>(); + ManagedUser managedUser = new ManagedUser(); + managedUsers.add(managedUser); + team.setManagedUsers(managedUsers); + teams.add(team); + ScheduledNotificationRule rule = new ScheduledNotificationRule(); + rule.setTeams(teams); + Assert.assertEquals(1, rule.getTeams().size()); + Assert.assertEquals(team, rule.getTeams().get(0)); + Assert.assertEquals(managedUser, rule.getTeams().get(0).getManagedUsers().get(0)); + } + + @Test + public void testLdapUsers(){ + List teams = new ArrayList<>(); + Team team = new Team(); + List ldapUsers = new ArrayList<>(); + LdapUser ldapUser = new LdapUser(); + ldapUsers.add(ldapUser); + team.setLdapUsers(ldapUsers); + teams.add(team); + ScheduledNotificationRule rule = new ScheduledNotificationRule(); + rule.setTeams(teams); + Assert.assertEquals(1, rule.getTeams().size()); + Assert.assertEquals(team, rule.getTeams().get(0)); + Assert.assertEquals(ldapUser, rule.getTeams().get(0).getLdapUsers().get(0)); + } + + @Test + public void testOidcUsers(){ + List teams = new ArrayList<>(); + Team team = new Team(); + List oidcUsers = new ArrayList<>(); + OidcUser oidcUser = new OidcUser(); + oidcUsers.add(oidcUser); + team.setOidcUsers(oidcUsers); + teams.add(team); + ScheduledNotificationRule rule = new ScheduledNotificationRule(); + rule.setTeams(teams); + Assert.assertEquals(1, rule.getTeams().size()); + Assert.assertEquals(team, rule.getTeams().get(0)); + Assert.assertEquals(oidcUser, rule.getTeams().get(0).getOidcUsers().get(0)); + } + + @Test + public void testCronConfig() { + ScheduledNotificationRule rule = new ScheduledNotificationRule(); + rule.setCronConfig("0 0 12 * *"); + Assert.assertEquals("0 0 12 * *", rule.getCronConfig()); + } + + @Test + public void testCronConfigNull() { + ScheduledNotificationRule rule = new ScheduledNotificationRule(); + rule.setCronConfig(null); + Assert.assertEquals(ConfigPropertyConstants.NOTIFICATION_CRON_DEFAULT_EXPRESSION.getDefaultPropertyValue(), rule.getCronConfig()); + } + + @Test + public void testLastExecutionTime() { + ScheduledNotificationRule rule = new ScheduledNotificationRule(); + ZonedDateTime zdt = ZonedDateTime.of(2024, 5, 20, 12, 10, 13, 0, ZoneOffset.UTC); + rule.setLastExecutionTime(zdt); + Assert.assertEquals(zdt, rule.getLastExecutionTime()); + } + + @Test + public void testPublishOnlyWithUpdates() { + ScheduledNotificationRule rule = new ScheduledNotificationRule(); + rule.setPublishOnlyWithUpdates(true); + Assert.assertTrue(rule.getPublishOnlyWithUpdates()); + } +} diff --git a/src/test/java/org/dependencytrack/model/scheduled/vulnerabilities/VulnerabilityDetailsInfoTest.java b/src/test/java/org/dependencytrack/model/scheduled/vulnerabilities/VulnerabilityDetailsInfoTest.java new file mode 100644 index 0000000000..aedb36e6d1 --- /dev/null +++ b/src/test/java/org/dependencytrack/model/scheduled/vulnerabilities/VulnerabilityDetailsInfoTest.java @@ -0,0 +1,94 @@ +package org.dependencytrack.model.scheduled.vulnerabilities; + +import java.math.BigDecimal; +import java.util.Date; +import java.util.UUID; + +import org.dependencytrack.model.AnalysisState; +import org.dependencytrack.model.Finding; +import org.dependencytrack.model.Severity; +import org.dependencytrack.tasks.scanners.AnalyzerIdentity; +import org.dependencytrack.util.ScheduledUtil; +import org.junit.Assert; +import org.junit.Test; + +public class VulnerabilityDetailsInfoTest { + private final UUID projectUuid = UUID.randomUUID(); + private final Date attributedOn = new Date(); + private final Finding finding = new Finding(projectUuid, "component-uuid", "component-name", "component-group", + "component-version", "component-purl", "component-cpe", "vuln-uuid", "vuln-source", "vuln-vulnId", "vuln-title", + "vuln-subtitle", "vuln-description", "vuln-recommendation", Severity.HIGH, BigDecimal.valueOf(7.2), BigDecimal.valueOf(8.4), BigDecimal.valueOf(1.25), BigDecimal.valueOf(1.75), BigDecimal.valueOf(1.3), + "0.5", "0.9", null, AnalyzerIdentity.INTERNAL_ANALYZER, attributedOn, null, "reference-url", AnalysisState.NOT_AFFECTED, true); + + @Test + public void testComponentUuid() { + var info = new VulnerabilityDetailsInfo(finding); + Assert.assertEquals("component-uuid", info.componentUuid()); + } + + @Test + public void testComponentName() { + var info = new VulnerabilityDetailsInfo(finding); + Assert.assertEquals("component-name", info.componentName()); + } + + @Test + public void testComponentVersion() { + var info = new VulnerabilityDetailsInfo(finding); + Assert.assertEquals("component-version", info.componentVersion()); + } + + @Test + public void testComponentGroup() { + var info = new VulnerabilityDetailsInfo(finding); + Assert.assertEquals("component-group", info.componentGroup()); + } + + @Test + public void testVulnerabilitySource() { + var info = new VulnerabilityDetailsInfo(finding); + Assert.assertEquals("vuln-source", info.vulnerabilitySource()); + } + + @Test + public void testVulnerabilityId() { + var info = new VulnerabilityDetailsInfo(finding); + Assert.assertEquals("vuln-vulnId", info.vulnerabilityId()); + } + + @Test + public void testVulnerabilitySeverity() { + var info = new VulnerabilityDetailsInfo(finding); + Assert.assertEquals(Severity.HIGH.toString(), info.vulnerabilitySeverity()); + } + + @Test + public void testAnalyzer() { + var info = new VulnerabilityDetailsInfo(finding); + Assert.assertEquals(AnalyzerIdentity.INTERNAL_ANALYZER.toString(), info.analyzer()); + } + + @Test + public void testAttributionReferenceUrl() { + var info = new VulnerabilityDetailsInfo(finding); + Assert.assertEquals("reference-url", info.attributionReferenceUrl()); + } + + @Test + public void testAttributedOn() { + var info = new VulnerabilityDetailsInfo(finding); + Assert.assertEquals(ScheduledUtil.getDateOrUnknownIfNull(attributedOn), info.attributedOn()); + } + + @Test + public void testAnalysisState() { + var info = new VulnerabilityDetailsInfo(finding); + Assert.assertEquals(AnalysisState.NOT_AFFECTED.toString(), info.analysisState()); + } + + @Test + public void testSuppressed() { + var info = new VulnerabilityDetailsInfo(finding); + Assert.assertTrue(info.suppressed()); + } +} diff --git a/src/test/java/org/dependencytrack/notification/publisher/AbstractPublisherTest.java b/src/test/java/org/dependencytrack/notification/publisher/AbstractPublisherTest.java index cba787320c..09874d6ad4 100644 --- a/src/test/java/org/dependencytrack/notification/publisher/AbstractPublisherTest.java +++ b/src/test/java/org/dependencytrack/notification/publisher/AbstractPublisherTest.java @@ -20,17 +20,36 @@ import alpine.notification.Notification; import alpine.notification.NotificationLevel; + +import org.apache.commons.collections4.map.LinkedMap; import org.apache.commons.io.IOUtils; import org.dependencytrack.PersistenceCapableTest; import org.dependencytrack.model.Analysis; import org.dependencytrack.model.AnalysisState; import org.dependencytrack.model.Bom; import org.dependencytrack.model.Component; +import org.dependencytrack.model.Policy; +import org.dependencytrack.model.PolicyCondition; +import org.dependencytrack.model.PolicyCondition.Operator; +import org.dependencytrack.model.PolicyViolation; import org.dependencytrack.model.Project; import org.dependencytrack.model.Severity; import org.dependencytrack.model.Tag; +import org.dependencytrack.model.ViolationAnalysis; +import org.dependencytrack.model.ViolationAnalysisState; import org.dependencytrack.model.Vulnerability; import org.dependencytrack.model.VulnerabilityAnalysisLevel; +import org.dependencytrack.model.Policy.ViolationState; +import org.dependencytrack.model.PolicyViolation.Type; +import org.dependencytrack.model.scheduled.policyviolations.PolicyViolationDetails; +import org.dependencytrack.model.scheduled.policyviolations.PolicyViolationOverview; +import org.dependencytrack.model.scheduled.policyviolations.PolicyViolationSummary; +import org.dependencytrack.model.scheduled.policyviolations.PolicyViolationSummaryInfo; +import org.dependencytrack.model.scheduled.vulnerabilities.VulnerabilityDetails; +import org.dependencytrack.model.scheduled.vulnerabilities.VulnerabilityDetailsInfo; +import org.dependencytrack.model.scheduled.vulnerabilities.VulnerabilityOverview; +import org.dependencytrack.model.scheduled.vulnerabilities.VulnerabilitySummary; +import org.dependencytrack.model.scheduled.vulnerabilities.VulnerabilitySummaryInfo; import org.dependencytrack.notification.NotificationConstants; import org.dependencytrack.notification.NotificationGroup; import org.dependencytrack.notification.NotificationScope; @@ -39,17 +58,23 @@ import org.dependencytrack.notification.vo.BomProcessingFailed; import org.dependencytrack.notification.vo.BomValidationFailed; import org.dependencytrack.notification.vo.NewVulnerabilityIdentified; -import org.dependencytrack.resources.v1.problems.InvalidBomProblemDetails; import org.dependencytrack.notification.vo.NewVulnerableDependency; +import org.dependencytrack.notification.vo.ScheduledNewVulnerabilitiesIdentified; +import org.dependencytrack.notification.vo.ScheduledPolicyViolationsIdentified; import org.junit.Test; import jakarta.json.Json; import jakarta.json.JsonObject; import jakarta.json.JsonObjectBuilder; import java.math.BigDecimal; +import java.time.Instant; import java.time.LocalDateTime; import java.time.ZoneOffset; +import java.util.Date; +import java.util.EnumMap; +import java.util.LinkedHashMap; import java.util.List; +import java.util.Map; import java.util.Set; import java.util.UUID; @@ -59,10 +84,18 @@ public abstract class AbstractPublisherTest extends PersistenceCapableTest { final DefaultNotificationPublishers publisher; + final DefaultNotificationPublishers scheduledPublisher; final T publisherInstance; + AbstractPublisherTest(final DefaultNotificationPublishers publisher, final DefaultNotificationPublishers scheduledPublisher, final T publisherInstance) { + this.publisher = publisher; + this.scheduledPublisher = scheduledPublisher; + this.publisherInstance = publisherInstance; + } + AbstractPublisherTest(final DefaultNotificationPublishers publisher, final T publisherInstance) { this.publisher = publisher; + this.scheduledPublisher = DefaultNotificationPublishers.SCHEDULED_EMAIL; this.publisherInstance = publisherInstance; } @@ -229,6 +262,81 @@ public void testInformWithEscapedData() { .isThrownBy(() -> publisherInstance.inform(PublishContext.from(notification), notification, createConfig())); } + @Test + public void testPublishWithScheduledNewVulnerabilitiesNotification() { + final var project = createProject(); + final var component = createComponent(project); + final var vuln = createVulnerability(); + final Map mapVulnBySev = new EnumMap<>(Severity.class); + final Map mapVulnSummInfos = new LinkedHashMap<>(); + final Map> mapVulnDetailInfos = new LinkedHashMap<>(); + + mapVulnBySev.put(Severity.CRITICAL, 1); + mapVulnSummInfos.put(project, new VulnerabilitySummaryInfo(mapVulnBySev, mapVulnBySev, new LinkedMap<>())); + mapVulnDetailInfos.put(project, List.of(new VulnerabilityDetailsInfo( + component.getUuid().toString(), + component.getName(), + component.getVersion(), + component.getGroup(), + vuln.getSource(), + vuln.getVulnId(), + vuln.getSeverity().name(), + "analyzer", + "http://example.com", + Date.from(Instant.ofEpochSecond(66666, 666)).toString(), // Thu Jan 01 18:31:06 GMT 1970 + AnalysisState.EXPLOITABLE.name(), + false))); + + final var subject = new ScheduledNewVulnerabilitiesIdentified( + new VulnerabilityOverview(1, 1, mapVulnBySev, 1, 0), + new VulnerabilitySummary(mapVulnSummInfos), + new VulnerabilityDetails(mapVulnDetailInfos) + ); + + final var notification = new Notification() + .scope(NotificationScope.PORTFOLIO) + .group(NotificationGroup.NEW_VULNERABILITY) + .level(NotificationLevel.INFORMATIONAL) + .title(NotificationConstants.Title.NEW_VULNERABILITY) + .content("") + .timestamp(LocalDateTime.ofEpochSecond(66666, 666, ZoneOffset.UTC)) + .subject(subject); + + assertThatNoException() + .isThrownBy(() -> publisherInstance.inform(PublishContext.from(notification), notification, createScheduledConfig())); + } + + @Test + public void testPublishWithScheduledNewPolicyViolationsNotification() { + final var project = createProject(); + final var violation = createPolicyViolation(); + final Map mapPolViolBySev = new EnumMap<>(PolicyViolation.Type.class); + final Map mapPolViolSummInfos = new LinkedHashMap<>(); + final Map> mapPolViolDetailInfos = new LinkedHashMap<>(); + + mapPolViolBySev.put(PolicyViolation.Type.LICENSE, 1); + mapPolViolSummInfos.put(project, new PolicyViolationSummaryInfo(mapPolViolBySev, mapPolViolBySev, new LinkedMap<>())); + mapPolViolDetailInfos.put(project, List.of(violation)); + + final var subject = new ScheduledPolicyViolationsIdentified( + new PolicyViolationOverview(1, 1, mapPolViolBySev, 1, 0), + new PolicyViolationSummary(mapPolViolSummInfos), + new PolicyViolationDetails(mapPolViolDetailInfos) + ); + + final var notification = new Notification() + .scope(NotificationScope.PORTFOLIO) + .group(NotificationGroup.POLICY_VIOLATION) + .level(NotificationLevel.INFORMATIONAL) + .title(NotificationConstants.Title.NEW_POLICY_VIOLATION) + .content("") + .timestamp(LocalDateTime.ofEpochSecond(66666, 666, ZoneOffset.UTC)) + .subject(subject); + + assertThatNoException() + .isThrownBy(() -> publisherInstance.inform(PublishContext.from(notification), notification, createScheduledConfig())); + } + private static Component createComponent(final Project project) { final var component = new Component(); component.setProject(project); @@ -287,6 +395,47 @@ private static Analysis createAnalysis(final Component component, final Vulnerab return analysis; } + private static Policy createPolicy() { + final var policy = new Policy(); + policy.setUuid(UUID.fromString("8d2f1ec1-3625-48c6-97c4-2a7553c7a376")); + policy.setViolationState(ViolationState.INFO); + policy.setName("policyName"); + return policy; + } + + private static ViolationAnalysis createViolationAnalysis() { + final var violationAnalysis = new ViolationAnalysis(); + violationAnalysis.setViolationAnalysisState(ViolationAnalysisState.APPROVED); + violationAnalysis.setSuppressed(false); + return violationAnalysis; + } + + private static PolicyCondition createPolicyCondition() { + final var policy = createPolicy(); + final var policyCondition = new PolicyCondition(); + policyCondition.setUuid(UUID.fromString("b029fce3-96f2-4c4a-9049-61070e9b6ea6")); + policyCondition.setPolicy(policy); + policyCondition.setSubject(PolicyCondition.Subject.AGE); + policyCondition.setOperator(Operator.NUMERIC_EQUAL); + return policyCondition; + } + + private static PolicyViolation createPolicyViolation() { + final var project = createProject(); + final var component = createComponent(project); + final var violation = new PolicyViolation(); + final var violationAnalysis = createViolationAnalysis(); + final var policyCondition = createPolicyCondition(); + + violation.setUuid(UUID.fromString("bf956a83-6013-4a69-9c76-857e2a8c8e45")); + violation.setPolicyCondition(policyCondition); + violation.setType(Type.LICENSE); + violation.setComponent(component); + violation.setTimestamp(Date.from(Instant.ofEpochSecond(66666, 666))); // Thu Jan 01 18:31:06 GMT 1970 + violation.setAnalysis(violationAnalysis); + return violation; + } + private JsonObject createConfig() throws Exception { return Json.createObjectBuilder() .add(Publisher.CONFIG_TEMPLATE_MIME_TYPE_KEY, publisher.getTemplateMimeType()) @@ -295,6 +444,14 @@ private JsonObject createConfig() throws Exception { .build(); } + private JsonObject createScheduledConfig() throws Exception { + return Json.createObjectBuilder() + .add(Publisher.CONFIG_TEMPLATE_MIME_TYPE_KEY, publisher.getTemplateMimeType()) + .add(Publisher.CONFIG_TEMPLATE_KEY, IOUtils.resourceToString(scheduledPublisher.getPublisherTemplateFile(), UTF_8)) + .addAll(extraConfig()) + .build(); + } + JsonObjectBuilder extraConfig() { return Json.createObjectBuilder(); } diff --git a/src/test/java/org/dependencytrack/notification/publisher/DefaultNotificationPublishersTest.java b/src/test/java/org/dependencytrack/notification/publisher/DefaultNotificationPublishersTest.java index b125d50766..5bbead401d 100644 --- a/src/test/java/org/dependencytrack/notification/publisher/DefaultNotificationPublishersTest.java +++ b/src/test/java/org/dependencytrack/notification/publisher/DefaultNotificationPublishersTest.java @@ -31,6 +31,7 @@ public void testEnums() { Assert.assertEquals("MS_TEAMS", DefaultNotificationPublishers.MS_TEAMS.name()); Assert.assertEquals("MATTERMOST", DefaultNotificationPublishers.MATTERMOST.name()); Assert.assertEquals("EMAIL", DefaultNotificationPublishers.EMAIL.name()); + Assert.assertEquals("SCHEDULED_EMAIL", DefaultNotificationPublishers.SCHEDULED_EMAIL.name()); Assert.assertEquals("CONSOLE", DefaultNotificationPublishers.CONSOLE.name()); Assert.assertEquals("WEBHOOK", DefaultNotificationPublishers.WEBHOOK.name()); Assert.assertEquals("JIRA", DefaultNotificationPublishers.JIRA.name()); @@ -44,6 +45,7 @@ public void testSlack() { Assert.assertEquals("/templates/notification/publisher/slack.peb", DefaultNotificationPublishers.SLACK.getPublisherTemplateFile()); Assert.assertEquals(MediaType.APPLICATION_JSON, DefaultNotificationPublishers.SLACK.getTemplateMimeType()); Assert.assertTrue(DefaultNotificationPublishers.SLACK.isDefaultPublisher()); + Assert.assertFalse(DefaultNotificationPublishers.SLACK.isPublishScheduled()); } @Test @@ -54,6 +56,7 @@ public void testMsTeams() { Assert.assertEquals("/templates/notification/publisher/msteams.peb", DefaultNotificationPublishers.MS_TEAMS.getPublisherTemplateFile()); Assert.assertEquals(MediaType.APPLICATION_JSON, DefaultNotificationPublishers.MS_TEAMS.getTemplateMimeType()); Assert.assertTrue(DefaultNotificationPublishers.MS_TEAMS.isDefaultPublisher()); + Assert.assertFalse(DefaultNotificationPublishers.MS_TEAMS.isPublishScheduled()); } @Test @@ -64,6 +67,7 @@ public void testMattermost() { Assert.assertEquals("/templates/notification/publisher/mattermost.peb", DefaultNotificationPublishers.MATTERMOST.getPublisherTemplateFile()); Assert.assertEquals(MediaType.APPLICATION_JSON, DefaultNotificationPublishers.MATTERMOST.getTemplateMimeType()); Assert.assertTrue(DefaultNotificationPublishers.MATTERMOST.isDefaultPublisher()); + Assert.assertFalse(DefaultNotificationPublishers.MATTERMOST.isPublishScheduled()); } @Test @@ -72,8 +76,20 @@ public void testEmail() { Assert.assertEquals("Sends notifications to an email address", DefaultNotificationPublishers.EMAIL.getPublisherDescription()); Assert.assertEquals(SendMailPublisher.class, DefaultNotificationPublishers.EMAIL.getPublisherClass()); Assert.assertEquals("/templates/notification/publisher/email.peb", DefaultNotificationPublishers.EMAIL.getPublisherTemplateFile()); - Assert.assertEquals("text/plain; charset=utf-8", DefaultNotificationPublishers.EMAIL.getTemplateMimeType()); + Assert.assertEquals(MediaType.TEXT_PLAIN, DefaultNotificationPublishers.EMAIL.getTemplateMimeType()); Assert.assertTrue(DefaultNotificationPublishers.EMAIL.isDefaultPublisher()); + Assert.assertFalse(DefaultNotificationPublishers.EMAIL.isPublishScheduled()); + } + + @Test + public void testScheduledEmail() { + Assert.assertEquals("Scheduled Email", DefaultNotificationPublishers.SCHEDULED_EMAIL.getPublisherName()); + Assert.assertEquals("Sends summarized notifications to an email address in a defined schedule", DefaultNotificationPublishers.SCHEDULED_EMAIL.getPublisherDescription()); + Assert.assertEquals(SendMailPublisher.class, DefaultNotificationPublishers.SCHEDULED_EMAIL.getPublisherClass()); + Assert.assertEquals("/templates/notification/publisher/scheduled_email_summary.peb", DefaultNotificationPublishers.SCHEDULED_EMAIL.getPublisherTemplateFile()); + Assert.assertEquals(MediaType.TEXT_HTML, DefaultNotificationPublishers.SCHEDULED_EMAIL.getTemplateMimeType()); + Assert.assertTrue(DefaultNotificationPublishers.SCHEDULED_EMAIL.isDefaultPublisher()); + Assert.assertTrue(DefaultNotificationPublishers.SCHEDULED_EMAIL.isPublishScheduled()); } @Test @@ -84,6 +100,7 @@ public void testConsole() { Assert.assertEquals("/templates/notification/publisher/console.peb", DefaultNotificationPublishers.CONSOLE.getPublisherTemplateFile()); Assert.assertEquals(MediaType.TEXT_PLAIN, DefaultNotificationPublishers.CONSOLE.getTemplateMimeType()); Assert.assertTrue(DefaultNotificationPublishers.CONSOLE.isDefaultPublisher()); + Assert.assertFalse(DefaultNotificationPublishers.CONSOLE.isPublishScheduled()); } @Test @@ -94,6 +111,7 @@ public void testWebhook() { Assert.assertEquals("/templates/notification/publisher/webhook.peb", DefaultNotificationPublishers.WEBHOOK.getPublisherTemplateFile()); Assert.assertEquals(MediaType.APPLICATION_JSON, DefaultNotificationPublishers.WEBHOOK.getTemplateMimeType()); Assert.assertTrue(DefaultNotificationPublishers.WEBHOOK.isDefaultPublisher()); + Assert.assertFalse(DefaultNotificationPublishers.WEBHOOK.isPublishScheduled()); } @Test @@ -104,5 +122,6 @@ public void testJira() { Assert.assertEquals("/templates/notification/publisher/jira.peb", DefaultNotificationPublishers.JIRA.getPublisherTemplateFile()); Assert.assertEquals(MediaType.APPLICATION_JSON, DefaultNotificationPublishers.JIRA.getTemplateMimeType()); Assert.assertTrue(DefaultNotificationPublishers.JIRA.isDefaultPublisher()); + Assert.assertFalse(DefaultNotificationPublishers.JIRA.isPublishScheduled()); } } diff --git a/src/test/java/org/dependencytrack/notification/publisher/SendMailPublisherTest.java b/src/test/java/org/dependencytrack/notification/publisher/SendMailPublisherTest.java index 1388ac4540..84bf7ad7c2 100644 --- a/src/test/java/org/dependencytrack/notification/publisher/SendMailPublisherTest.java +++ b/src/test/java/org/dependencytrack/notification/publisher/SendMailPublisherTest.java @@ -40,7 +40,7 @@ public class SendMailPublisherTest extends AbstractPublisherTest { + assertThat(message.getSubject()).isEqualTo("[Dependency-Track] New Vulnerability Identified"); + assertThat(message.getContent()).isInstanceOf(MimeMultipart.class); + final MimeMultipart content = (MimeMultipart) message.getContent(); + assertThat(content.getCount()).isEqualTo(1); + assertThat(content.getBodyPart(0)).isInstanceOf(MimeBodyPart.class); + assertThat((String) content.getBodyPart(0).getContent()).isEqualToIgnoringWhitespace(""" + + + + + +

New Vulnerability Identified

+

-------------

+

+ + +

Overview

+
+ + + + + + + + + + + + + + + + + + + + + + + +
Projects included in this rule1
Total new vulnerabilities1
New vulnerabilities (CRITICAL)1
Components affected by new vulnerabilities1
Suppressed new vulnerabilities (not included above)0
+
+ +

Summary per project in this rule

+
+ + + + + + + + + + + + + + + + + + + + +
Project NameVersionNew VulnerabilitiesAll VulnerabilitiesSuppressed New Vulnerabilities
+ + projectName + + projectVersion + CRITICAL: 1
+
+ CRITICAL: 1
+
+
+
+ +

New vulnerabilities per project

+ +

+ Project "projectName" [Version: projectVersion] +

+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + +
ComponentVersionGroupVulnerabilitySeverityAnalyzerAttributed OnAnalysisSuppressed
+ + componentName + + + componentVersion + + + + INT-001 + + + MEDIUM + + + analyzer + + + Thu Jan 01 19:31:06 CET 1970 + + EXPLOITABLE + + No +
+
+ +
+

Executed: 1970-01-01T18:31:06.000000666

+
+ + """); + }); + } + + @Override + public void testPublishWithScheduledNewPolicyViolationsNotification() { + super.testPublishWithScheduledNewPolicyViolationsNotification(); + + assertThat(greenMail.getReceivedMessages()).satisfiesExactly(message -> { + assertThat(message.getSubject()).isEqualTo("[Dependency-Track] New Policy Violation Identified"); + assertThat(message.getContent()).isInstanceOf(MimeMultipart.class); + final MimeMultipart content = (MimeMultipart) message.getContent(); + assertThat(content.getCount()).isEqualTo(1); + assertThat(content.getBodyPart(0)).isInstanceOf(MimeBodyPart.class); + assertThat((String) content.getBodyPart(0).getContent()).isEqualToIgnoringWhitespace(""" + + + + + +

New Policy Violation Identified

+

-------------

+

+ + +

Overview

+
+ + + + + + + + + + + + + + + + + + + + + + + + + +
Projects included in this rule1
Total new policy violations1
New policy violations (LICENSE)1
Components affected by new policy violations1
Suppressed new policy violations (not included above)0
+
+ +

Summary per project in this rule

+
+ + + + + + + + + + + + + + + + + + + + + +
Project NameVersionNew Policy ViolationAll Policy ViolationsSuppressed Policy Violations
+ + projectName + + projectVersion + + LICENSE: 1
+ +
+ + LICENSE: 1
+ +
+ +
+
+ +

New policy violations per project

+ + +

+ Project "projectName" [Version: + projectVersion] +

+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + +
StateRisk TypePolicy NameComponentComponent VersionOccurred onAnalysisSuppressed
+ INFO + + LICENSE + + policyName + + + + componentName + + + + componentVersion + + Thu Jan 01 19:31:06 CET 1970 + + APPROVED + + No +
+
+ + + +
+

Executed: 1970-01-01T18:31:06.000000666

+
+ + """); + }); + } + @Override public void testInformWithNewVulnerableDependencyNotification() { super.testInformWithNewVulnerableDependencyNotification(); diff --git a/src/test/java/org/dependencytrack/persistence/PolicyQueryManagerTest.java b/src/test/java/org/dependencytrack/persistence/PolicyQueryManagerTest.java index eca5f07681..3df1e8f9e9 100644 --- a/src/test/java/org/dependencytrack/persistence/PolicyQueryManagerTest.java +++ b/src/test/java/org/dependencytrack/persistence/PolicyQueryManagerTest.java @@ -19,7 +19,6 @@ package org.dependencytrack.persistence; import org.dependencytrack.PersistenceCapableTest; -import org.dependencytrack.model.Classifier; import org.dependencytrack.model.Component; import org.dependencytrack.model.Policy; import org.dependencytrack.model.PolicyViolation; @@ -30,21 +29,10 @@ import org.junit.Test; import org.junit.Assert; -import com.github.packageurl.PackageURL; -import com.github.packageurl.PackageURLBuilder; - -import ch.qos.logback.core.subst.Token.Type; -import us.springett.parsers.cpe.Cpe; -import us.springett.parsers.cpe.CpeParser; -import us.springett.parsers.cpe.exceptions.CpeParsingException; - import java.util.ArrayList; import java.util.Date; import java.util.List; -import javax.jdo.PersistenceManager; - -import static java.util.Collections.newSetFromMap; import static org.assertj.core.api.Assertions.assertThat; public class PolicyQueryManagerTest extends PersistenceCapableTest { diff --git a/src/test/java/org/dependencytrack/resources/v1/NotificationPublisherResourceTest.java b/src/test/java/org/dependencytrack/resources/v1/NotificationPublisherResourceTest.java index 677303f372..ede0944fa1 100644 --- a/src/test/java/org/dependencytrack/resources/v1/NotificationPublisherResourceTest.java +++ b/src/test/java/org/dependencytrack/resources/v1/NotificationPublisherResourceTest.java @@ -75,6 +75,24 @@ public void getAllNotificationPublishersTest() { Assert.assertNull(response.getHeaderString(TOTAL_COUNT_HEADER)); JsonArray json = parseJsonArray(response); Assert.assertNotNull(json); + Assert.assertEquals(10, json.size()); + Assert.assertEquals("Console", json.getJsonObject(1).getString("name")); + Assert.assertEquals("Displays notifications on the system console", json.getJsonObject(1).getString("description")); + Assert.assertEquals("text/plain", json.getJsonObject(1).getString("templateMimeType")); + Assert.assertNotNull("template"); + Assert.assertTrue(json.getJsonObject(1).getBoolean("defaultPublisher")); + Assert.assertTrue(UuidUtil.isValidUUID(json.getJsonObject(1).getString("uuid"))); + } + + @Test + public void getAllEventNotificationPublishersTest() { + Response response = jersey.target(V1_NOTIFICATION_PUBLISHER_EVENT).request() + .header(X_API_KEY, apiKey) + .get(Response.class); + Assert.assertEquals(200, response.getStatus(), 0); + Assert.assertNull(response.getHeaderString(TOTAL_COUNT_HEADER)); + JsonArray json = parseJsonArray(response); + Assert.assertNotNull(json); Assert.assertEquals(8, json.size()); Assert.assertEquals("Console", json.getJsonObject(1).getString("name")); Assert.assertEquals("Displays notifications on the system console", json.getJsonObject(1).getString("description")); @@ -84,6 +102,24 @@ public void getAllNotificationPublishersTest() { Assert.assertTrue(UuidUtil.isValidUUID(json.getJsonObject(1).getString("uuid"))); } + @Test + public void getAllScheduledNotificationPublishersTest() { + Response response = jersey.target(V1_NOTIFICATION_PUBLISHER_SCHEDULED).request() + .header(X_API_KEY, apiKey) + .get(Response.class); + Assert.assertEquals(200, response.getStatus(), 0); + Assert.assertNull(response.getHeaderString(TOTAL_COUNT_HEADER)); + JsonArray json = parseJsonArray(response); + Assert.assertNotNull(json); + Assert.assertEquals(2, json.size()); + Assert.assertEquals("Scheduled Email", json.getJsonObject(1).getString("name")); + Assert.assertEquals("Sends summarized notifications to an email address in a defined schedule", json.getJsonObject(1).getString("description")); + Assert.assertEquals("text/html", json.getJsonObject(1).getString("templateMimeType")); + Assert.assertNotNull("template"); + Assert.assertTrue(json.getJsonObject(1).getBoolean("defaultPublisher")); + Assert.assertTrue(UuidUtil.isValidUUID(json.getJsonObject(1).getString("uuid"))); + } + @Test public void createNotificationPublisherTest() { NotificationPublisher publisher = new NotificationPublisher(); @@ -219,7 +255,7 @@ public void updateUnknownNotificationPublisherTest() { @Test public void updateExistingDefaultNotificationPublisherTest() { - NotificationPublisher notificationPublisher = qm.getDefaultNotificationPublisher(SendMailPublisher.class); + NotificationPublisher notificationPublisher = qm.getDefaultNotificationPublisher(DefaultNotificationPublishers.EMAIL); notificationPublisher.setName(notificationPublisher.getName() + " Updated"); Response response = jersey.target(V1_NOTIFICATION_PUBLISHER).request() .header(X_API_KEY, apiKey) @@ -324,7 +360,7 @@ public void deleteUnknownNotificationPublisherTest() { @Test public void deleteDefaultNotificationPublisherTest() { - NotificationPublisher notificationPublisher = qm.getDefaultNotificationPublisher(SendMailPublisher.class); + NotificationPublisher notificationPublisher = qm.getDefaultNotificationPublisher(DefaultNotificationPublishers.EMAIL); Response response = jersey.target(V1_NOTIFICATION_PUBLISHER + "/" + notificationPublisher.getUuid()).request() .header(X_API_KEY, apiKey) .delete(); @@ -370,7 +406,7 @@ public void testNotificationRuleTest() { @Test public void restoreDefaultTemplatesTest() { - NotificationPublisher slackPublisher = qm.getDefaultNotificationPublisher(DefaultNotificationPublishers.SLACK.getPublisherClass()); + NotificationPublisher slackPublisher = qm.getDefaultNotificationPublisher(DefaultNotificationPublishers.SLACK); slackPublisher.setName(slackPublisher.getName()+" Updated"); qm.persist(slackPublisher); qm.detach(NotificationPublisher.class, slackPublisher.getId()); @@ -387,7 +423,7 @@ public void restoreDefaultTemplatesTest() { qm.getPersistenceManager().refreshAll(); Assert.assertEquals(200, response.getStatus(), 0); Assert.assertFalse(qm.isEnabled(ConfigPropertyConstants.NOTIFICATION_TEMPLATE_DEFAULT_OVERRIDE_ENABLED)); - slackPublisher = qm.getDefaultNotificationPublisher(DefaultNotificationPublishers.SLACK.getPublisherClass()); + slackPublisher = qm.getDefaultNotificationPublisher(DefaultNotificationPublishers.SLACK); Assert.assertEquals(DefaultNotificationPublishers.SLACK.getPublisherName(), slackPublisher.getName()); } } diff --git a/src/test/java/org/dependencytrack/resources/v1/NotificationRuleResourceTest.java b/src/test/java/org/dependencytrack/resources/v1/NotificationRuleResourceTest.java index 9906483b4a..9e8db554cc 100644 --- a/src/test/java/org/dependencytrack/resources/v1/NotificationRuleResourceTest.java +++ b/src/test/java/org/dependencytrack/resources/v1/NotificationRuleResourceTest.java @@ -136,7 +136,7 @@ public void createNotificationRuleInvalidPublisherTest() { Assert.assertEquals(404, response.getStatus(), 0); Assert.assertNull(response.getHeaderString(TOTAL_COUNT_HEADER)); String body = getPlainTextBody(response); - Assert.assertEquals("The UUID of the notification publisher could not be found.", body); + Assert.assertEquals("The UUID of the notification rule could not be found.", body); } @Test @@ -227,6 +227,7 @@ public void updateNotificationRuleWithTagsTest() { "publisherClass": "${json-unit.any-string}", "templateMimeType": "${json-unit.any-string}", "defaultPublisher": true, + "publishScheduled": false, "uuid": "${json-unit.any-string}" }, "uuid": "${json-unit.matches:ruleUuid}" @@ -274,6 +275,7 @@ public void updateNotificationRuleWithTagsTest() { "publisherClass": "${json-unit.any-string}", "templateMimeType": "${json-unit.any-string}", "defaultPublisher": true, + "publishScheduled": false, "uuid": "${json-unit.any-string}" }, "uuid": "${json-unit.matches:ruleUuid}" @@ -545,6 +547,7 @@ public void addTeamToRuleWithCustomEmailPublisherTest() { "publisherClass": "org.dependencytrack.notification.publisher.SendMailPublisher", "templateMimeType": "templateMimeType", "defaultPublisher": false, + "publishScheduled": false, "uuid": "${json-unit.matches:publisherUuid}" }, "uuid": "${json-unit.matches:ruleUuid}" diff --git a/src/test/java/org/dependencytrack/resources/v1/ScheduledNotificationRuleResourceTest.java b/src/test/java/org/dependencytrack/resources/v1/ScheduledNotificationRuleResourceTest.java new file mode 100644 index 0000000000..aee23fccf5 --- /dev/null +++ b/src/test/java/org/dependencytrack/resources/v1/ScheduledNotificationRuleResourceTest.java @@ -0,0 +1,699 @@ +/* + * 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; + +import java.time.ZonedDateTime; +import java.time.format.DateTimeFormatter; +import java.time.temporal.ChronoUnit; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.UUID; + +import jakarta.json.JsonArray; +import jakarta.json.JsonObject; +import jakarta.json.JsonValue; +import jakarta.ws.rs.client.Entity; +import jakarta.ws.rs.core.MediaType; +import jakarta.ws.rs.core.Response; + +import org.dependencytrack.JerseyTestRule; +import org.dependencytrack.ResourceTest; +import org.dependencytrack.model.ConfigPropertyConstants; +import org.dependencytrack.model.NotificationPublisher; +import org.dependencytrack.model.Project; +import org.dependencytrack.model.ScheduledNotificationRule; +import org.dependencytrack.notification.NotificationGroup; +import org.dependencytrack.notification.NotificationScope; +import org.dependencytrack.notification.publisher.DefaultNotificationPublishers; +import org.dependencytrack.notification.publisher.SendMailPublisher; +import org.dependencytrack.persistence.DefaultObjectGenerator; +import org.glassfish.jersey.client.ClientProperties; +import org.glassfish.jersey.server.ResourceConfig; +import org.glassfish.jersey.servlet.ServletContainer; +import org.glassfish.jersey.test.DeploymentContext; +import org.glassfish.jersey.test.ServletDeploymentContext; +import org.junit.Assert; +import org.junit.Before; +import org.junit.ClassRule; +import org.junit.Test; + +import com.fasterxml.jackson.core.JsonProcessingException; +import alpine.common.util.UuidUtil; +import alpine.model.Team; +import alpine.server.filters.ApiFilter; +import alpine.server.filters.AuthenticationFilter; +import static net.javacrumbs.jsonunit.assertj.JsonAssertions.assertThatJson; +import static org.assertj.core.api.Assertions.assertThat; +import static org.hamcrest.Matchers.equalTo; + +public class ScheduledNotificationRuleResourceTest extends ResourceTest { + + + protected DeploymentContext configureDeployment() { + return ServletDeploymentContext.forServlet(new ServletContainer( + new ResourceConfig(ScheduledNotificationRuleResource.class) + .register(ApiFilter.class) + .register(AuthenticationFilter.class))) + .build(); + } + + @ClassRule + public static JerseyTestRule jersey = new JerseyTestRule( + new ResourceConfig(ScheduledNotificationRuleResource.class) + .register(ApiFilter.class) + .register(AuthenticationFilter.class)); + + @Before + public void before() throws Exception { + super.before(); + DefaultObjectGenerator generator = new DefaultObjectGenerator(); + generator.contextInitialized(null); + } + + @Test + public void getAllScheduledNotificationRulesTest(){ + NotificationPublisher publisher = qm.getNotificationPublisher(DefaultNotificationPublishers.SLACK.getPublisherName()); + ScheduledNotificationRule r1 = qm.createScheduledNotificationRule("Rule 1", NotificationScope.PORTFOLIO, publisher); + qm.createScheduledNotificationRule("Rule 2", NotificationScope.PORTFOLIO, publisher); + qm.createScheduledNotificationRule("Rule 3", NotificationScope.SYSTEM, publisher); + Response response = jersey.target(V1_SCHEDULED_NOTIFICATION_RULE).request() + .header(X_API_KEY, apiKey) + .get(Response.class); + Assert.assertEquals(200, response.getStatus(), 0); + Assert.assertEquals(String.valueOf(3), response.getHeaderString(TOTAL_COUNT_HEADER)); + JsonArray json = parseJsonArray(response); + Assert.assertNotNull(json); + Assert.assertEquals(3, json.size()); + Assert.assertEquals("Rule 1", json.getJsonObject(0).getString("name")); + Assert.assertTrue(json.getJsonObject(0).getBoolean("enabled")); + Assert.assertEquals("PORTFOLIO", json.getJsonObject(0).getString("scope")); + Assert.assertEquals(0, json.getJsonObject(0).getJsonArray("notifyOn").size()); + Assert.assertTrue(UuidUtil.isValidUUID(json.getJsonObject(0).getString("uuid"))); + Assert.assertEquals("Slack", json.getJsonObject(0).getJsonObject("publisher").getString("name")); + Assert.assertFalse(json.getJsonObject(0).getBoolean("logSuccessfulPublish")); + Assert.assertEquals(ConfigPropertyConstants.NOTIFICATION_CRON_DEFAULT_EXPRESSION.getDefaultPropertyValue(), json.getJsonObject(0).getString("cronConfig")); + JsonValue jsonValue = json.getJsonObject(0).get("lastExecutionTime"); + try { + Assert.assertEquals(r1.getLastExecutionTime().truncatedTo(ChronoUnit.SECONDS), jsonMapper.readValue(jsonValue.toString(), ZonedDateTime.class).withZoneSameInstant(r1.getLastExecutionTime().getZone()).truncatedTo(ChronoUnit.SECONDS)); + } catch (JsonProcessingException e) { + Assert.fail(); + } + Assert.assertFalse(json.getJsonObject(0).getBoolean("publishOnlyWithUpdates")); + } + + @Test + public void createScheduledNotificationRuleTest() { + NotificationPublisher publisher = qm.getNotificationPublisher(DefaultNotificationPublishers.SLACK.getPublisherName()); + ScheduledNotificationRule rule = new ScheduledNotificationRule(); + rule.setName("Example Rule"); + rule.setScope(NotificationScope.SYSTEM); + rule.setPublisher(publisher); + Response response = jersey.target(V1_SCHEDULED_NOTIFICATION_RULE).request() + .header(X_API_KEY, apiKey) + .put(Entity.entity(rule, MediaType.APPLICATION_JSON)); + Assert.assertEquals(201, response.getStatus(), 0); + JsonObject json = parseJsonObject(response); + Assert.assertNotNull(json); + Assert.assertEquals("Example Rule", json.getString("name")); + Assert.assertTrue(json.getBoolean("enabled")); + Assert.assertTrue(json.getBoolean("notifyChildren")); + Assert.assertFalse(json.getBoolean("logSuccessfulPublish")); + Assert.assertEquals("SYSTEM", json.getString("scope")); + Assert.assertEquals(0, json.getJsonArray("notifyOn").size()); + Assert.assertTrue(UuidUtil.isValidUUID(json.getString("uuid"))); + Assert.assertEquals("Slack", json.getJsonObject("publisher").getString("name")); + Assert.assertEquals(ConfigPropertyConstants.NOTIFICATION_CRON_DEFAULT_EXPRESSION.getDefaultPropertyValue(), json.getString("cronConfig")); + Assert.assertFalse(json.getBoolean("publishOnlyWithUpdates")); + } + + /* this test does not make sense because the API endpoint "/v1/schedulednotification/rule" calls the + qm.createScheduledNotificationRule() method, which sets the enabled field to true by default. + @Test + public void createScheduledNotificationRuleDisabledRuleTest() { + NotificationPublisher publisher = qm.getNotificationPublisher(DefaultNotificationPublishers.SLACK.getPublisherName()); + ScheduledNotificationRule rule = new ScheduledNotificationRule(); + rule.setPublisher(publisher); + rule.setEnabled(false); + rule.setName("Example Rule"); + rule.setScope(NotificationScope.SYSTEM); + Response response = jersey.target(V1_SCHEDULED_NOTIFICATION_RULE).request() + .header(X_API_KEY, apiKey) + .put(Entity.entity(rule, MediaType.APPLICATION_JSON)); + Assert.assertEquals(201, response.getStatus(), 0); + JsonObject json = parseJsonObject(response); + Assert.assertNotNull(json); + Assert.assertEquals("Example Rule", json.getString("name")); + Assert.assertFalse(json.getBoolean("enabled")); // test doesn't make sense + Assert.assertTrue(json.getBoolean("notifyChildren")); + Assert.assertFalse(json.getBoolean("logSuccessfulPublish")); + Assert.assertEquals("SYSTEM", json.getString("scope")); + Assert.assertEquals(0, json.getJsonArray("notifyOn").size()); + Assert.assertTrue(UuidUtil.isValidUUID(json.getString("uuid"))); + Assert.assertEquals("Slack", json.getJsonObject("publisher").getString("name")); + Assert.assertEquals(ConfigPropertyConstants.NOTIFICATION_CRON_DEFAULT_EXPRESSION.getDefaultPropertyValue(), json.getString("cronConfig")); + Assert.assertFalse(json.getBoolean("publishOnlyWithUpdates")); + } + */ + + + @Test + public void createScheduledNotificationRuleInvalidPublisherTest() { + NotificationPublisher publisher = new NotificationPublisher(); + publisher.setUuid(UUID.randomUUID()); + ScheduledNotificationRule rule = new ScheduledNotificationRule(); + rule.setName("Example Rule"); + rule.setEnabled(true); + rule.setPublisherConfig("{ \"foo\": \"bar\" }"); + rule.setMessage("A message"); + rule.setScope(NotificationScope.SYSTEM); + rule.setPublisher(publisher); + rule.setCronConfig("0 * * * *"); + rule.setLastExecutionTime(ZonedDateTime.now()); + Response response = jersey.target(V1_SCHEDULED_NOTIFICATION_RULE).request() + .header(X_API_KEY, apiKey) + .put(Entity.entity(rule, MediaType.APPLICATION_JSON)); + Assert.assertEquals(404, response.getStatus(), 0); + Assert.assertNull(response.getHeaderString(TOTAL_COUNT_HEADER)); + String body = getPlainTextBody(response); + Assert.assertEquals("The UUID of the scheduled notification rule could not be found.", body); + } + + @Test + public void createScheduledNotificationRuleWithInvalidCronConfigTest() { + NotificationPublisher publisher = qm.getNotificationPublisher(DefaultNotificationPublishers.SLACK.getPublisherName()); + ScheduledNotificationRule rule = new ScheduledNotificationRule(); + rule.setPublisher(publisher); + rule.setName("Example Rule"); + rule.setScope(NotificationScope.PORTFOLIO); + rule.setEnabled(true); + rule.setCronConfig("A B C D E"); + rule.setLastExecutionTime(ZonedDateTime.now()); + Response response = jersey.target(V1_SCHEDULED_NOTIFICATION_RULE).request() + .header(X_API_KEY, apiKey) + .put(Entity.entity(rule, MediaType.APPLICATION_JSON)); + Assert.assertEquals(400, response.getStatus(), 0); + Assert.assertNull(response.getHeaderString(TOTAL_COUNT_HEADER)); + String body = getPlainTextBody(response); + Assert.assertEquals("Invalid cron expression", body); + } + + @Test + public void updateScheduledNotificationRuleTest() { + NotificationPublisher publisher = qm.getNotificationPublisher(DefaultNotificationPublishers.SLACK.getPublisherName()); + ScheduledNotificationRule rule = qm.createScheduledNotificationRule("Rule 1", NotificationScope.PORTFOLIO, publisher); + rule.setName("Example Rule"); + rule.setNotifyOn(Collections.singleton(NotificationGroup.NEW_VULNERABILITY)); + Response response = jersey.target(V1_SCHEDULED_NOTIFICATION_RULE).request() + .header(X_API_KEY, apiKey) + .post(Entity.entity(rule, MediaType.APPLICATION_JSON)); + Assert.assertEquals(200, response.getStatus(), 0); + JsonObject json = parseJsonObject(response); + Assert.assertNotNull(json); + Assert.assertEquals("Example Rule", json.getString("name")); + Assert.assertTrue(json.getBoolean("enabled")); + Assert.assertEquals("PORTFOLIO", json.getString("scope")); + Assert.assertEquals("NEW_VULNERABILITY", json.getJsonArray("notifyOn").getString(0)); + Assert.assertTrue(UuidUtil.isValidUUID(json.getString("uuid"))); + Assert.assertEquals("Slack", json.getJsonObject("publisher").getString("name")); + } + + @Test + public void updateScheduledNotificationRuleSetDisabledTest() { + NotificationPublisher publisher = qm.getNotificationPublisher(DefaultNotificationPublishers.SLACK.getPublisherName()); + ScheduledNotificationRule rule = qm.createScheduledNotificationRule("Rule 1", NotificationScope.PORTFOLIO, publisher); + rule.setName("Example Rule"); + rule.setEnabled(false); + Response response = jersey.target(V1_SCHEDULED_NOTIFICATION_RULE).request() + .header(X_API_KEY, apiKey) + .post(Entity.entity(rule, MediaType.APPLICATION_JSON)); + Assert.assertEquals(200, response.getStatus(), 0); + JsonObject json = parseJsonObject(response); + Assert.assertNotNull(json); + Assert.assertEquals("Example Rule", json.getString("name")); + Assert.assertFalse(json.getBoolean("enabled")); + } + + @Test + public void updateScheduledNotificationRuleWithInvalidCronConfigTest() { + NotificationPublisher publisher = qm.getNotificationPublisher(DefaultNotificationPublishers.SLACK.getPublisherName()); + ScheduledNotificationRule rule = qm.createScheduledNotificationRule("Rule 1", NotificationScope.PORTFOLIO, publisher); + rule.setName("Example Rule"); + rule.setCronConfig("A B C D E"); + Response response = jersey.target(V1_SCHEDULED_NOTIFICATION_RULE).request() + .header(X_API_KEY, apiKey) + .post(Entity.entity(rule, MediaType.APPLICATION_JSON)); + Assert.assertEquals(400, response.getStatus(), 0); + Assert.assertNull(response.getHeaderString(TOTAL_COUNT_HEADER)); + String body = getPlainTextBody(response); + Assert.assertEquals("Invalid cron expression", body); + } + + @Test + public void updateScheduledNotificationRuleInvalidTest() { + NotificationPublisher publisher = qm.getNotificationPublisher(DefaultNotificationPublishers.SLACK.getPublisherName()); + ScheduledNotificationRule rule = qm.createScheduledNotificationRule("Rule 1", NotificationScope.PORTFOLIO, publisher); + rule = qm.detach(ScheduledNotificationRule.class, rule.getId()); + rule.setUuid(UUID.randomUUID()); + Response response = jersey.target(V1_SCHEDULED_NOTIFICATION_RULE).request() + .header(X_API_KEY, apiKey) + .post(Entity.entity(rule, MediaType.APPLICATION_JSON)); + Assert.assertEquals(404, response.getStatus(), 0); + Assert.assertNull(response.getHeaderString(TOTAL_COUNT_HEADER)); + String body = getPlainTextBody(response); + Assert.assertEquals("The UUID of the scheduled notification rule could not be found.", body); + } + + @Test + public void deleteScheduledNotificationRuleTest() { + NotificationPublisher publisher = qm.getNotificationPublisher(DefaultNotificationPublishers.SLACK.getPublisherName()); + ScheduledNotificationRule rule = qm.createScheduledNotificationRule("Rule 1", NotificationScope.PORTFOLIO, publisher); + rule.setName("Example Rule"); + Response response = jersey.target(V1_SCHEDULED_NOTIFICATION_RULE).request() + .header(X_API_KEY, apiKey) + .property(ClientProperties.SUPPRESS_HTTP_COMPLIANCE_VALIDATION, true) // HACK + .method("DELETE", Entity.entity(rule, MediaType.APPLICATION_JSON)); // HACK + // Hack: Workaround to https://github.com/eclipse-ee4j/jersey/issues/3798 + Assert.assertEquals(204, response.getStatus(), 0); + } + + @Test + public void deleteScheduledNotificationRuleNotExistingTest() { + NotificationPublisher publisher = qm.getNotificationPublisher(DefaultNotificationPublishers.SLACK.getPublisherName()); + ScheduledNotificationRule rule = qm.createScheduledNotificationRule("Rule 1", NotificationScope.PORTFOLIO, publisher); + rule = qm.detach(ScheduledNotificationRule.class, rule.getId()); + rule.setUuid(UUID.randomUUID()); + Response response = jersey.target(V1_SCHEDULED_NOTIFICATION_RULE).request() + .header(X_API_KEY, apiKey) + .property(ClientProperties.SUPPRESS_HTTP_COMPLIANCE_VALIDATION, true) // HACK + .method("DELETE", Entity.entity(rule, MediaType.APPLICATION_JSON)); // HACK + // Hack: Workaround to https://github.com/eclipse-ee4j/jersey/issues/3798 + Assert.assertEquals(404, response.getStatus(), 0); + Assert.assertNull(response.getHeaderString(TOTAL_COUNT_HEADER)); + String body = getPlainTextBody(response); + Assert.assertEquals("The UUID of the scheduled notification rule could not be found.", body); + } + + @Test + public void addProjectToRuleTest() { + Project project = qm.createProject("Acme Example", null, null, null, null, null, true, false); + NotificationPublisher publisher = qm.getNotificationPublisher(DefaultNotificationPublishers.SLACK.getPublisherName()); + ScheduledNotificationRule rule = qm.createScheduledNotificationRule("Example Rule", NotificationScope.PORTFOLIO, publisher); + Response response = jersey.target(V1_SCHEDULED_NOTIFICATION_RULE + "/" + rule.getUuid().toString() + "/project/" + project.getUuid().toString()).request() + .header(X_API_KEY, apiKey) + .post(Entity.json("")); + Assert.assertEquals(200, response.getStatus(), 0); + JsonObject json = parseJsonObject(response); + Assert.assertNotNull(json); + Assert.assertEquals("Example Rule", json.getString("name")); + Assert.assertEquals(1, json.getJsonArray("projects").size()); + Assert.assertEquals("Acme Example", json.getJsonArray("projects").getJsonObject(0).getString("name")); + Assert.assertEquals(project.getUuid().toString(), json.getJsonArray("projects").getJsonObject(0).getString("uuid")); + } + + @Test + public void addProjectToRuleInvalidRuleTest() { + Project project = qm.createProject("Acme Example", null, null, null, null, null, true, false); + Response response = jersey.target(V1_SCHEDULED_NOTIFICATION_RULE + "/" + UUID.randomUUID().toString() + "/project/" + project.getUuid().toString()).request() + .header(X_API_KEY, apiKey) + .post(Entity.json("")); + Assert.assertEquals(404, response.getStatus(), 0); + Assert.assertNull(response.getHeaderString(TOTAL_COUNT_HEADER)); + String body = getPlainTextBody(response); + Assert.assertEquals("The scheduled notification rule could not be found.", body); + } + + @Test + public void addProjectToRuleInvalidScopeTest() { + Project project = qm.createProject("Acme Example", null, null, null, null, null, true, false); + NotificationPublisher publisher = qm.getNotificationPublisher(DefaultNotificationPublishers.SLACK.getPublisherName()); + ScheduledNotificationRule rule = qm.createScheduledNotificationRule("Example Rule", NotificationScope.SYSTEM, publisher); + Response response = jersey.target(V1_SCHEDULED_NOTIFICATION_RULE + "/" + rule.getUuid().toString() + "/project/" + project.getUuid().toString()).request() + .header(X_API_KEY, apiKey) + .post(Entity.json("")); + Assert.assertEquals(406, response.getStatus(), 0); + Assert.assertNull(response.getHeaderString(TOTAL_COUNT_HEADER)); + String body = getPlainTextBody(response); + Assert.assertEquals("Project limitations are only possible on scheduled notification rules with PORTFOLIO scope.", body); + } + + @Test + public void addProjectToRuleInvalidProjectTest() { + NotificationPublisher publisher = qm.getNotificationPublisher(DefaultNotificationPublishers.SLACK.getPublisherName()); + ScheduledNotificationRule rule = qm.createScheduledNotificationRule("Example Rule", NotificationScope.PORTFOLIO, publisher); + Response response = jersey.target(V1_SCHEDULED_NOTIFICATION_RULE + "/" + rule.getUuid().toString() + "/project/" + UUID.randomUUID().toString()).request() + .header(X_API_KEY, apiKey) + .post(Entity.json("")); + Assert.assertEquals(404, response.getStatus(), 0); + Assert.assertNull(response.getHeaderString(TOTAL_COUNT_HEADER)); + String body = getPlainTextBody(response); + Assert.assertEquals("The project could not be found.", body); + } + + @Test + public void addProjectToRuleDuplicateProjectTest() { + Project project = qm.createProject("Acme Example", null, null, null, null, null, true, false); + NotificationPublisher publisher = qm.getNotificationPublisher(DefaultNotificationPublishers.SLACK.getPublisherName()); + ScheduledNotificationRule rule = qm.createScheduledNotificationRule("Example Rule", NotificationScope.PORTFOLIO, publisher); + List projects = new ArrayList<>(); + projects.add(project); + rule.setProjects(projects); + qm.persist(rule); + Response response = jersey.target(V1_SCHEDULED_NOTIFICATION_RULE + "/" + rule.getUuid().toString() + "/project/" + project.getUuid().toString()).request() + .header(X_API_KEY, apiKey) + .post(Entity.json("")); + Assert.assertEquals(304, response.getStatus(), 0); + Assert.assertNull(response.getHeaderString(TOTAL_COUNT_HEADER)); + } + + @Test + public void removeProjectFromRuleTest() { + Project project = qm.createProject("Acme Example", null, null, null, null, null, true, false); + NotificationPublisher publisher = qm.getNotificationPublisher(DefaultNotificationPublishers.SLACK.getPublisherName()); + ScheduledNotificationRule rule = qm.createScheduledNotificationRule("Example Rule", NotificationScope.PORTFOLIO, publisher); + List projects = new ArrayList<>(); + projects.add(project); + rule.setProjects(projects); + qm.persist(rule); + Response response = jersey.target(V1_SCHEDULED_NOTIFICATION_RULE + "/" + rule.getUuid().toString() + "/project/" + project.getUuid().toString()).request() + .header(X_API_KEY, apiKey) + .delete(); + Assert.assertEquals(200, response.getStatus(), 0); + Assert.assertNull(response.getHeaderString(TOTAL_COUNT_HEADER)); + } + + @Test + public void removeProjectFromRuleInvalidRuleTest() { + Project project = qm.createProject("Acme Example", null, null, null, null, null, true, false); + Response response = jersey.target(V1_SCHEDULED_NOTIFICATION_RULE + "/" + UUID.randomUUID().toString() + "/project/" + project.getUuid().toString()).request() + .header(X_API_KEY, apiKey) + .delete(); + Assert.assertEquals(404, response.getStatus(), 0); + Assert.assertNull(response.getHeaderString(TOTAL_COUNT_HEADER)); + String body = getPlainTextBody(response); + Assert.assertEquals("The scheduled notification rule could not be found.", body); + } + + @Test + public void removeProjectFromRuleInvalidScopeTest() { + Project project = qm.createProject("Acme Example", null, null, null, null, null, true, false); + NotificationPublisher publisher = qm.getNotificationPublisher(DefaultNotificationPublishers.SLACK.getPublisherName()); + ScheduledNotificationRule rule = qm.createScheduledNotificationRule("Example Rule", NotificationScope.SYSTEM, publisher); + Response response = jersey.target(V1_SCHEDULED_NOTIFICATION_RULE + "/" + rule.getUuid().toString() + "/project/" + project.getUuid().toString()).request() + .header(X_API_KEY, apiKey) + .delete(); + Assert.assertEquals(406, response.getStatus(), 0); + Assert.assertNull(response.getHeaderString(TOTAL_COUNT_HEADER)); + String body = getPlainTextBody(response); + Assert.assertEquals("Project limitations are only possible on scheduled notification rules with PORTFOLIO scope.", body); + } + + @Test + public void removeProjectFromRuleInvalidProjectTest() { + NotificationPublisher publisher = qm.getNotificationPublisher(DefaultNotificationPublishers.SLACK.getPublisherName()); + ScheduledNotificationRule rule = qm.createScheduledNotificationRule("Example Rule", NotificationScope.PORTFOLIO, publisher); + Response response = jersey.target(V1_SCHEDULED_NOTIFICATION_RULE + "/" + rule.getUuid().toString() + "/project/" + UUID.randomUUID().toString()).request() + .header(X_API_KEY, apiKey) + .delete(); + Assert.assertEquals(404, response.getStatus(), 0); + Assert.assertNull(response.getHeaderString(TOTAL_COUNT_HEADER)); + String body = getPlainTextBody(response); + Assert.assertEquals("The project could not be found.", body); + } + + @Test + public void removeProjectFromRuleDuplicateProjectTest() { + Project project = qm.createProject("Acme Example", null, null, null, null, null, true, false); + NotificationPublisher publisher = qm.getNotificationPublisher(DefaultNotificationPublishers.SLACK.getPublisherName()); + ScheduledNotificationRule rule = qm.createScheduledNotificationRule("Example Rule", NotificationScope.PORTFOLIO, publisher); + Response response = jersey.target(V1_SCHEDULED_NOTIFICATION_RULE + "/" + rule.getUuid().toString() + "/project/" + project.getUuid().toString()).request() + .header(X_API_KEY, apiKey) + .delete(); + Assert.assertEquals(304, response.getStatus(), 0); + Assert.assertNull(response.getHeaderString(TOTAL_COUNT_HEADER)); + } + + @Test + public void addTeamToRuleTest(){ + Team team = qm.createTeam("Team Example", false); + NotificationPublisher publisher = qm.getNotificationPublisher(DefaultNotificationPublishers.EMAIL.getPublisherName()); + ScheduledNotificationRule rule = qm.createScheduledNotificationRule("Example Rule", NotificationScope.PORTFOLIO, publisher); + Response response = jersey.target(V1_SCHEDULED_NOTIFICATION_RULE + "/" + rule.getUuid().toString() + "/team/" + team.getUuid().toString()).request() + .header(X_API_KEY, apiKey) + .post(Entity.json("")); + Assert.assertEquals(200, response.getStatus(), 0); + JsonObject json = parseJsonObject(response); + Assert.assertNotNull(json); + Assert.assertEquals("Example Rule", json.getString("name")); + Assert.assertEquals(1, json.getJsonArray("teams").size()); + Assert.assertEquals("Team Example", json.getJsonArray("teams").getJsonObject(0).getString("name")); + Assert.assertEquals(team.getUuid().toString(), json.getJsonArray("teams").getJsonObject(0).getString("uuid")); + } + + @Test + public void addTeamToRuleInvalidRuleTest(){ + Team team = qm.createTeam("Team Example", false); + Response response = jersey.target(V1_SCHEDULED_NOTIFICATION_RULE + "/" + UUID.randomUUID().toString() + "/team/" + team.getUuid().toString()).request() + .header(X_API_KEY, apiKey) + .post(Entity.json("")); + Assert.assertEquals(404, response.getStatus(), 0); + Assert.assertNull(response.getHeaderString(TOTAL_COUNT_HEADER)); + String body = getPlainTextBody(response); + Assert.assertEquals("The scheduled notification rule could not be found.", body); + } + + @Test + public void addTeamToRuleInvalidTeamTest() { + NotificationPublisher publisher = qm.getNotificationPublisher(DefaultNotificationPublishers.EMAIL.getPublisherName()); + ScheduledNotificationRule rule = qm.createScheduledNotificationRule("Example Rule", NotificationScope.PORTFOLIO, publisher); + Response response = jersey.target(V1_SCHEDULED_NOTIFICATION_RULE + "/" + rule.getUuid().toString() + "/team/" + UUID.randomUUID().toString()).request() + .header(X_API_KEY, apiKey) + .post(Entity.json("")); + Assert.assertEquals(404, response.getStatus(), 0); + Assert.assertNull(response.getHeaderString(TOTAL_COUNT_HEADER)); + String body = getPlainTextBody(response); + Assert.assertEquals("The team could not be found.", body); + } + + @Test + public void addTeamToRuleDuplicateTeamTest() { + Team team = qm.createTeam("Team Example", false); + NotificationPublisher publisher = qm.getNotificationPublisher(DefaultNotificationPublishers.EMAIL.getPublisherName()); + ScheduledNotificationRule rule = qm.createScheduledNotificationRule("Example Rule", NotificationScope.PORTFOLIO, publisher); + List teams = new ArrayList<>(); + teams.add(team); + rule.setTeams(teams); + qm.persist(rule); + Response response = jersey.target(V1_SCHEDULED_NOTIFICATION_RULE + "/" + rule.getUuid().toString() + "/team/" + team.getUuid().toString()).request() + .header(X_API_KEY, apiKey) + .post(Entity.json("")); + Assert.assertEquals(304, response.getStatus(), 0); + Assert.assertNull(response.getHeaderString(TOTAL_COUNT_HEADER)); + } + + @Test + public void addTeamToRuleInvalidPublisherTest(){ + Team team = qm.createTeam("Team Example", false); + NotificationPublisher publisher = qm.getNotificationPublisher(DefaultNotificationPublishers.SLACK.getPublisherName()); + ScheduledNotificationRule rule = qm.createScheduledNotificationRule("Example Rule", NotificationScope.PORTFOLIO, publisher); + Response response = jersey.target(V1_SCHEDULED_NOTIFICATION_RULE + "/" + rule.getUuid().toString() + "/team/" + team.getUuid().toString()).request() + .header(X_API_KEY, apiKey) + .post(Entity.json("")); + Assert.assertEquals(406, response.getStatus(), 0); + Assert.assertNull(response.getHeaderString(TOTAL_COUNT_HEADER)); + String body = getPlainTextBody(response); + Assert.assertEquals("Team subscriptions are only possible on scheduled notification rules with EMAIL publisher.", body); + } + + @Test + public void addTeamToRuleWithCustomEmailPublisherTest() { + final Team team = qm.createTeam("Team Example", false); + final NotificationPublisher publisher = qm.createNotificationPublisher("foo", "description", SendMailPublisher.class, "template", "templateMimeType", false, true); + final ScheduledNotificationRule rule = qm.createScheduledNotificationRule("Example Rule", NotificationScope.PORTFOLIO, publisher); + final ZonedDateTime testTime = ZonedDateTime.parse("2024-05-31T13:24:46Z", DateTimeFormatter.ISO_OFFSET_DATE_TIME); + rule.setLastExecutionTime(testTime); + final Response response = jersey.target(V1_SCHEDULED_NOTIFICATION_RULE + "/" + rule.getUuid() + "/team/" + team.getUuid()).request() + .header(X_API_KEY, apiKey) + .post(Entity.json("")); + assertThat(response.getStatus()).isEqualTo(200); + assertThatJson(getPlainTextBody(response)) + .withMatcher("publisherUuid", equalTo(publisher.getUuid().toString())) + .withMatcher("ruleUuid", equalTo(rule.getUuid().toString())) + .withMatcher("teamUuid", equalTo(team.getUuid().toString())) + .withMatcher("cronConfig", equalTo(rule.getCronConfig())) + .isEqualTo(""" + { + "name": "Example Rule", + "enabled": true, + "notifyChildren": true, + "logSuccessfulPublish": false, + "scope": "PORTFOLIO", + "projects": [], + "teams": [ + { + "uuid": "${json-unit.matches:teamUuid}", + "name": "Team Example", + "permissions": [] + } + ], + "notifyOn": [], + "publisher": { + "name": "foo", + "description": "description", + "publisherClass": "org.dependencytrack.notification.publisher.SendMailPublisher", + "templateMimeType": "templateMimeType", + "defaultPublisher": false, + "publishScheduled": true, + "uuid": "${json-unit.matches:publisherUuid}" + }, + "uuid": "${json-unit.matches:ruleUuid}", + "cronConfig": "${json-unit.matches:cronConfig}", + "lastExecutionTime": "2024-05-31T13:24:46Z", + "publishOnlyWithUpdates": false + } + """); + } + + @Test + public void removeTeamFromRuleTest() { + Team team = qm.createTeam("Team Example", false); + NotificationPublisher publisher = qm.getNotificationPublisher(DefaultNotificationPublishers.SCHEDULED_EMAIL.getPublisherName()); + ScheduledNotificationRule rule = qm.createScheduledNotificationRule("Example Rule", NotificationScope.PORTFOLIO, publisher); + List teams = new ArrayList<>(); + teams.add(team); + rule.setTeams(teams); + qm.persist(rule); + Response response = jersey.target(V1_SCHEDULED_NOTIFICATION_RULE + "/" + rule.getUuid().toString() + "/team/" + team.getUuid().toString()).request() + .header(X_API_KEY, apiKey) + .delete(); + Assert.assertEquals(200, response.getStatus(), 0); + Assert.assertNull(response.getHeaderString(TOTAL_COUNT_HEADER)); + } + + @Test + public void removeTeamFromRuleInvalidRuleTest() { + Team team = qm.createTeam("Team Example", false); + Response response = jersey.target(V1_SCHEDULED_NOTIFICATION_RULE + "/" + UUID.randomUUID().toString() + "/team/" + team.getUuid().toString()).request() + .header(X_API_KEY, apiKey) + .delete(); + Assert.assertEquals(404, response.getStatus(), 0); + Assert.assertNull(response.getHeaderString(TOTAL_COUNT_HEADER)); + String body = getPlainTextBody(response); + Assert.assertEquals("The scheduled notification rule could not be found.", body); + } + + @Test + public void removeTeamFromRuleInvalidTeamTest() { + NotificationPublisher publisher = qm.getNotificationPublisher(DefaultNotificationPublishers.SCHEDULED_EMAIL.getPublisherName()); + ScheduledNotificationRule rule = qm.createScheduledNotificationRule("Example Rule", NotificationScope.PORTFOLIO, publisher); + Response response = jersey.target(V1_SCHEDULED_NOTIFICATION_RULE + "/" + rule.getUuid().toString() + "/team/" + UUID.randomUUID().toString()).request() + .header(X_API_KEY, apiKey) + .delete(); + Assert.assertEquals(404, response.getStatus(), 0); + Assert.assertNull(response.getHeaderString(TOTAL_COUNT_HEADER)); + String body = getPlainTextBody(response); + Assert.assertEquals("The team could not be found.", body); + } + + @Test + public void removeTeamFromRuleDuplicateTeamTest() { + Team team = qm.createTeam("Team Example", false); + NotificationPublisher publisher = qm.getNotificationPublisher(DefaultNotificationPublishers.SCHEDULED_EMAIL.getPublisherName()); + ScheduledNotificationRule rule = qm.createScheduledNotificationRule("Example Rule", NotificationScope.PORTFOLIO, publisher); + Response response = jersey.target(V1_SCHEDULED_NOTIFICATION_RULE + "/" + rule.getUuid().toString() + "/team/" + team.getUuid().toString()).request() + .header(X_API_KEY, apiKey) + .delete(); + Assert.assertEquals(304, response.getStatus(), 0); + Assert.assertNull(response.getHeaderString(TOTAL_COUNT_HEADER)); + } + + @Test + public void removeTeamToRuleInvalidPublisherTest(){ + Team team = qm.createTeam("Team Example", false); + NotificationPublisher publisher = qm.getNotificationPublisher(DefaultNotificationPublishers.SLACK.getPublisherName()); + ScheduledNotificationRule rule = qm.createScheduledNotificationRule("Example Rule", NotificationScope.PORTFOLIO, publisher); + Response response = jersey.target(V1_SCHEDULED_NOTIFICATION_RULE + "/" + rule.getUuid().toString() + "/team/" + team.getUuid().toString()).request() + .header(X_API_KEY, apiKey) + .delete(); + Assert.assertEquals(406, response.getStatus(), 0); + Assert.assertNull(response.getHeaderString(TOTAL_COUNT_HEADER)); + String body = getPlainTextBody(response); + Assert.assertEquals("Team subscriptions are only possible on scheduled notification rules with EMAIL publisher.", body); + } + + @Test + public void executeScheduledNotificationRuleNowTest(){ + NotificationPublisher publisher = qm.getNotificationPublisher(DefaultNotificationPublishers.SCHEDULED_EMAIL.getPublisherName()); + ScheduledNotificationRule rule = qm.createScheduledNotificationRule("Example Rule", NotificationScope.PORTFOLIO, publisher); + Response response = jersey.target(V1_SCHEDULED_NOTIFICATION_RULE + "/execute").request() + .header(X_API_KEY, apiKey) + .post(Entity.entity(rule, MediaType.APPLICATION_JSON)); + Assert.assertEquals(200, response.getStatus(), 0); + JsonObject json = parseJsonObject(response); + Assert.assertNotNull(json); + Assert.assertEquals("Example Rule", json.getString("name")); + Assert.assertEquals(rule.getUuid().toString(), json.getString("uuid")); + } + + @Test + public void executeScheduledNotificationRuleNowRuleDisabledTest(){ + NotificationPublisher publisher = qm.getNotificationPublisher(DefaultNotificationPublishers.SCHEDULED_EMAIL.getPublisherName()); + ScheduledNotificationRule rule = qm.createScheduledNotificationRule("Example Rule", NotificationScope.PORTFOLIO, publisher); + rule.setEnabled(false); + Response response = jersey.target(V1_SCHEDULED_NOTIFICATION_RULE + "/execute").request() + .header(X_API_KEY, apiKey) + .post(Entity.entity(rule, MediaType.APPLICATION_JSON)); + Assert.assertEquals(200, response.getStatus(), 0); + JsonObject json = parseJsonObject(response); + Assert.assertNotNull(json); + Assert.assertEquals("Example Rule", json.getString("name")); + Assert.assertEquals(rule.getUuid().toString(), json.getString("uuid")); + Assert.assertFalse(json.getBoolean("enabled")); + } + + @Test + public void executeScheduledNotificationRuleNowWithInvalidCronConfigTest(){ + NotificationPublisher publisher = qm.getNotificationPublisher(DefaultNotificationPublishers.SCHEDULED_EMAIL.getPublisherName()); + ScheduledNotificationRule rule = qm.createScheduledNotificationRule("Example Rule", NotificationScope.PORTFOLIO, publisher); + rule.setCronConfig("A B C D E"); + Response response = jersey.target(V1_SCHEDULED_NOTIFICATION_RULE + "/execute").request() + .header(X_API_KEY, apiKey) + .post(Entity.entity(rule, MediaType.APPLICATION_JSON)); + Assert.assertEquals(400, response.getStatus(), 0); + Assert.assertNull(response.getHeaderString(TOTAL_COUNT_HEADER)); + String body = getPlainTextBody(response); + Assert.assertEquals("Invalid cron expression", body); + } + + @Test + public void executeScheduledNotificationRuleNowInvalidRuleTest(){ + NotificationPublisher publisher = qm.getNotificationPublisher(DefaultNotificationPublishers.SCHEDULED_EMAIL.getPublisherName()); + ScheduledNotificationRule rule = qm.createScheduledNotificationRule("Example Rule", NotificationScope.PORTFOLIO, publisher); + // detach the rule to uncouple from database, else setUuid(...) will update the persistent entry and the request will be valid with http code 200 + rule = qm.detach(ScheduledNotificationRule.class, rule.getId()); + rule.setUuid(UUID.randomUUID()); + Response response = jersey.target(V1_SCHEDULED_NOTIFICATION_RULE + "/execute").request() + .header(X_API_KEY, apiKey) + .post(Entity.entity(rule, MediaType.APPLICATION_JSON)); + Assert.assertEquals(404, response.getStatus(), 0); + Assert.assertNull(response.getHeaderString(TOTAL_COUNT_HEADER)); + String body = getPlainTextBody(response); + Assert.assertEquals("The UUID of the scheduled notification rule could not be found.", body); + } +}