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" %}: +

+

+

+ {% 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 %} +
+

+
+ {% 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,