diff --git a/dojo/api_v2/serializers.py b/dojo/api_v2/serializers.py
index 4801e8038e9..93a8148e4cc 100644
--- a/dojo/api_v2/serializers.py
+++ b/dojo/api_v2/serializers.py
@@ -2957,6 +2957,9 @@ class NotificationsSerializer(serializers.ModelSerializer):
sla_breach = MultipleChoiceField(
choices=NOTIFICATION_CHOICES, default=DEFAULT_NOTIFICATION
)
+ sla_breach_combined = MultipleChoiceField(
+ choices=NOTIFICATION_CHOICES, default=DEFAULT_NOTIFICATION
+ )
risk_acceptance_expiration = MultipleChoiceField(
choices=NOTIFICATION_CHOICES, default=DEFAULT_NOTIFICATION
)
diff --git a/dojo/db_migrations/0191_notifications_sla_breach_combined.py b/dojo/db_migrations/0191_notifications_sla_breach_combined.py
new file mode 100644
index 00000000000..40209366c02
--- /dev/null
+++ b/dojo/db_migrations/0191_notifications_sla_breach_combined.py
@@ -0,0 +1,19 @@
+# Generated by Django 4.1.10 on 2023-09-12 11:29
+
+from django.db import migrations
+import multiselectfield.db.fields
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('dojo', '0190_system_settings_experimental_fp_history'),
+ ]
+
+ operations = [
+ migrations.AddField(
+ model_name='notifications',
+ name='sla_breach_combined',
+ field=multiselectfield.db.fields.MultiSelectField(blank=True, choices=[('slack', 'slack'), ('msteams', 'msteams'), ('mail', 'mail'), ('alert', 'alert')], default=('alert', 'alert'), help_text='Get notified of (upcoming) SLA breaches (a message per project)', max_length=24, verbose_name='SLA breach (combined)'),
+ ),
+ ]
diff --git a/dojo/forms.py b/dojo/forms.py
index e2d31684fca..dbc1c24b72d 100755
--- a/dojo/forms.py
+++ b/dojo/forms.py
@@ -2592,11 +2592,12 @@ def __init__(self, *args, **kwargs):
self.initial['test_added'] = ''
self.initial['scan_added'] = ''
self.initial['sla_breach'] = ''
+ self.initial['sla_breach_combined'] = ''
self.initial['risk_acceptance_expiration'] = ''
class Meta:
model = Notifications
- fields = ['engagement_added', 'close_engagement', 'test_added', 'scan_added', 'sla_breach', 'risk_acceptance_expiration']
+ fields = ['engagement_added', 'close_engagement', 'test_added', 'scan_added', 'sla_breach', 'sla_breach_combined', 'risk_acceptance_expiration']
class AjaxChoiceField(forms.ChoiceField):
diff --git a/dojo/models.py b/dojo/models.py
index ee75eca665b..5ca0da0aa79 100755
--- a/dojo/models.py
+++ b/dojo/models.py
@@ -3763,6 +3763,9 @@ class Notifications(models.Model):
sla_breach = MultiSelectField(choices=NOTIFICATION_CHOICES, default=DEFAULT_NOTIFICATION, blank=True,
verbose_name=_('SLA breach'),
help_text=_('Get notified of (upcoming) SLA breaches'))
+ sla_breach_combined = MultiSelectField(choices=NOTIFICATION_CHOICES, default=DEFAULT_NOTIFICATION, blank=True,
+ verbose_name=_('SLA breach (combined)'),
+ help_text=_('Get notified of (upcoming) SLA breaches (a message per project)'))
risk_acceptance_expiration = MultiSelectField(choices=NOTIFICATION_CHOICES, default='alert', blank=True,
verbose_name=_('Risk Acceptance Expiration'),
help_text=_('Get notified of (upcoming) Risk Acceptance expiries'))
@@ -3805,6 +3808,7 @@ def merge_notifications_list(cls, notifications_list):
result.review_requested = merge_sets_safe(result.review_requested, notifications.review_requested)
result.other = merge_sets_safe(result.other, notifications.other)
result.sla_breach = merge_sets_safe(result.sla_breach, notifications.sla_breach)
+ result.sla_breach_combined = merge_sets_safe(result.sla_breach_combined, notifications.sla_breach_combined)
result.risk_acceptance_expiration = merge_sets_safe(result.risk_acceptance_expiration, notifications.risk_acceptance_expiration)
return result
diff --git a/dojo/templates/notifications/mail/sla_breach_combined.tpl b/dojo/templates/notifications/mail/sla_breach_combined.tpl
new file mode 100644
index 00000000000..5b88a656e27
--- /dev/null
+++ b/dojo/templates/notifications/mail/sla_breach_combined.tpl
@@ -0,0 +1,72 @@
+{% load i18n %}
+{% load navigation_tags %}
+{% load display_tags %}
+
+
+ {% autoescape on %}
+ {% trans "Hello" %} {{ user.get_full_name }},
+
+ {% trans "Product summary" %}:
+
+ - {% trans "name" %}: {{ product.name }}
+ - {% trans "product type" %}: {{ product.prod_type }}
+ - {% trans "team manager" %}: {{ product.team_manager }}
+ - {% trans "product manager" %}: {{ product.product_manager }}
+ - {% trans "technical contact" %}: {{ product.technical_contact }}
+
+
+
+ {% if breach_kind == 'breached' %}
+ {% blocktranslate trimmed %}
+ These security findings have breached their SLA:
+ {% endblocktranslate %}
+ {% elif breach_kind == 'prebreach' %}
+ {% blocktranslate trimmed %}
+ These security findings are about to breach their SLA:
+ {% endblocktranslate %}
+ {% elif breach_kind == 'breaching' %}
+ {% blocktranslate trimmed %}
+ These security findings breaching their SLA today:
+ {% endblocktranslate %}
+ {% else %}
+ This should not happen, check 'breach_kind' and 'kind' properties value in the source code.
+ {% endif %}
+
+
+ {% for f in findings %}
+ {% url 'view_finding' f.id as finding_url %}
+ -
+ "{{ f.title }}" ({{ f.severity }} {% trans "severity" %}), {% trans "SLA age" %}: {{ f.sla_age }}
+
+ {% endfor %}
+
+
+ {% trans "Please refer to your SLA documentation for further guidance" %}
+
+ {% trans "Kind regards" %},
+
+ {% if system_settings.team_name %}
+ {{ system_settings.team_name }}
+ {% else %}
+ Defect Dojo
+ {% endif %}
+
+
+ {% url 'notifications' as notification_url %}
+ {% trans "You can manage your notification settings here" %}: {{ notification_url|full_url }}
+
+ {% if system_settings.disclaimer and system_settings.disclaimer.strip %}
+
+
+
{% trans "Disclaimer" %}
+
+
{{ system_settings.disclaimer }}
+
+ {% endif %}
+ {% endautoescape %}
+
+
diff --git a/dojo/utils.py b/dojo/utils.py
index 839e93e2020..27a0135d836 100644
--- a/dojo/utils.py
+++ b/dojo/utils.py
@@ -17,7 +17,7 @@
from django.conf import settings
from django.core.mail import send_mail
from django.core.paginator import Paginator
-from django.urls import get_resolver, reverse
+from django.urls import get_resolver, reverse, get_script_prefix
from django.db.models import Q, Sum, Case, When, IntegerField, Value, Count
from django.utils import timezone
from django.utils.translation import gettext as _
@@ -1921,19 +1921,89 @@ def sla_compute_and_notify(*args, **kwargs):
"""
import dojo.jira_link.helper as jira_helper
- def _notify(finding, title):
- if not finding.test.engagement.product.disable_sla_breach_notifications:
- create_notification(
- event='sla_breach',
- title=title,
- finding=finding,
- url=reverse('view_finding', args=(finding.id,)),
- sla_age=sla_age
- )
+ class NotificationEntry:
+ def __init__(self, finding=None, jira_issue=None, do_jira_sla_comment=False):
+ self.finding = finding
+ self.jira_issue = jira_issue
+ self.do_jira_sla_comment = do_jira_sla_comment
+
+ def _add_notification(finding, kind):
+ # jira_issue, do_jira_sla_comment are taken from the context
+ # kind can be one of: breached, prebreach, breaching
+ if finding.test.engagement.product.disable_sla_breach_notifications:
+ return
+
+ notification = NotificationEntry(finding=finding,
+ jira_issue=jira_issue,
+ do_jira_sla_comment=do_jira_sla_comment)
- if do_jira_sla_comment:
- logger.info("Creating JIRA comment to notify of SLA breach information.")
- jira_helper.add_simple_jira_comment(jira_instance, jira_issue, title)
+ pt = finding.test.engagement.product.prod_type.name
+ p = finding.test.engagement.product.name
+
+ if pt in combined_notifications:
+ if p in combined_notifications[pt]:
+ if kind in combined_notifications[pt][p]:
+ combined_notifications[pt][p][kind].append(notification)
+ else:
+ combined_notifications[pt][p][kind] = [notification]
+ else:
+ combined_notifications[pt][p] = {kind: [notification]}
+ else:
+ combined_notifications[pt] = {p: {kind: [notification]}}
+
+ def _notification_title_for_finding(finding, kind, sla_age):
+ title = "Finding %s - " % (finding.id)
+ if kind == 'breached':
+ abs_sla_age = abs(sla_age)
+ period = "day"
+ if abs_sla_age > 1:
+ period = "days"
+ title += "SLA breached by %d %s! Overdue notice" % (abs_sla_age, period)
+ elif kind == 'prebreach':
+ title += "SLA pre-breach warning - %d day(s) left" % (sla_age)
+ elif kind == 'breaching':
+ title += "SLA is breaching today"
+
+ return title
+
+ def _create_notifications():
+ for pt in combined_notifications:
+ for p in combined_notifications[pt]:
+ for kind in combined_notifications[pt][p]:
+ # creating notifications on per-finding basis
+
+ # we need this list for combined notification feature as we
+ # can not supply references to local objects as
+ # create_notification() arguments
+ findings_list = []
+
+ for n in combined_notifications[pt][p][kind]:
+ title = _notification_title_for_finding(n.finding, kind, n.finding.sla_days_remaining())
+
+ create_notification(
+ event='sla_breach',
+ title=title,
+ finding=n.finding,
+ url=reverse('view_finding', args=(n.finding.id,)),
+ )
+
+ if n.do_jira_sla_comment:
+ logger.info("Creating JIRA comment to notify of SLA breach information.")
+ jira_helper.add_simple_jira_comment(jira_instance, n.jira_issue, title)
+
+ findings_list.append(n.finding)
+
+ # producing a "combined" SLA breach notification
+ title_combined = "SLA alert (%s): product type '%s', product '%s'" % (kind, pt, p)
+ product = combined_notifications[pt][p][kind][0].finding.test.engagement.product
+ create_notification(
+ event='sla_breach_combined',
+ title=title_combined,
+ product=product,
+ findings=findings_list,
+ breach_kind=kind,
+ base_url=get_script_prefix(),
+ )
# exit early on flags
system_settings = System_Settings.objects.get()
@@ -1943,6 +2013,8 @@ def _notify(finding, title):
jira_issue = None
jira_instance = None
+ # notifications list per product per product type
+ combined_notifications = {}
try:
if system_settings.enable_finding_sla:
logger.info("About to process findings for SLA notifications.")
@@ -2031,23 +2103,21 @@ def _notify(finding, title):
logger.info("Finding {} has breached by {} days.".format(finding.id, abs(sla_age)))
abs_sla_age = abs(sla_age)
if not system_settings.enable_notify_sla_exponential_backoff or abs_sla_age == 1 or (abs_sla_age & (abs_sla_age - 1) == 0):
- period = "day"
- if abs_sla_age > 1:
- period = "days"
- _notify(finding, 'Finding {} - SLA breached by {} {}! Overdue notice'.format(finding.id, abs_sla_age, period))
+ _add_notification(finding, 'breached')
else:
logger.info("Skipping notification as exponential backoff is enabled and the SLA is not a power of two")
# The finding is within the pre-breach period
elif (sla_age > 0) and (sla_age <= settings.SLA_NOTIFY_PRE_BREACH):
pre_breach_count += 1
logger.info("Security SLA pre-breach warning for finding ID {}. Days remaining: {}".format(finding.id, sla_age))
- _notify(finding, 'Finding {} - SLA pre-breach warning - {} day(s) left'.format(finding.id, sla_age))
+ _add_notification(finding, 'prebreach')
# The finding breaches the SLA today
elif (sla_age == 0):
at_breach_count += 1
logger.info("Security SLA breach warning. Finding ID {} breaching today ({})".format(finding.id, sla_age))
- _notify(finding, "Finding {} - SLA is breaching today".format(finding.id))
+ _add_notification(finding, 'breaching')
+ _create_notifications()
logger.info("SLA run results: Pre-breach: {}, at-breach: {}, post-breach: {}, post-breach-no-notify: {}, with-jira: {}, TOTAL: {}".format(
pre_breach_count,
at_breach_count,