From 991d3b79a84382c6f073f95c49b6b562797ba116 Mon Sep 17 00:00:00 2001 From: Cody Maffucci <46459665+Maffooch@users.noreply.github.com> Date: Mon, 27 Apr 2026 16:39:03 -0600 Subject: [PATCH] Consolidate notification code into dojo/notifications/ package Move notification models, admin, services (helper.py), signals, tasks, settings, context processors, forms, UI views/urls/templates, and API serializers/viewsets/urls into dojo/notifications/, matching the canonical dojo/url/ shape from CLAUDE.md. - Notifications, Notification_Webhooks, Alerts (and constants) extracted from dojo/models.py into dojo/notifications/models.py; admin moved to dojo/notifications/admin.py using @admin.register(); registered from dojo/apps.py ready() to keep model class definition out of the settings-loading import chain. - helper.py absorbs process_tag_notifications and sla_compute_and_notify from dojo/utils.py. All @app.task wrappers (log_generic_alert, add_alerts, cleanup_alerts, async_sla_compute_and_notify_task, async_create_notification, send_{slack,msteams,mail,webhooks}_notification, webhook_reactivation, webhook_status_cleanup) consolidated into dojo/notifications/tasks.py. async_create_notification calls into helper.create_notification via lazy import so test patches still apply. - Default-Notifications-on-User-create logic moved out of dojo/utils.py into dojo/notifications/signals.py and registered via apps.py ready(). - 7 UI .html templates and 62 channel .tpl templates moved under dojo/notifications/templates/notifications/. New TEMPLATES DIRS entry added so notifications/{channel}/{event}.tpl lookups still resolve; view template_name strings updated where the namespace changed. - Env-var schema extracted into dojo/notifications/settings.py (NOTIFICATIONS_ENV_DEFAULTS dict + populate_settings); settings.dist.py unpacks it into env() and applies values to globals(). - bind_alert_count and session_expiry_notification moved to dojo/notifications/context_processors.py; TEMPLATES paths updated. - API NotificationsSerializer/NotificationWebhooksSerializer and Viewsets relocated under dojo/notifications/api/, exposed via add_notifications_urls(router) (CLAUDE.md API pattern). - Celery beat task paths updated to dojo.notifications.tasks.*. - Backward-compatible re-exports preserved at every original location (dojo/models.py, dojo/forms.py, dojo/utils.py, dojo/tasks.py, dojo/context_processors.py, dojo/api_v2/{serializers,views}.py, dojo/notifications/helper.py). - No new migrations (string FK references are migration-equivalent). Verified: manage.py check, makemigrations --check (no diff), spectacular --fail-on-warn, the notification + cleanup-alerts + jira-webhook + REST framework test suites pass, and ruff check . is clean for new code. Co-Authored-By: Claude Opus 4.7 (1M context) --- dojo/api_v2/serializers.py | 116 +----- dojo/api_v2/views.py | 27 -- dojo/apps.py | 2 + dojo/context_processors.py | 30 +- dojo/forms.py | 57 +-- dojo/models.py | 149 +------ dojo/notifications/admin.py | 23 ++ dojo/notifications/api/__init__.py | 1 + dojo/notifications/api/serializer.py | 123 ++++++ dojo/notifications/api/urls.py | 11 + dojo/notifications/api/views.py | 36 ++ dojo/notifications/context_processors.py | 30 ++ dojo/notifications/helper.py | 364 ++++++++++++------ dojo/notifications/models.py | 132 +++++++ dojo/notifications/settings.py | 28 ++ dojo/notifications/signals.py | 34 ++ dojo/notifications/tasks.py | 239 ++++++++++++ .../add_notification_webhook.html | 0 .../notifications/alert/engagement_added.tpl | 0 .../notifications/alert/engagement_closed.tpl | 0 .../templates/notifications/alert/other.tpl | 0 .../notifications/alert/product_added.tpl | 0 .../alert/product_type_added.tpl | 0 .../notifications/alert/review_requested.tpl | 0 .../notifications/alert/scan_added_empty.tpl | 0 .../notifications/alert/sla_breach.tpl | 0 .../notifications/alert/test_added.tpl | 0 .../alert/upcoming_engagement.tpl | 0 .../notifications/alert/user_mentioned.tpl | 0 .../templates/notifications}/alerts.html | 0 .../notifications}/delete_alerts.html | 0 .../delete_notification_webhook.html | 0 .../edit_notification_webhook.html | 0 .../notifications/mail/engagement_added.tpl | 0 .../notifications/mail/engagement_closed.tpl | 0 .../templates/notifications/mail/other.tpl | 0 .../notifications/mail/product_added.tpl | 0 .../notifications/mail/product_type_added.tpl | 0 .../notifications/mail/review_requested.tpl | 0 .../mail/risk_acceptance_expiration.tpl | 0 .../notifications/mail/scan_added.tpl | 0 .../notifications/mail/scan_added_empty.tpl | 0 .../notifications/mail/sla_breach.tpl | 0 .../mail/sla_breach_combined.tpl | 0 .../notifications/mail/test_added.tpl | 0 .../mail/upcoming_engagement.tpl | 0 .../notifications/mail/user_mentioned.tpl | 0 .../msteams/engagement_added.tpl | 0 .../msteams/engagement_closed.tpl | 0 .../templates/notifications/msteams/other.tpl | 0 .../notifications/msteams/product_added.tpl | 0 .../msteams/product_type_added.tpl | 0 .../msteams/review_requested.tpl | 0 .../msteams/risk_acceptance_expiration.tpl | 0 .../notifications/msteams/scan_added.tpl | 0 .../msteams/scan_added_empty.tpl | 0 .../notifications/msteams/sla_breach.tpl | 0 .../notifications/msteams/test_added.tpl | 0 .../msteams/upcoming_engagement.tpl | 0 .../notifications/msteams/user_mentioned.tpl | 0 .../notifications}/notifications.html | 0 .../notifications/slack/engagement_added.tpl | 0 .../notifications/slack/engagement_closed.tpl | 0 .../templates/notifications/slack/other.tpl | 0 .../notifications/slack/product_added.tpl | 0 .../slack/product_type_added.tpl | 0 .../notifications/slack/report_created.tpl | 0 .../notifications/slack/review_requested.tpl | 0 .../slack/risk_acceptance_expiration.tpl | 0 .../notifications/slack/scan_added.tpl | 0 .../notifications/slack/scan_added_empty.tpl | 0 .../notifications/slack/sla_breach.tpl | 0 .../notifications/slack/test_added.tpl | 0 .../slack/upcoming_engagement.tpl | 0 .../notifications/slack/user_mentioned.tpl | 0 .../view_notification_webhooks.html | 0 .../webhooks/engagement_added.tpl | 0 .../notifications/webhooks/other.tpl | 0 .../notifications/webhooks/product_added.tpl | 0 .../webhooks/product_type_added.tpl | 0 .../notifications/webhooks/scan_added.tpl | 0 .../webhooks/scan_added_empty.tpl | 0 .../webhooks/subtemplates/base.tpl | 0 .../webhooks/subtemplates/engagement.tpl | 0 .../webhooks/subtemplates/findings_list.tpl | 0 .../webhooks/subtemplates/product.tpl | 0 .../webhooks/subtemplates/product_type.tpl | 0 .../webhooks/subtemplates/test.tpl | 0 .../webhooks/subtemplates/user.tpl | 0 .../notifications/webhooks/test_added.tpl | 0 dojo/notifications/ui/__init__.py | 0 dojo/notifications/ui/forms.py | 54 +++ dojo/notifications/{ => ui}/urls.py | 0 dojo/notifications/{ => ui}/views.py | 18 +- dojo/settings/settings.dist.py | 48 +-- dojo/tasks.py | 91 +---- dojo/urls.py | 8 +- dojo/user/views.py | 4 +- dojo/utils.py | 259 +------------ unittests/test_notifications.py | 4 +- unittests/test_rest_framework.py | 3 +- unittests/test_utils.py | 65 +--- 102 files changed, 1048 insertions(+), 908 deletions(-) create mode 100644 dojo/notifications/admin.py create mode 100644 dojo/notifications/api/__init__.py create mode 100644 dojo/notifications/api/serializer.py create mode 100644 dojo/notifications/api/urls.py create mode 100644 dojo/notifications/api/views.py create mode 100644 dojo/notifications/context_processors.py create mode 100644 dojo/notifications/models.py create mode 100644 dojo/notifications/settings.py create mode 100644 dojo/notifications/signals.py create mode 100644 dojo/notifications/tasks.py rename dojo/{templates/dojo => notifications/templates/notifications}/add_notification_webhook.html (100%) rename dojo/{ => notifications}/templates/notifications/alert/engagement_added.tpl (100%) rename dojo/{ => notifications}/templates/notifications/alert/engagement_closed.tpl (100%) rename dojo/{ => notifications}/templates/notifications/alert/other.tpl (100%) rename dojo/{ => notifications}/templates/notifications/alert/product_added.tpl (100%) rename dojo/{ => notifications}/templates/notifications/alert/product_type_added.tpl (100%) rename dojo/{ => notifications}/templates/notifications/alert/review_requested.tpl (100%) rename dojo/{ => notifications}/templates/notifications/alert/scan_added_empty.tpl (100%) rename dojo/{ => notifications}/templates/notifications/alert/sla_breach.tpl (100%) rename dojo/{ => notifications}/templates/notifications/alert/test_added.tpl (100%) rename dojo/{ => notifications}/templates/notifications/alert/upcoming_engagement.tpl (100%) rename dojo/{ => notifications}/templates/notifications/alert/user_mentioned.tpl (100%) rename dojo/{templates/dojo => notifications/templates/notifications}/alerts.html (100%) rename dojo/{templates/dojo => notifications/templates/notifications}/delete_alerts.html (100%) rename dojo/{templates/dojo => notifications/templates/notifications}/delete_notification_webhook.html (100%) rename dojo/{templates/dojo => notifications/templates/notifications}/edit_notification_webhook.html (100%) rename dojo/{ => notifications}/templates/notifications/mail/engagement_added.tpl (100%) rename dojo/{ => notifications}/templates/notifications/mail/engagement_closed.tpl (100%) rename dojo/{ => notifications}/templates/notifications/mail/other.tpl (100%) rename dojo/{ => notifications}/templates/notifications/mail/product_added.tpl (100%) rename dojo/{ => notifications}/templates/notifications/mail/product_type_added.tpl (100%) rename dojo/{ => notifications}/templates/notifications/mail/review_requested.tpl (100%) rename dojo/{ => notifications}/templates/notifications/mail/risk_acceptance_expiration.tpl (100%) rename dojo/{ => notifications}/templates/notifications/mail/scan_added.tpl (100%) rename dojo/{ => notifications}/templates/notifications/mail/scan_added_empty.tpl (100%) rename dojo/{ => notifications}/templates/notifications/mail/sla_breach.tpl (100%) rename dojo/{ => notifications}/templates/notifications/mail/sla_breach_combined.tpl (100%) rename dojo/{ => notifications}/templates/notifications/mail/test_added.tpl (100%) rename dojo/{ => notifications}/templates/notifications/mail/upcoming_engagement.tpl (100%) rename dojo/{ => notifications}/templates/notifications/mail/user_mentioned.tpl (100%) rename dojo/{ => notifications}/templates/notifications/msteams/engagement_added.tpl (100%) rename dojo/{ => notifications}/templates/notifications/msteams/engagement_closed.tpl (100%) rename dojo/{ => notifications}/templates/notifications/msteams/other.tpl (100%) rename dojo/{ => notifications}/templates/notifications/msteams/product_added.tpl (100%) rename dojo/{ => notifications}/templates/notifications/msteams/product_type_added.tpl (100%) rename dojo/{ => notifications}/templates/notifications/msteams/review_requested.tpl (100%) rename dojo/{ => notifications}/templates/notifications/msteams/risk_acceptance_expiration.tpl (100%) rename dojo/{ => notifications}/templates/notifications/msteams/scan_added.tpl (100%) rename dojo/{ => notifications}/templates/notifications/msteams/scan_added_empty.tpl (100%) rename dojo/{ => notifications}/templates/notifications/msteams/sla_breach.tpl (100%) rename dojo/{ => notifications}/templates/notifications/msteams/test_added.tpl (100%) rename dojo/{ => notifications}/templates/notifications/msteams/upcoming_engagement.tpl (100%) rename dojo/{ => notifications}/templates/notifications/msteams/user_mentioned.tpl (100%) rename dojo/{templates/dojo => notifications/templates/notifications}/notifications.html (100%) rename dojo/{ => notifications}/templates/notifications/slack/engagement_added.tpl (100%) rename dojo/{ => notifications}/templates/notifications/slack/engagement_closed.tpl (100%) rename dojo/{ => notifications}/templates/notifications/slack/other.tpl (100%) rename dojo/{ => notifications}/templates/notifications/slack/product_added.tpl (100%) rename dojo/{ => notifications}/templates/notifications/slack/product_type_added.tpl (100%) rename dojo/{ => notifications}/templates/notifications/slack/report_created.tpl (100%) rename dojo/{ => notifications}/templates/notifications/slack/review_requested.tpl (100%) rename dojo/{ => notifications}/templates/notifications/slack/risk_acceptance_expiration.tpl (100%) rename dojo/{ => notifications}/templates/notifications/slack/scan_added.tpl (100%) rename dojo/{ => notifications}/templates/notifications/slack/scan_added_empty.tpl (100%) rename dojo/{ => notifications}/templates/notifications/slack/sla_breach.tpl (100%) rename dojo/{ => notifications}/templates/notifications/slack/test_added.tpl (100%) rename dojo/{ => notifications}/templates/notifications/slack/upcoming_engagement.tpl (100%) rename dojo/{ => notifications}/templates/notifications/slack/user_mentioned.tpl (100%) rename dojo/{templates/dojo => notifications/templates/notifications}/view_notification_webhooks.html (100%) rename dojo/{ => notifications}/templates/notifications/webhooks/engagement_added.tpl (100%) rename dojo/{ => notifications}/templates/notifications/webhooks/other.tpl (100%) rename dojo/{ => notifications}/templates/notifications/webhooks/product_added.tpl (100%) rename dojo/{ => notifications}/templates/notifications/webhooks/product_type_added.tpl (100%) rename dojo/{ => notifications}/templates/notifications/webhooks/scan_added.tpl (100%) rename dojo/{ => notifications}/templates/notifications/webhooks/scan_added_empty.tpl (100%) rename dojo/{ => notifications}/templates/notifications/webhooks/subtemplates/base.tpl (100%) rename dojo/{ => notifications}/templates/notifications/webhooks/subtemplates/engagement.tpl (100%) rename dojo/{ => notifications}/templates/notifications/webhooks/subtemplates/findings_list.tpl (100%) rename dojo/{ => notifications}/templates/notifications/webhooks/subtemplates/product.tpl (100%) rename dojo/{ => notifications}/templates/notifications/webhooks/subtemplates/product_type.tpl (100%) rename dojo/{ => notifications}/templates/notifications/webhooks/subtemplates/test.tpl (100%) rename dojo/{ => notifications}/templates/notifications/webhooks/subtemplates/user.tpl (100%) rename dojo/{ => notifications}/templates/notifications/webhooks/test_added.tpl (100%) create mode 100644 dojo/notifications/ui/__init__.py create mode 100644 dojo/notifications/ui/forms.py rename dojo/notifications/{ => ui}/urls.py (100%) rename dojo/notifications/{ => ui}/views.py (96%) diff --git a/dojo/api_v2/serializers.py b/dojo/api_v2/serializers.py index 0fe27f0255c..d1d36de473a 100644 --- a/dojo/api_v2/serializers.py +++ b/dojo/api_v2/serializers.py @@ -21,7 +21,7 @@ from rest_framework import serializers from rest_framework.exceptions import NotFound from rest_framework.exceptions import ValidationError as RestFrameworkValidationError -from rest_framework.fields import DictField, MultipleChoiceField +from rest_framework.fields import DictField import dojo.finding.helper as finding_helper import dojo.risk_acceptance.helper as ra_helper @@ -43,9 +43,7 @@ from dojo.jira import services as jira_services from dojo.location.models import Location, LocationFindingReference from dojo.models import ( - DEFAULT_NOTIFICATION, IMPORT_ACTIONS, - NOTIFICATION_CHOICES, SEVERITIES, SEVERITY_CHOICES, STATS_FIELDS, @@ -82,8 +80,6 @@ Note_Type, NoteHistory, Notes, - Notification_Webhooks, - Notifications, Product, Product_API_Scan_Configuration, Product_Group, @@ -3069,110 +3065,7 @@ class FindingNoteSerializer(serializers.Serializer): note_id = serializers.IntegerField() -class NotificationsSerializer(serializers.ModelSerializer): - product = serializers.PrimaryKeyRelatedField( - queryset=Product.objects.all(), - required=False, - default=None, - allow_null=True, - ) - user = serializers.PrimaryKeyRelatedField( - queryset=Dojo_User.objects.all(), - required=False, - default=None, - allow_null=True, - ) - product_type_added = MultipleChoiceField( - choices=NOTIFICATION_CHOICES, default=DEFAULT_NOTIFICATION, - ) - product_added = MultipleChoiceField( - choices=NOTIFICATION_CHOICES, default=DEFAULT_NOTIFICATION, - ) - engagement_added = MultipleChoiceField( - choices=NOTIFICATION_CHOICES, default=DEFAULT_NOTIFICATION, - ) - test_added = MultipleChoiceField( - choices=NOTIFICATION_CHOICES, default=DEFAULT_NOTIFICATION, - ) - scan_added = MultipleChoiceField( - choices=NOTIFICATION_CHOICES, default=DEFAULT_NOTIFICATION, - ) - jira_update = MultipleChoiceField( - choices=NOTIFICATION_CHOICES, default=DEFAULT_NOTIFICATION, - ) - upcoming_engagement = MultipleChoiceField( - choices=NOTIFICATION_CHOICES, default=DEFAULT_NOTIFICATION, - ) - stale_engagement = MultipleChoiceField( - choices=NOTIFICATION_CHOICES, default=DEFAULT_NOTIFICATION, - ) - auto_close_engagement = MultipleChoiceField( - choices=NOTIFICATION_CHOICES, default=DEFAULT_NOTIFICATION, - ) - close_engagement = MultipleChoiceField( - choices=NOTIFICATION_CHOICES, default=DEFAULT_NOTIFICATION, - ) - user_mentioned = MultipleChoiceField( - choices=NOTIFICATION_CHOICES, default=DEFAULT_NOTIFICATION, - ) - code_review = MultipleChoiceField( - choices=NOTIFICATION_CHOICES, default=DEFAULT_NOTIFICATION, - ) - review_requested = MultipleChoiceField( - choices=NOTIFICATION_CHOICES, default=DEFAULT_NOTIFICATION, - ) - other = MultipleChoiceField( - choices=NOTIFICATION_CHOICES, default=DEFAULT_NOTIFICATION, - ) - 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, - ) - template = serializers.BooleanField(default=False) - - class Meta: - model = Notifications - fields = "__all__" - - def validate(self, data): - user = None - product = None - template = False - - if self.instance is not None: - user = self.instance.user - product = self.instance.product - - if "user" in data: - user = data.get("user") - if "product" in data: - product = data.get("product") - if "template" in data: - template = data.get("template") - - if ( - template - and Notifications.objects.filter(template=True).count() > 0 - ): - msg = "Notification template already exists" - raise ValidationError(msg) - if ( - self.instance is None - or user != self.instance.user - or product != self.instance.product - ): - notifications = Notifications.objects.filter( - user=user, product=product, template=template, - ).count() - if notifications > 0: - msg = "Notification for user and product already exists" - raise ValidationError(msg) - return data +from dojo.notifications.api.serializer import NotificationsSerializer # noqa: E402, F401 -- backward compat class EngagementPresetsSerializer(serializers.ModelSerializer): @@ -3349,7 +3242,4 @@ def create(self, validated_data): raise -class NotificationWebhooksSerializer(serializers.ModelSerializer): - class Meta: - model = Notification_Webhooks - fields = "__all__" +from dojo.notifications.api.serializer import NotificationWebhooksSerializer # noqa: E402, F401 -- backward compat diff --git a/dojo/api_v2/views.py b/dojo/api_v2/views.py index 4da5a02885c..9e081d59696 100644 --- a/dojo/api_v2/views.py +++ b/dojo/api_v2/views.py @@ -119,8 +119,6 @@ Note_Type, NoteHistory, Notes, - Notification_Webhooks, - Notifications, Product, Product_API_Scan_Configuration, Product_Group, @@ -3406,21 +3404,6 @@ def queue_task_purge(self, request): return Response({"purged": purged}) -# Authorization: superuser -@extend_schema_view(**schema_with_prefetch()) -class NotificationsViewSet( - PrefetchDojoModelViewSet, -): - serializer_class = serializers.NotificationsSerializer - queryset = Notifications.objects.none() - filter_backends = (DjangoFilterBackend,) - filterset_fields = ["id", "user", "product", "template"] - permission_classes = (permissions.IsSuperUser, DjangoModelPermissions) - - def get_queryset(self): - return Notifications.objects.all().order_by("id") - - @extend_schema_view(**schema_with_prefetch()) class EngagementPresetsViewset( PrefetchDojoModelViewSet, @@ -3683,13 +3666,3 @@ class AnnouncementViewSet( def get_queryset(self): return Announcement.objects.all().order_by("id") - - -class NotificationWebhooksViewSet( - PrefetchDojoModelViewSet, -): - serializer_class = serializers.NotificationWebhooksSerializer - queryset = Notification_Webhooks.objects.all() - filter_backends = (DjangoFilterBackend,) - filterset_fields = "__all__" - permission_classes = (permissions.IsSuperUser, DjangoModelPermissions) # TODO: add permission also for other users diff --git a/dojo/apps.py b/dojo/apps.py index 96b8c87af34..89820e2bf12 100644 --- a/dojo/apps.py +++ b/dojo/apps.py @@ -84,6 +84,8 @@ def ready(self): import dojo.file_uploads.signals # noqa: PLC0415, F401 raised: AppRegistryNotReady import dojo.finding_group.signals # noqa: PLC0415, F401 raised: AppRegistryNotReady import dojo.notes.signals # noqa: PLC0415, F401 raised: AppRegistryNotReady + import dojo.notifications.admin # noqa: PLC0415, F401 raised: AppRegistryNotReady + import dojo.notifications.signals # noqa: PLC0415, F401 raised: AppRegistryNotReady import dojo.product.signals # noqa: PLC0415, F401 raised: AppRegistryNotReady import dojo.product_type.signals # noqa: PLC0415, F401 raised: AppRegistryNotReady import dojo.risk_acceptance.signals # noqa: PLC0415, F401 raised: AppRegistryNotReady diff --git a/dojo/context_processors.py b/dojo/context_processors.py index 792e1eb6b42..fe6893b5eab 100644 --- a/dojo/context_processors.py +++ b/dojo/context_processors.py @@ -1,5 +1,4 @@ import contextlib -import time # import the settings file from django.conf import settings @@ -7,7 +6,7 @@ from dojo.announcement.os_message import get_os_banner from dojo.labels import get_labels -from dojo.models import Alerts, System_Settings, UserAnnouncement +from dojo.models import System_Settings, UserAnnouncement def globalize_vars(request): @@ -86,14 +85,6 @@ def bind_system_settings(request): return {"system_settings": system_settings} -def bind_alert_count(request): - if not settings.DISABLE_ALERT_COUNTER: - - if hasattr(request, "user") and request.user.is_authenticated: - return {"alert_count": Alerts.objects.filter(user_id=request.user).count()} - return {} - - def bind_announcement(request): with contextlib.suppress(Exception): # TODO: this should be replaced with more meaningful exception if request.user.is_authenticated: @@ -104,21 +95,10 @@ def bind_announcement(request): return {} -def session_expiry_notification(request): - try: - if request.user.is_authenticated: - last_activity = request.session.get("_last_activity", time.time()) - expiry_time = last_activity + settings.SESSION_COOKIE_AGE # When the session will expire - warning_time = settings.SESSION_EXPIRE_WARNING # Show warning X seconds before expiry - notify_time = expiry_time - warning_time - else: - notify_time = None - except Exception: - return {} - else: - return { - "session_notify_time": notify_time, - } +from dojo.notifications.context_processors import ( # noqa: E402, F401 -- backward compat + bind_alert_count, + session_expiry_notification, +) def labels(request): diff --git a/dojo/forms.py b/dojo/forms.py index 211314915b3..af27bb274ae 100644 --- a/dojo/forms.py +++ b/dojo/forms.py @@ -85,8 +85,6 @@ Global_Role, Note_Type, Notes, - Notification_Webhooks, - Notifications, Objects_Product, Product, Product_API_Scan_Configuration, @@ -3155,55 +3153,12 @@ class Meta: exclude = [""] -class NotificationsForm(forms.ModelForm): - - class Meta: - model = Notifications - exclude = ["template"] - - -class NotificationsWebhookForm(forms.ModelForm): - class Meta: - model = Notification_Webhooks - exclude = [] - - def __init__(self, *args, **kwargs): - is_superuser = kwargs.pop("is_superuser", False) - super().__init__(*args, **kwargs) - if not is_superuser: # Only superadmins can edit owner - self.fields["owner"].disabled = True # TODO: needs to be tested - - -class DeleteNotificationsWebhookForm(forms.ModelForm): - id = forms.IntegerField(required=True, - widget=forms.widgets.HiddenInput()) - - def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) - self.fields["name"].disabled = True - self.fields["url"].disabled = True - - class Meta: - model = Notification_Webhooks - fields = ["id", "name", "url"] - - -class ProductNotificationsForm(forms.ModelForm): - - def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) - if not self.instance.id: - self.initial["engagement_added"] = "" - self.initial["close_engagement"] = "" - 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", "sla_breach_combined", "risk_acceptance_expiration"] +from dojo.notifications.ui.forms import ( # noqa: E402, F401 -- backward compat + DeleteNotificationsWebhookForm, + NotificationsForm, + NotificationsWebhookForm, + ProductNotificationsForm, +) class AjaxChoiceField(forms.ChoiceField): diff --git a/dojo/models.py b/dojo/models.py index e80e22aa099..ad7d6538614 100644 --- a/dojo/models.py +++ b/dojo/models.py @@ -38,7 +38,6 @@ from django.utils.timezone import now from django.utils.translation import gettext as _ from django_extensions.db.models import TimeStampedModel -from multiselectfield import MultiSelectField from polymorphic.base import ManagerInheritanceWarning from polymorphic.managers import PolymorphicManager from polymorphic.models import PolymorphicModel @@ -4131,132 +4130,20 @@ def __str__(self): JIRA_Issue, JIRA_Project, ) - -NOTIFICATION_CHOICE_SLACK = ("slack", "slack") -NOTIFICATION_CHOICE_MSTEAMS = ("msteams", "msteams") -NOTIFICATION_CHOICE_MAIL = ("mail", "mail") -NOTIFICATION_CHOICE_WEBHOOKS = ("webhooks", "webhooks") -NOTIFICATION_CHOICE_ALERT = ("alert", "alert") - -NOTIFICATION_CHOICES = ( - NOTIFICATION_CHOICE_SLACK, - NOTIFICATION_CHOICE_MSTEAMS, +from dojo.notifications.admin import NotificationsAdmin # noqa: E402, F401 -- backward compat +from dojo.notifications.models import ( # noqa: E402, F401 -- backward compat + DEFAULT_NOTIFICATION, + NOTIFICATION_CHOICE_ALERT, NOTIFICATION_CHOICE_MAIL, + NOTIFICATION_CHOICE_MSTEAMS, + NOTIFICATION_CHOICE_SLACK, NOTIFICATION_CHOICE_WEBHOOKS, - NOTIFICATION_CHOICE_ALERT, + NOTIFICATION_CHOICES, + Alerts, + Notification_Webhooks, + Notifications, ) -DEFAULT_NOTIFICATION = NOTIFICATION_CHOICE_ALERT - - -class Notifications(models.Model): - product_type_added = MultiSelectField(choices=NOTIFICATION_CHOICES, default=DEFAULT_NOTIFICATION, blank=True) - product_added = MultiSelectField(choices=NOTIFICATION_CHOICES, default=DEFAULT_NOTIFICATION, blank=True) - engagement_added = MultiSelectField(choices=NOTIFICATION_CHOICES, default=DEFAULT_NOTIFICATION, blank=True) - test_added = MultiSelectField(choices=NOTIFICATION_CHOICES, default=DEFAULT_NOTIFICATION, blank=True) - - scan_added = MultiSelectField(choices=NOTIFICATION_CHOICES, default=DEFAULT_NOTIFICATION, blank=True, help_text=_("Triggered whenever an (re-)import has been done that created/updated/closed findings.")) - scan_added_empty = MultiSelectField(choices=NOTIFICATION_CHOICES, default=[], blank=True, help_text=_("Triggered whenever an (re-)import has been done (even if that created/updated/closed no findings).")) - jira_update = MultiSelectField(choices=NOTIFICATION_CHOICES, default=DEFAULT_NOTIFICATION, blank=True, verbose_name=_("JIRA problems"), help_text=_("JIRA sync happens in the background, errors will be shown as notifications/alerts so make sure to subscribe")) - upcoming_engagement = MultiSelectField(choices=NOTIFICATION_CHOICES, default=DEFAULT_NOTIFICATION, blank=True) - stale_engagement = MultiSelectField(choices=NOTIFICATION_CHOICES, default=DEFAULT_NOTIFICATION, blank=True) - auto_close_engagement = MultiSelectField(choices=NOTIFICATION_CHOICES, default=DEFAULT_NOTIFICATION, blank=True) - close_engagement = MultiSelectField(choices=NOTIFICATION_CHOICES, default=DEFAULT_NOTIFICATION, blank=True) - user_mentioned = MultiSelectField(choices=NOTIFICATION_CHOICES, default=DEFAULT_NOTIFICATION, blank=True) - code_review = MultiSelectField(choices=NOTIFICATION_CHOICES, default=DEFAULT_NOTIFICATION, blank=True) - review_requested = MultiSelectField(choices=NOTIFICATION_CHOICES, default=DEFAULT_NOTIFICATION, blank=True) - other = MultiSelectField(choices=NOTIFICATION_CHOICES, default=DEFAULT_NOTIFICATION, blank=True) - user = models.ForeignKey(Dojo_User, default=None, null=True, editable=False, on_delete=models.CASCADE) - product = models.ForeignKey(Product, default=None, null=True, editable=False, on_delete=models.CASCADE) - template = models.BooleanField(default=False) - sla_breach = MultiSelectField(choices=NOTIFICATION_CHOICES, default=DEFAULT_NOTIFICATION, blank=True, - verbose_name=_("SLA breach"), - help_text=_("Get notified of (upcoming) SLA breaches")) - risk_acceptance_expiration = MultiSelectField(choices=NOTIFICATION_CHOICES, default=DEFAULT_NOTIFICATION, blank=True, - verbose_name=_("Risk Acceptance Expiration"), - help_text=_("Get notified of (upcoming) Risk Acceptance expiries")) - 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)")) - - class Meta: - constraints = [ - models.UniqueConstraint(fields=["user", "product"], name="notifications_user_product"), - ] - indexes = [ - models.Index(fields=["user", "product"]), - ] - - def __str__(self): - return f"Notifications about {self.product or 'all projects'} for {self.user or 'system notifications'}" - - @classmethod - def merge_notifications_list(cls, notifications_list): - if not notifications_list: - return [] - - result = None - for notifications in notifications_list: - if result is None: - # we start by copying the first instance, because creating a new instance would set all notification columns to 'alert' :-() - result = notifications - # result.pk = None # detach from db - else: - result.product_type_added = {*result.product_type_added, *notifications.product_type_added} - result.product_added = {*result.product_added, *notifications.product_added} - result.engagement_added = {*result.engagement_added, *notifications.engagement_added} - result.test_added = {*result.test_added, *notifications.test_added} - result.scan_added = {*result.scan_added, *notifications.scan_added} - result.jira_update = {*result.jira_update, *notifications.jira_update} - result.upcoming_engagement = {*result.upcoming_engagement, *notifications.upcoming_engagement} - result.stale_engagement = {*result.stale_engagement, *notifications.stale_engagement} - result.auto_close_engagement = {*result.auto_close_engagement, *notifications.auto_close_engagement} - result.close_engagement = {*result.close_engagement, *notifications.close_engagement} - result.user_mentioned = {*result.user_mentioned, *notifications.user_mentioned} - result.code_review = {*result.code_review, *notifications.code_review} - result.review_requested = {*result.review_requested, *notifications.review_requested} - result.other = {*result.other, *notifications.other} - result.sla_breach = {*result.sla_breach, *notifications.sla_breach} - result.sla_breach_combined = {*result.sla_breach_combined, *notifications.sla_breach_combined} - result.risk_acceptance_expiration = {*result.risk_acceptance_expiration, *notifications.risk_acceptance_expiration} - return result - - -class NotificationsAdmin(admin.ModelAdmin): - list_filter = ("user", "product") - - def get_list_display(self, request): - list_fields = ["user", "product"] - list_fields += [field.name for field in self.model._meta.fields if field.name not in list_fields] - return list_fields - - -class Notification_Webhooks(models.Model): - class Status(models.TextChoices): - __STATUS_ACTIVE = "active" - __STATUS_INACTIVE = "inactive" - STATUS_ACTIVE = f"{__STATUS_ACTIVE}", _("Active") - STATUS_ACTIVE_TMP = f"{__STATUS_ACTIVE}_tmp", _("Active but 5xx (or similar) error detected") - STATUS_INACTIVE_TMP = f"{__STATUS_INACTIVE}_tmp", _("Temporary inactive because of 5xx (or similar) error") - STATUS_INACTIVE_PERMANENT = f"{__STATUS_INACTIVE}_permanent", _("Permanently inactive") - - name = models.CharField(max_length=100, default="", blank=False, unique=True, - help_text=_("Name of the incoming webhook")) - url = models.URLField(max_length=200, default="", blank=False, - help_text=_("The full URL of the incoming webhook")) - header_name = models.CharField(max_length=100, default="", blank=True, null=True, - help_text=_("Name of the header required for interacting with Webhook endpoint")) - header_value = models.CharField(max_length=100, default="", blank=True, null=True, - help_text=_("Content of the header required for interacting with Webhook endpoint")) - status = models.CharField(max_length=20, choices=Status, default="active", blank=False, - help_text=_("Status of the incoming webhook"), editable=False) - first_error = models.DateTimeField(help_text=_("If endpoint is active, when error happened first time"), blank=True, null=True, editable=False) - last_error = models.DateTimeField(help_text=_("If endpoint is active, when error happened last time"), blank=True, null=True, editable=False) - note = models.CharField(max_length=1000, default="", blank=True, null=True, help_text=_("Description of the latest error"), editable=False) - owner = models.ForeignKey(Dojo_User, editable=True, null=True, blank=True, on_delete=models.CASCADE, - help_text=_("Owner/receiver of notification, if empty processed as system notification")) - # TODO: Test that `editable` will block editing via API - class Tool_Product_Settings(models.Model): name = models.CharField(max_length=200, null=False) @@ -4280,19 +4167,6 @@ class Tool_Product_History(models.Model): blank=True) -class Alerts(models.Model): - title = models.CharField(max_length=250, default="", null=False) - description = models.CharField(max_length=2000, null=True, blank=True) - url = models.URLField(max_length=2000, null=True, blank=True) - source = models.CharField(max_length=100, default="Generic") - icon = models.CharField(max_length=25, default="icon-user-check") - user_id = models.ForeignKey(Dojo_User, null=True, editable=False, on_delete=models.CASCADE) - created = models.DateTimeField(auto_now_add=True, null=False) - - class Meta: - ordering = ["-created"] - - class Cred_User(models.Model): name = models.CharField(max_length=200, null=False) username = models.CharField(max_length=200, null=False) @@ -4775,14 +4649,12 @@ def __str__(self): admin.site.register(UserContactInfo) admin.site.register(Notes) admin.site.register(Note_Type) -admin.site.register(Alerts) admin.site.register(GITHUB_Conf) admin.site.register(GITHUB_Issue) admin.site.register(GITHUB_Clone) admin.site.register(GITHUB_Details_Cache) admin.site.register(GITHUB_PKey) admin.site.register(Tool_Configuration, Tool_Configuration_Admin) -admin.site.register(Notification_Webhooks) admin.site.register(Tool_Product_Settings) admin.site.register(Tool_Type) admin.site.register(Cred_User) @@ -4818,7 +4690,6 @@ def __str__(self): admin.site.register(Announcement) admin.site.register(UserAnnouncement) admin.site.register(BannerConf) -admin.site.register(Notifications, NotificationsAdmin) admin.site.register(Tool_Product_History) admin.site.register(General_Survey) admin.site.register(Test_Import) diff --git a/dojo/notifications/admin.py b/dojo/notifications/admin.py new file mode 100644 index 00000000000..3747f1a9395 --- /dev/null +++ b/dojo/notifications/admin.py @@ -0,0 +1,23 @@ +from django.contrib import admin + +from dojo.notifications.models import Alerts, Notification_Webhooks, Notifications + + +@admin.register(Notifications) +class NotificationsAdmin(admin.ModelAdmin): + list_filter = ("user", "product") + + def get_list_display(self, request): + list_fields = ["user", "product"] + list_fields += [f.name for f in self.model._meta.fields if f.name not in list_fields] + return list_fields + + +@admin.register(Notification_Webhooks) +class NotificationWebhooksAdmin(admin.ModelAdmin): + pass + + +@admin.register(Alerts) +class AlertsAdmin(admin.ModelAdmin): + pass diff --git a/dojo/notifications/api/__init__.py b/dojo/notifications/api/__init__.py new file mode 100644 index 00000000000..1c7c58d9437 --- /dev/null +++ b/dojo/notifications/api/__init__.py @@ -0,0 +1 @@ +path = "notifications" # noqa: RUF067 -- API URL prefix consumed by add_notifications_urls diff --git a/dojo/notifications/api/serializer.py b/dojo/notifications/api/serializer.py new file mode 100644 index 00000000000..ba489dc053e --- /dev/null +++ b/dojo/notifications/api/serializer.py @@ -0,0 +1,123 @@ +from django.core.exceptions import ValidationError +from rest_framework import serializers +from rest_framework.fields import MultipleChoiceField + +from dojo.models import Dojo_User, Product +from dojo.notifications.models import ( + DEFAULT_NOTIFICATION, + NOTIFICATION_CHOICES, + Notification_Webhooks, + Notifications, +) + + +class NotificationsSerializer(serializers.ModelSerializer): + product = serializers.PrimaryKeyRelatedField( + queryset=Product.objects.all(), + required=False, + default=None, + allow_null=True, + ) + user = serializers.PrimaryKeyRelatedField( + queryset=Dojo_User.objects.all(), + required=False, + default=None, + allow_null=True, + ) + product_type_added = MultipleChoiceField( + choices=NOTIFICATION_CHOICES, default=DEFAULT_NOTIFICATION, + ) + product_added = MultipleChoiceField( + choices=NOTIFICATION_CHOICES, default=DEFAULT_NOTIFICATION, + ) + engagement_added = MultipleChoiceField( + choices=NOTIFICATION_CHOICES, default=DEFAULT_NOTIFICATION, + ) + test_added = MultipleChoiceField( + choices=NOTIFICATION_CHOICES, default=DEFAULT_NOTIFICATION, + ) + scan_added = MultipleChoiceField( + choices=NOTIFICATION_CHOICES, default=DEFAULT_NOTIFICATION, + ) + jira_update = MultipleChoiceField( + choices=NOTIFICATION_CHOICES, default=DEFAULT_NOTIFICATION, + ) + upcoming_engagement = MultipleChoiceField( + choices=NOTIFICATION_CHOICES, default=DEFAULT_NOTIFICATION, + ) + stale_engagement = MultipleChoiceField( + choices=NOTIFICATION_CHOICES, default=DEFAULT_NOTIFICATION, + ) + auto_close_engagement = MultipleChoiceField( + choices=NOTIFICATION_CHOICES, default=DEFAULT_NOTIFICATION, + ) + close_engagement = MultipleChoiceField( + choices=NOTIFICATION_CHOICES, default=DEFAULT_NOTIFICATION, + ) + user_mentioned = MultipleChoiceField( + choices=NOTIFICATION_CHOICES, default=DEFAULT_NOTIFICATION, + ) + code_review = MultipleChoiceField( + choices=NOTIFICATION_CHOICES, default=DEFAULT_NOTIFICATION, + ) + review_requested = MultipleChoiceField( + choices=NOTIFICATION_CHOICES, default=DEFAULT_NOTIFICATION, + ) + other = MultipleChoiceField( + choices=NOTIFICATION_CHOICES, default=DEFAULT_NOTIFICATION, + ) + 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, + ) + template = serializers.BooleanField(default=False) + + class Meta: + model = Notifications + fields = "__all__" + + def validate(self, data): + user = None + product = None + template = False + + if self.instance is not None: + user = self.instance.user + product = self.instance.product + + if "user" in data: + user = data.get("user") + if "product" in data: + product = data.get("product") + if "template" in data: + template = data.get("template") + + if ( + template + and Notifications.objects.filter(template=True).count() > 0 + ): + msg = "Notification template already exists" + raise ValidationError(msg) + if ( + self.instance is None + or user != self.instance.user + or product != self.instance.product + ): + notifications = Notifications.objects.filter( + user=user, product=product, template=template, + ).count() + if notifications > 0: + msg = "Notification for user and product already exists" + raise ValidationError(msg) + return data + + +class NotificationWebhooksSerializer(serializers.ModelSerializer): + class Meta: + model = Notification_Webhooks + fields = "__all__" diff --git a/dojo/notifications/api/urls.py b/dojo/notifications/api/urls.py new file mode 100644 index 00000000000..4c7df9072d4 --- /dev/null +++ b/dojo/notifications/api/urls.py @@ -0,0 +1,11 @@ +from dojo.notifications.api import path +from dojo.notifications.api.views import ( + NotificationsViewSet, + NotificationWebhooksViewSet, +) + + +def add_notifications_urls(router): + router.register(rf"{path}", NotificationsViewSet, basename=path) + router.register(r"notification_webhooks", NotificationWebhooksViewSet) + return router diff --git a/dojo/notifications/api/views.py b/dojo/notifications/api/views.py new file mode 100644 index 00000000000..a009c8562df --- /dev/null +++ b/dojo/notifications/api/views.py @@ -0,0 +1,36 @@ +from django_filters.rest_framework import DjangoFilterBackend +from drf_spectacular.utils import extend_schema_view +from rest_framework.permissions import DjangoModelPermissions + +from dojo.api_v2 import permissions +from dojo.api_v2.views import PrefetchDojoModelViewSet, schema_with_prefetch +from dojo.notifications.api.serializer import ( + NotificationsSerializer, + NotificationWebhooksSerializer, +) +from dojo.notifications.models import Notification_Webhooks, Notifications + + +# Authorization: superuser +@extend_schema_view(**schema_with_prefetch()) +class NotificationsViewSet( + PrefetchDojoModelViewSet, +): + serializer_class = NotificationsSerializer + queryset = Notifications.objects.none() + filter_backends = (DjangoFilterBackend,) + filterset_fields = ["id", "user", "product", "template"] + permission_classes = (permissions.IsSuperUser, DjangoModelPermissions) + + def get_queryset(self): + return Notifications.objects.all().order_by("id") + + +class NotificationWebhooksViewSet( + PrefetchDojoModelViewSet, +): + serializer_class = NotificationWebhooksSerializer + queryset = Notification_Webhooks.objects.all() + filter_backends = (DjangoFilterBackend,) + filterset_fields = "__all__" + permission_classes = (permissions.IsSuperUser, DjangoModelPermissions) # TODO: add permission also for other users diff --git a/dojo/notifications/context_processors.py b/dojo/notifications/context_processors.py new file mode 100644 index 00000000000..cd7b78994d8 --- /dev/null +++ b/dojo/notifications/context_processors.py @@ -0,0 +1,30 @@ +import time + +from django.conf import settings + +from dojo.notifications.models import Alerts + + +def bind_alert_count(request): + if not settings.DISABLE_ALERT_COUNTER: + + if hasattr(request, "user") and request.user.is_authenticated: + return {"alert_count": Alerts.objects.filter(user_id=request.user).count()} + return {} + + +def session_expiry_notification(request): + try: + if request.user.is_authenticated: + last_activity = request.session.get("_last_activity", time.time()) + expiry_time = last_activity + settings.SESSION_COOKIE_AGE # When the session will expire + warning_time = settings.SESSION_EXPIRE_WARNING # Show warning X seconds before expiry + notify_time = expiry_time - warning_time + else: + notify_time = None + except Exception: + return {} + else: + return { + "session_notify_time": notify_time, + } diff --git a/dojo/notifications/helper.py b/dojo/notifications/helper.py index c7161b81227..a8c3db6f611 100644 --- a/dojo/notifications/helper.py +++ b/dojo/notifications/helper.py @@ -1,9 +1,10 @@ import importlib import json import logging +import re from contextlib import suppress -from datetime import timedelta +import crum import requests import yaml from django.conf import settings @@ -12,22 +13,18 @@ from django.db.models import Count, Prefetch, Q, QuerySet from django.template import TemplateDoesNotExist from django.template.loader import render_to_string -from django.urls import reverse +from django.urls import get_script_prefix, reverse from django.utils.translation import gettext as _ from dojo import __version__ as dd_version from dojo.authorization.roles_permissions import Permissions -from dojo.celery import app from dojo.celery_dispatch import dojo_dispatch_task from dojo.decorators import we_want_async from dojo.labels import get_labels from dojo.models import ( - Alerts, Dojo_User, Engagement, Finding, - Notification_Webhooks, - Notifications, Product, Product_Type, System_Settings, @@ -35,6 +32,7 @@ UserContactInfo, get_current_datetime, ) +from dojo.notifications.models import Alerts, Notification_Webhooks, Notifications from dojo.user.queries import ( get_authorized_users_for_product_and_product_type, get_authorized_users_for_product_type, @@ -814,6 +812,9 @@ def _process_notifications( logger.warning("no notifications!") return + # Lazy import to avoid circular import: dojo.notifications.tasks imports the + # Manager classes defined in this module. + logger.debug( "sending notification " + ("asynchronously" if we_want_async() else "synchronously"), ) @@ -887,121 +888,254 @@ def _process_notifications( ) -@app.task -def send_slack_notification(event: str, user_id: int | None = None, **kwargs: dict) -> None: - user = Dojo_User.objects.get(pk=user_id) if user_id else None - get_manager_class_instance()._get_manager_instance("slack").send_slack_notification(event, user=user, **kwargs) - - -@app.task -def send_msteams_notification(event: str, user_id: int | None = None, **kwargs: dict) -> None: - user = Dojo_User.objects.get(pk=user_id) if user_id else None - get_manager_class_instance()._get_manager_instance("msteams").send_msteams_notification(event, user=user, **kwargs) - - -@app.task -def send_mail_notification(event: str, user_id: int | None = None, **kwargs: dict) -> None: - user = Dojo_User.objects.get(pk=user_id) if user_id else None - get_manager_class_instance()._get_manager_instance("mail").send_mail_notification(event, user=user, **kwargs) - - -@app.task -def send_webhooks_notification(event: str, user_id: int | None = None, **kwargs: dict) -> None: - user = Dojo_User.objects.get(pk=user_id) if user_id else None - get_manager_class_instance()._get_manager_instance("webhooks").send_webhooks_notification(event, user=user, **kwargs) - - -@app.task -def async_create_notification( - event: str, - engagement_id: int | None = None, - product_id: int | None = None, - product_type_id: int | None = None, - finding_id: int | None = None, - test_id: int | None = None, - **kwargs: dict, -) -> None: - # Re-fetch by id so the recipient-enumeration query and per-user Alert writes - # run in the worker rather than the request thread. - # Fetch most-specific first and derive parent objects from the already-loaded - # select_related chain to avoid redundant queries. For example, fetching a - # Test with select_related("engagement__product") covers all three objects in - # one query, so engagement_id and product_id don't need separate fetches. - fetched_engagement = None - fetched_product = None - - if test_id is not None: - test = Test.objects.filter(pk=test_id).select_related("engagement__product").first() - if test is None: +def process_tag_notifications(request, note, parent_url, parent_title): + regex = re.compile(r"(?:\A|\s)@(\w+)\b") + + usernames_to_check = set(un.lower() for un in regex.findall(note.entry)) # noqa: C401 + + users_to_notify = [ + Dojo_User.objects.filter(username=username).get() + for username in usernames_to_check + if Dojo_User.objects.filter(is_active=True, username=username).exists() + ] + + if len(note.entry) > 200: + note.entry = note.entry[:200] + note.entry += "..." + + create_notification( + event="user_mentioned", + section=parent_title, + note=note, + title=f"{request.user} jotted a note", + url=parent_url, + icon="commenting", + recipients=users_to_notify, + requested_by=crum.get_current_user()) + + +def sla_compute_and_notify(*args, **kwargs): + """ + The SLA computation and notification will be disabled if the user opts out + of the Findings SLA on the System Settings page. + + Notifications are managed the usual way, so you'd have to opt-in. + Exception is for JIRA issues, which would get a comment anyways. + """ + from dojo.jira import services as jira_services # noqa: PLC0415 circular import + + 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 - kwargs["test"] = test - fetched_engagement = test.engagement - fetched_product = test.engagement.product - - if engagement_id is not None: - if fetched_engagement is not None: - kwargs["engagement"] = fetched_engagement - else: - engagement = Engagement.objects.filter(pk=engagement_id).select_related("product").first() - if engagement is None: - return - kwargs["engagement"] = engagement - fetched_product = engagement.product - - if product_id is not None: - if fetched_product is not None: - kwargs["product"] = fetched_product - else: - product = Product.objects.filter(pk=product_id).first() - if product is None: - return - kwargs["product"] = product - if product_type_id is not None: - product_type = Product_Type.objects.filter(pk=product_type_id).first() - if product_type is None: - return - kwargs["product_type"] = product_type + notification = NotificationEntry(finding=finding, + jira_issue=jira_issue, + do_jira_sla_comment=do_jira_sla_comment) - if finding_id is not None: - finding = Finding.objects.filter(pk=finding_id).select_related("test__engagement__product").first() - if finding is None: - return - kwargs["finding"] = finding + pt = finding.test.engagement.product.prod_type.name + p = finding.test.engagement.product.name - create_notification(event=event, **kwargs) + 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 = f"Finding {finding.id} - " + if kind == "breached": + abs_sla_age = abs(sla_age) + period = "day" + if abs_sla_age > 1: + period = "days" + title += f"SLA breached by {abs_sla_age} {period}! Overdue notice" + elif kind == "prebreach": + title += f"SLA pre-breach warning - {sla_age} day(s) left" + elif kind == "breaching": + title += "SLA is breaching today" + + return title + + def _create_notifications(): + for prodtype, comb_notif_prodtype in combined_notifications.items(): + for prod, comb_notif_prod in comb_notif_prodtype.items(): + for kind, comb_notif_kind in comb_notif_prod.items(): + # 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 comb_notif_kind: + sla_age = n.finding.sla_days_remaining() + title = _notification_title_for_finding(n.finding, kind, sla_age) + create_notification( + event="sla_breach", + title=title, + finding=n.finding, + sla_age=sla_age, + 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_services.add_simple_comment(jira_instance, n.jira_issue, title) + + findings_list.append(n.finding) + + # producing a "combined" SLA breach notification + title_combined = f"SLA alert ({kind}): " + labels.ORG_WITH_NAME_LABEL % {"name": prodtype} + ", " + labels.ASSET_WITH_NAME_LABEL % {"name": prod} + product = comb_notif_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(), + ) -@app.task(ignore_result=True) -def webhook_reactivation(endpoint_id: int, **_kwargs: dict) -> None: - get_manager_class_instance()._get_manager_instance("webhooks")._webhook_reactivation(endpoint_id=endpoint_id) + # exit early on flags + system_settings = System_Settings.objects.get() + if not system_settings.enable_notify_sla_active and not system_settings.enable_notify_sla_active_verified: + logger.info("Will not notify on SLA breach per user configured settings") + return + + 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.") + logger.debug(f"Active {system_settings.enable_notify_sla_active}, Verified {system_settings.enable_notify_sla_active_verified}, Has JIRA {system_settings.enable_notify_sla_jira_only}, pre-breach {settings.SLA_NOTIFY_PRE_BREACH}, post-breach {settings.SLA_NOTIFY_POST_BREACH}") + + query = None + if system_settings.enable_notify_sla_active_verified: + query = Q(active=True, verified=True, is_mitigated=False, duplicate=False) + elif system_settings.enable_notify_sla_active: + query = Q(active=True, is_mitigated=False, duplicate=False) + logger.debug("My query: %s", query) + + no_jira_findings = {} + if system_settings.enable_notify_sla_jira_only: + logger.debug("Ignoring findings that are not linked to a JIRA issue") + no_jira_findings = Finding.objects.exclude(jira_issue__isnull=False) + + total_count = 0 + pre_breach_count = 0 + post_breach_count = 0 + post_breach_no_notify_count = 0 + jira_count = 0 + at_breach_count = 0 + + # Taking away for now, since the prefetch is not efficient + # .select_related('jira_issue') \ + # .prefetch_related(Prefetch('test__engagement__product__jira_project_set__jira_instance')) \ + # A finding with 'Info' severity will not be considered for SLA notifications (not in model) + findings = Finding.objects \ + .filter(query) \ + .exclude(severity="Info") \ + .exclude(id__in=no_jira_findings) + + for finding in findings: + total_count += 1 + sla_age = finding.sla_days_remaining() + + # get the sla enforcement for the severity and, if the severity setting is not enforced, do not notify + # resolves an issue where notifications are always sent for the severity of SLA that is not enforced + severity, enforce = finding.get_sla_period() + if not enforce: + logger.debug(f"SLA is not enforced for Finding {finding.id} of {severity} severity, skipping notification.") + continue + # if SLA is set to 0 in settings, it's a null. And setting at 0 means no SLA apparently. + if sla_age is None: + sla_age = 0 -@app.task(ignore_result=True) -def webhook_status_cleanup(*_args: list, **_kwargs: dict): - # If some endpoint was affected by some outage (5xx, 429, Timeout) but it was clean during last 24 hours, - # we consider this endpoint as healthy so need to reset it - endpoints = Notification_Webhooks.objects.filter( - status=Notification_Webhooks.Status.STATUS_ACTIVE_TMP, - last_error__lt=get_current_datetime() - timedelta(hours=24), - ) - for endpoint in endpoints: - endpoint.status = Notification_Webhooks.Status.STATUS_ACTIVE - endpoint.first_error = None - endpoint.last_error = None - endpoint.note = f"Reactivation from {Notification_Webhooks.Status.STATUS_ACTIVE_TMP}" - endpoint.save() - logger.debug( - f"Webhook endpoint '{endpoint.name}' reactivated from '{Notification_Webhooks.Status.STATUS_ACTIVE_TMP}' to '{Notification_Webhooks.Status.STATUS_ACTIVE}'", - ) + if (sla_age < 0) and (abs(sla_age) > settings.SLA_NOTIFY_POST_BREACH): + post_breach_no_notify_count += 1 + # Skip finding notification if breached for too long + logger.debug(f"Finding {finding.id} breached the SLA {abs(sla_age)} days ago. Skipping notifications.") + continue - # Reactivation of STATUS_INACTIVE_TMP endpoints. - # They should reactive automatically in 60s, however in case of some unexpected event (e.g. start of whole stack), - # endpoints should not be left in STATUS_INACTIVE_TMP state - broken_endpoints = Notification_Webhooks.objects.filter( - status=Notification_Webhooks.Status.STATUS_INACTIVE_TMP, - last_error__lt=get_current_datetime() - timedelta(minutes=5), - ) - for endpoint in broken_endpoints: - manager = WebhookNotificationManger() - manager._webhook_reactivation(endpoint_id=endpoint.pk) + do_jira_sla_comment = False + jira_issue = None + if finding.has_jira_issue: + jira_issue = finding.jira_issue + elif finding.has_jira_group_issue: + jira_issue = finding.finding_group.jira_issue + + if jira_issue: + jira_count += 1 + jira_instance = jira_services.get_instance(finding) + if jira_instance is not None: + logger.debug("JIRA config for finding is %s", jira_instance) + # global config or product config set, product level takes precedence + try: + # TODO: see new property from #2649 to then replace, somehow not working with prefetching though. + product_jira_sla_comment_enabled = jira_services.get_project(finding).product_jira_sla_notification + except Exception as e: + logger.error("The product is not linked to a JIRA configuration! Something is weird here.") + logger.error("Error is: %s", e) + + jiraconfig_sla_notification_enabled = jira_instance.global_jira_sla_notification + + if jiraconfig_sla_notification_enabled or product_jira_sla_comment_enabled: + logger.debug("Global setting %s -- Product setting %s", jiraconfig_sla_notification_enabled, product_jira_sla_comment_enabled) + do_jira_sla_comment = True + logger.debug(f"JIRA issue is {jira_issue.jira_key}") + + logger.debug(f"Finding {finding.id} has {sla_age} days left to breach SLA.") + if (sla_age < 0): + post_breach_count += 1 + logger.info(f"Finding {finding.id} has breached by {abs(sla_age)} days.") + 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): + _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(f"Security SLA pre-breach warning for finding ID {finding.id}. Days remaining: {sla_age}") + _add_notification(finding, "prebreach") + # The finding breaches the SLA today + elif (sla_age == 0): + at_breach_count += 1 + logger.info(f"Security SLA breach warning. Finding ID {finding.id} breaching today ({sla_age})") + _add_notification(finding, "breaching") + + _create_notifications() + logger.info("SLA run results: Pre-breach: %s, at-breach: %s, post-breach: %s, post-breach-no-notify: %s, with-jira: %s, TOTAL: %s", pre_breach_count, at_breach_count, post_breach_count, post_breach_no_notify_count, jira_count, total_count) + + except System_Settings.DoesNotExist: + logger.info("Findings SLA is not enabled.") + + +# Backward-compat re-exports: tasks moved to dojo.notifications.tasks. Placed at +# end-of-file so the Manager classes above are fully defined before +# dojo.notifications.tasks (which imports them) is loaded. +from dojo.notifications.tasks import ( # noqa: E402, F401 -- backward compat + async_create_notification, + send_mail_notification, + send_msteams_notification, + send_slack_notification, + send_webhooks_notification, + webhook_reactivation, + webhook_status_cleanup, +) diff --git a/dojo/notifications/models.py b/dojo/notifications/models.py new file mode 100644 index 00000000000..c1e42a2bbd6 --- /dev/null +++ b/dojo/notifications/models.py @@ -0,0 +1,132 @@ +from django.db import models +from django.utils.translation import gettext_lazy as _ +from multiselectfield import MultiSelectField + +NOTIFICATION_CHOICE_SLACK = ("slack", "slack") +NOTIFICATION_CHOICE_MSTEAMS = ("msteams", "msteams") +NOTIFICATION_CHOICE_MAIL = ("mail", "mail") +NOTIFICATION_CHOICE_WEBHOOKS = ("webhooks", "webhooks") +NOTIFICATION_CHOICE_ALERT = ("alert", "alert") + +NOTIFICATION_CHOICES = ( + NOTIFICATION_CHOICE_SLACK, + NOTIFICATION_CHOICE_MSTEAMS, + NOTIFICATION_CHOICE_MAIL, + NOTIFICATION_CHOICE_WEBHOOKS, + NOTIFICATION_CHOICE_ALERT, +) + +DEFAULT_NOTIFICATION = NOTIFICATION_CHOICE_ALERT + + +class Notifications(models.Model): + product_type_added = MultiSelectField(choices=NOTIFICATION_CHOICES, default=DEFAULT_NOTIFICATION, blank=True) + product_added = MultiSelectField(choices=NOTIFICATION_CHOICES, default=DEFAULT_NOTIFICATION, blank=True) + engagement_added = MultiSelectField(choices=NOTIFICATION_CHOICES, default=DEFAULT_NOTIFICATION, blank=True) + test_added = MultiSelectField(choices=NOTIFICATION_CHOICES, default=DEFAULT_NOTIFICATION, blank=True) + + scan_added = MultiSelectField(choices=NOTIFICATION_CHOICES, default=DEFAULT_NOTIFICATION, blank=True, help_text=_("Triggered whenever an (re-)import has been done that created/updated/closed findings.")) + scan_added_empty = MultiSelectField(choices=NOTIFICATION_CHOICES, default=[], blank=True, help_text=_("Triggered whenever an (re-)import has been done (even if that created/updated/closed no findings).")) + jira_update = MultiSelectField(choices=NOTIFICATION_CHOICES, default=DEFAULT_NOTIFICATION, blank=True, verbose_name=_("JIRA problems"), help_text=_("JIRA sync happens in the background, errors will be shown as notifications/alerts so make sure to subscribe")) + upcoming_engagement = MultiSelectField(choices=NOTIFICATION_CHOICES, default=DEFAULT_NOTIFICATION, blank=True) + stale_engagement = MultiSelectField(choices=NOTIFICATION_CHOICES, default=DEFAULT_NOTIFICATION, blank=True) + auto_close_engagement = MultiSelectField(choices=NOTIFICATION_CHOICES, default=DEFAULT_NOTIFICATION, blank=True) + close_engagement = MultiSelectField(choices=NOTIFICATION_CHOICES, default=DEFAULT_NOTIFICATION, blank=True) + user_mentioned = MultiSelectField(choices=NOTIFICATION_CHOICES, default=DEFAULT_NOTIFICATION, blank=True) + code_review = MultiSelectField(choices=NOTIFICATION_CHOICES, default=DEFAULT_NOTIFICATION, blank=True) + review_requested = MultiSelectField(choices=NOTIFICATION_CHOICES, default=DEFAULT_NOTIFICATION, blank=True) + other = MultiSelectField(choices=NOTIFICATION_CHOICES, default=DEFAULT_NOTIFICATION, blank=True) + user = models.ForeignKey("dojo.Dojo_User", default=None, null=True, editable=False, on_delete=models.CASCADE) + product = models.ForeignKey("dojo.Product", default=None, null=True, editable=False, on_delete=models.CASCADE) + template = models.BooleanField(default=False) + sla_breach = MultiSelectField(choices=NOTIFICATION_CHOICES, default=DEFAULT_NOTIFICATION, blank=True, + verbose_name=_("SLA breach"), + help_text=_("Get notified of (upcoming) SLA breaches")) + risk_acceptance_expiration = MultiSelectField(choices=NOTIFICATION_CHOICES, default=DEFAULT_NOTIFICATION, blank=True, + verbose_name=_("Risk Acceptance Expiration"), + help_text=_("Get notified of (upcoming) Risk Acceptance expiries")) + 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)")) + + class Meta: + constraints = [ + models.UniqueConstraint(fields=["user", "product"], name="notifications_user_product"), + ] + indexes = [ + models.Index(fields=["user", "product"]), + ] + + def __str__(self): + return f"Notifications about {self.product or 'all projects'} for {self.user or 'system notifications'}" + + @classmethod + def merge_notifications_list(cls, notifications_list): + if not notifications_list: + return [] + + result = None + for notifications in notifications_list: + if result is None: + # we start by copying the first instance, because creating a new instance would set all notification columns to 'alert' :-() + result = notifications + # result.pk = None # detach from db + else: + result.product_type_added = {*result.product_type_added, *notifications.product_type_added} + result.product_added = {*result.product_added, *notifications.product_added} + result.engagement_added = {*result.engagement_added, *notifications.engagement_added} + result.test_added = {*result.test_added, *notifications.test_added} + result.scan_added = {*result.scan_added, *notifications.scan_added} + result.jira_update = {*result.jira_update, *notifications.jira_update} + result.upcoming_engagement = {*result.upcoming_engagement, *notifications.upcoming_engagement} + result.stale_engagement = {*result.stale_engagement, *notifications.stale_engagement} + result.auto_close_engagement = {*result.auto_close_engagement, *notifications.auto_close_engagement} + result.close_engagement = {*result.close_engagement, *notifications.close_engagement} + result.user_mentioned = {*result.user_mentioned, *notifications.user_mentioned} + result.code_review = {*result.code_review, *notifications.code_review} + result.review_requested = {*result.review_requested, *notifications.review_requested} + result.other = {*result.other, *notifications.other} + result.sla_breach = {*result.sla_breach, *notifications.sla_breach} + result.sla_breach_combined = {*result.sla_breach_combined, *notifications.sla_breach_combined} + result.risk_acceptance_expiration = {*result.risk_acceptance_expiration, *notifications.risk_acceptance_expiration} + return result + + +class Notification_Webhooks(models.Model): + class Status(models.TextChoices): + __STATUS_ACTIVE = "active" + __STATUS_INACTIVE = "inactive" + STATUS_ACTIVE = f"{__STATUS_ACTIVE}", _("Active") + STATUS_ACTIVE_TMP = f"{__STATUS_ACTIVE}_tmp", _("Active but 5xx (or similar) error detected") + STATUS_INACTIVE_TMP = f"{__STATUS_INACTIVE}_tmp", _("Temporary inactive because of 5xx (or similar) error") + STATUS_INACTIVE_PERMANENT = f"{__STATUS_INACTIVE}_permanent", _("Permanently inactive") + + name = models.CharField(max_length=100, default="", blank=False, unique=True, + help_text=_("Name of the incoming webhook")) + url = models.URLField(max_length=200, default="", blank=False, + help_text=_("The full URL of the incoming webhook")) + header_name = models.CharField(max_length=100, default="", blank=True, null=True, + help_text=_("Name of the header required for interacting with Webhook endpoint")) + header_value = models.CharField(max_length=100, default="", blank=True, null=True, + help_text=_("Content of the header required for interacting with Webhook endpoint")) + status = models.CharField(max_length=20, choices=Status, default="active", blank=False, + help_text=_("Status of the incoming webhook"), editable=False) + first_error = models.DateTimeField(help_text=_("If endpoint is active, when error happened first time"), blank=True, null=True, editable=False) + last_error = models.DateTimeField(help_text=_("If endpoint is active, when error happened last time"), blank=True, null=True, editable=False) + note = models.CharField(max_length=1000, default="", blank=True, null=True, help_text=_("Description of the latest error"), editable=False) + owner = models.ForeignKey("dojo.Dojo_User", editable=True, null=True, blank=True, on_delete=models.CASCADE, + help_text=_("Owner/receiver of notification, if empty processed as system notification")) + # TODO: Test that `editable` will block editing via API + + +class Alerts(models.Model): + title = models.CharField(max_length=250, default="", null=False) + description = models.CharField(max_length=2000, null=True, blank=True) + url = models.URLField(max_length=2000, null=True, blank=True) + source = models.CharField(max_length=100, default="Generic") + icon = models.CharField(max_length=25, default="icon-user-check") + user_id = models.ForeignKey("dojo.Dojo_User", null=True, editable=False, on_delete=models.CASCADE) + created = models.DateTimeField(auto_now_add=True, null=False) + + class Meta: + ordering = ["-created"] diff --git a/dojo/notifications/settings.py b/dojo/notifications/settings.py new file mode 100644 index 00000000000..4e634d84b4a --- /dev/null +++ b/dojo/notifications/settings.py @@ -0,0 +1,28 @@ +NOTIFICATIONS_ENV_DEFAULTS: dict[str, tuple] = { + "DD_SLA_NOTIFY_ACTIVE": (bool, False), + "DD_SLA_NOTIFY_ACTIVE_VERIFIED_ONLY": (bool, False), + "DD_SLA_NOTIFY_WITH_JIRA_ONLY": (bool, False), + "DD_SLA_NOTIFY_PRE_BREACH": (int, 3), + "DD_SLA_NOTIFY_POST_BREACH": (int, 7), + "DD_NOTIFICATIONS_SYSTEM_LEVEL_TRUMP": (list, ["user_mentioned", "review_requested"]), + "DD_ALERT_REFRESH": (bool, True), + "DD_DISABLE_ALERT_COUNTER": (bool, False), + "DD_MAX_ALERTS_PER_USER": (int, 999), +} + +_ENV_TO_SETTING = { + "DD_SLA_NOTIFY_ACTIVE": "SLA_NOTIFY_ACTIVE", + "DD_SLA_NOTIFY_ACTIVE_VERIFIED_ONLY": "SLA_NOTIFY_ACTIVE_VERIFIED_ONLY", + "DD_SLA_NOTIFY_WITH_JIRA_ONLY": "SLA_NOTIFY_WITH_JIRA_ONLY", + "DD_SLA_NOTIFY_PRE_BREACH": "SLA_NOTIFY_PRE_BREACH", + "DD_SLA_NOTIFY_POST_BREACH": "SLA_NOTIFY_POST_BREACH", + "DD_NOTIFICATIONS_SYSTEM_LEVEL_TRUMP": "NOTIFICATIONS_SYSTEM_LEVEL_TRUMP", + "DD_ALERT_REFRESH": "ALERT_REFRESH", + "DD_DISABLE_ALERT_COUNTER": "DISABLE_ALERT_COUNTER", + "DD_MAX_ALERTS_PER_USER": "MAX_ALERTS_PER_USER", +} + + +def populate_settings(env, target: dict) -> None: + for env_var, setting_name in _ENV_TO_SETTING.items(): + target[setting_name] = env(env_var) diff --git a/dojo/notifications/signals.py b/dojo/notifications/signals.py new file mode 100644 index 00000000000..b4b54b66f16 --- /dev/null +++ b/dojo/notifications/signals.py @@ -0,0 +1,34 @@ +import logging + +from django.contrib.auth.models import User +from django.db.models.signals import post_save +from django.dispatch import receiver + +from dojo.models import Dojo_User +from dojo.notifications.models import Notifications + +logger = logging.getLogger(__name__) + + +@receiver(post_save, sender=User) +@receiver(post_save, sender=Dojo_User) +def create_default_notifications(sender, instance, created, **kwargs): + """ + Create a default Notifications row for newly-created users. + + Cloned from the template row when present so admins can pre-configure + a system-wide default. Runs for users created via any auth backend + (LDAP, OAuth, SAML, etc.) which is why it lives as a signal. + """ + if not created: + return + try: + notifications = Notifications.objects.get(template=True) + notifications.pk = None + notifications.template = False + notifications.user = instance + logger.info("creating default set (from template) of notifications for: " + str(instance)) + except Notifications.DoesNotExist: + notifications = Notifications(user=instance) + logger.info("creating default set of notifications for: " + str(instance)) + notifications.save() diff --git a/dojo/notifications/tasks.py b/dojo/notifications/tasks.py new file mode 100644 index 00000000000..afdb1d1d92d --- /dev/null +++ b/dojo/notifications/tasks.py @@ -0,0 +1,239 @@ +import logging +from datetime import timedelta + +from celery.utils.log import get_task_logger +from django.conf import settings +from django.urls import reverse +from django.utils import timezone + +from dojo.celery import app +from dojo.celery_dispatch import dojo_dispatch_task +from dojo.models import ( + Dojo_User, + Engagement, + Finding, + Product, + Product_Type, + System_Settings, + Test, + User, + get_current_datetime, +) +from dojo.notifications.helper import ( + WebhookNotificationManger, + create_notification, + get_manager_class_instance, + sla_compute_and_notify, +) +from dojo.notifications.models import Alerts, Notification_Webhooks + +logger = get_task_logger(__name__) +deduplicationLogger = logging.getLogger("dojo.specific-loggers.deduplication") + + +# Logs the error to the alerts table, which appears in the notification toolbar +def log_generic_alert(source, title, description): + create_notification(event="other", title=title, description=description, + icon="bullseye", source=source) + + +@app.task(bind=True) +def add_alerts(self, runinterval, *args, **kwargs): + now = timezone.now() + + upcoming_engagements = Engagement.objects.filter(target_start__gt=now + timedelta(days=3), target_start__lt=now + timedelta(days=3) + runinterval).order_by("target_start") + for engagement in upcoming_engagements: + create_notification(event="upcoming_engagement", + title=f"Upcoming engagement: {engagement.name}", + engagement=engagement, + recipients=[engagement.lead], + url=reverse("view_engagement", args=(engagement.id,))) + + stale_engagements = Engagement.objects.filter( + target_start__gt=now - runinterval, + target_end__lt=now, + status="In Progress").order_by("-target_end") + for eng in stale_engagements: + create_notification(event="stale_engagement", + title=f"Stale Engagement: {eng.name}", + description='The engagement "{}" is stale. Target end was {}.'.format(eng.name, eng.target_end.strftime("%b. %d, %Y")), + url=reverse("view_engagement", args=(eng.id,)), + recipients=[eng.lead]) + + system_settings = System_Settings.objects.get() + if system_settings.engagement_auto_close: + # Close Engagements older than user defined days + close_days = system_settings.engagement_auto_close_days + unclosed_engagements = Engagement.objects.filter(target_end__lte=now - timedelta(days=close_days), + status="In Progress").order_by("target_end") + + for eng in unclosed_engagements: + create_notification(event="auto_close_engagement", + title=eng.name, + description='The engagement "{}" has auto-closed. Target end was {}.'.format(eng.name, eng.target_end.strftime("%b. %d, %Y")), + url=reverse("view_engagement", args=(eng.id,)), + recipients=[eng.lead]) + + unclosed_engagements.update(status="Completed", active=False, updated=timezone.now()) + + # Calculate grade + if system_settings.enable_product_grade: + # Lazy import: dojo.utils imports create_notification from this module's + # sibling (helper.py) at top-of-file, so importing dojo.utils eagerly here + # creates a circular import during Django startup. + from dojo.utils import calculate_grade # noqa: PLC0415 + products = Product.objects.all() + for product in products: + dojo_dispatch_task(calculate_grade, product.id) + + +@app.task(bind=True) +def cleanup_alerts(*args, **kwargs): + try: + max_alerts_per_user = settings.MAX_ALERTS_PER_USER + except System_Settings.DoesNotExist: + max_alerts_per_user = -1 + + if max_alerts_per_user > -1: + total_deleted_count = 0 + logger.info("start deleting oldest alerts if a user has more than %s alerts", max_alerts_per_user) + users = User.objects.all() + for user in users: + alerts_to_delete = Alerts.objects.filter(user_id=user.id).order_by("-created")[max_alerts_per_user:].values_list("id", flat=True) + total_deleted_count += len(alerts_to_delete) + Alerts.objects.filter(pk__in=list(alerts_to_delete)).delete() + logger.info("total number of alerts deleted: %s", total_deleted_count) + + +@app.task +def async_sla_compute_and_notify_task(*args, **kwargs): + logger.debug("Computing SLAs and notifying as needed") + try: + system_settings = System_Settings.objects.get() + if system_settings.enable_finding_sla: + sla_compute_and_notify(*args, **kwargs) + except Exception: + logger.exception("An unexpected error was thrown calling the SLA code") + + +@app.task +def send_slack_notification(event: str, user_id: int | None = None, **kwargs: dict) -> None: + user = Dojo_User.objects.get(pk=user_id) if user_id else None + get_manager_class_instance()._get_manager_instance("slack").send_slack_notification(event, user=user, **kwargs) + + +@app.task +def send_msteams_notification(event: str, user_id: int | None = None, **kwargs: dict) -> None: + user = Dojo_User.objects.get(pk=user_id) if user_id else None + get_manager_class_instance()._get_manager_instance("msteams").send_msteams_notification(event, user=user, **kwargs) + + +@app.task +def send_mail_notification(event: str, user_id: int | None = None, **kwargs: dict) -> None: + user = Dojo_User.objects.get(pk=user_id) if user_id else None + get_manager_class_instance()._get_manager_instance("mail").send_mail_notification(event, user=user, **kwargs) + + +@app.task +def send_webhooks_notification(event: str, user_id: int | None = None, **kwargs: dict) -> None: + user = Dojo_User.objects.get(pk=user_id) if user_id else None + get_manager_class_instance()._get_manager_instance("webhooks").send_webhooks_notification(event, user=user, **kwargs) + + +@app.task +def async_create_notification( + event: str, + engagement_id: int | None = None, + product_id: int | None = None, + product_type_id: int | None = None, + finding_id: int | None = None, + test_id: int | None = None, + **kwargs: dict, +) -> None: + # Re-fetch by id so the recipient-enumeration query and per-user Alert writes + # run in the worker rather than the request thread. + # Fetch most-specific first and derive parent objects from the already-loaded + # select_related chain to avoid redundant queries. For example, fetching a + # Test with select_related("engagement__product") covers all three objects in + # one query, so engagement_id and product_id don't need separate fetches. + fetched_engagement = None + fetched_product = None + + if test_id is not None: + test = Test.objects.filter(pk=test_id).select_related("engagement__product").first() + if test is None: + return + kwargs["test"] = test + fetched_engagement = test.engagement + fetched_product = test.engagement.product + + if engagement_id is not None: + if fetched_engagement is not None: + kwargs["engagement"] = fetched_engagement + else: + engagement = Engagement.objects.filter(pk=engagement_id).select_related("product").first() + if engagement is None: + return + kwargs["engagement"] = engagement + fetched_product = engagement.product + + if product_id is not None: + if fetched_product is not None: + kwargs["product"] = fetched_product + else: + product = Product.objects.filter(pk=product_id).first() + if product is None: + return + kwargs["product"] = product + + if product_type_id is not None: + product_type = Product_Type.objects.filter(pk=product_type_id).first() + if product_type is None: + return + kwargs["product_type"] = product_type + + if finding_id is not None: + finding = Finding.objects.filter(pk=finding_id).select_related("test__engagement__product").first() + if finding is None: + return + kwargs["finding"] = finding + + # Resolve via the helper module so unit tests that patch + # `dojo.notifications.helper.create_notification` capture this call. + from dojo.notifications import helper as _notifications_helper # noqa: PLC0415 + _notifications_helper.create_notification(event=event, **kwargs) + + +@app.task(ignore_result=True) +def webhook_reactivation(endpoint_id: int, **_kwargs: dict) -> None: + get_manager_class_instance()._get_manager_instance("webhooks")._webhook_reactivation(endpoint_id=endpoint_id) + + +@app.task(ignore_result=True) +def webhook_status_cleanup(*_args: list, **_kwargs: dict): + # If some endpoint was affected by some outage (5xx, 429, Timeout) but it was clean during last 24 hours, + # we consider this endpoint as healthy so need to reset it + endpoints = Notification_Webhooks.objects.filter( + status=Notification_Webhooks.Status.STATUS_ACTIVE_TMP, + last_error__lt=get_current_datetime() - timedelta(hours=24), + ) + for endpoint in endpoints: + endpoint.status = Notification_Webhooks.Status.STATUS_ACTIVE + endpoint.first_error = None + endpoint.last_error = None + endpoint.note = f"Reactivation from {Notification_Webhooks.Status.STATUS_ACTIVE_TMP}" + endpoint.save() + logger.debug( + f"Webhook endpoint '{endpoint.name}' reactivated from '{Notification_Webhooks.Status.STATUS_ACTIVE_TMP}' to '{Notification_Webhooks.Status.STATUS_ACTIVE}'", + ) + + # Reactivation of STATUS_INACTIVE_TMP endpoints. + # They should reactive automatically in 60s, however in case of some unexpected event (e.g. start of whole stack), + # endpoints should not be left in STATUS_INACTIVE_TMP state + broken_endpoints = Notification_Webhooks.objects.filter( + status=Notification_Webhooks.Status.STATUS_INACTIVE_TMP, + last_error__lt=get_current_datetime() - timedelta(minutes=5), + ) + for endpoint in broken_endpoints: + manager = WebhookNotificationManger() + manager._webhook_reactivation(endpoint_id=endpoint.pk) diff --git a/dojo/templates/dojo/add_notification_webhook.html b/dojo/notifications/templates/notifications/add_notification_webhook.html similarity index 100% rename from dojo/templates/dojo/add_notification_webhook.html rename to dojo/notifications/templates/notifications/add_notification_webhook.html diff --git a/dojo/templates/notifications/alert/engagement_added.tpl b/dojo/notifications/templates/notifications/alert/engagement_added.tpl similarity index 100% rename from dojo/templates/notifications/alert/engagement_added.tpl rename to dojo/notifications/templates/notifications/alert/engagement_added.tpl diff --git a/dojo/templates/notifications/alert/engagement_closed.tpl b/dojo/notifications/templates/notifications/alert/engagement_closed.tpl similarity index 100% rename from dojo/templates/notifications/alert/engagement_closed.tpl rename to dojo/notifications/templates/notifications/alert/engagement_closed.tpl diff --git a/dojo/templates/notifications/alert/other.tpl b/dojo/notifications/templates/notifications/alert/other.tpl similarity index 100% rename from dojo/templates/notifications/alert/other.tpl rename to dojo/notifications/templates/notifications/alert/other.tpl diff --git a/dojo/templates/notifications/alert/product_added.tpl b/dojo/notifications/templates/notifications/alert/product_added.tpl similarity index 100% rename from dojo/templates/notifications/alert/product_added.tpl rename to dojo/notifications/templates/notifications/alert/product_added.tpl diff --git a/dojo/templates/notifications/alert/product_type_added.tpl b/dojo/notifications/templates/notifications/alert/product_type_added.tpl similarity index 100% rename from dojo/templates/notifications/alert/product_type_added.tpl rename to dojo/notifications/templates/notifications/alert/product_type_added.tpl diff --git a/dojo/templates/notifications/alert/review_requested.tpl b/dojo/notifications/templates/notifications/alert/review_requested.tpl similarity index 100% rename from dojo/templates/notifications/alert/review_requested.tpl rename to dojo/notifications/templates/notifications/alert/review_requested.tpl diff --git a/dojo/templates/notifications/alert/scan_added_empty.tpl b/dojo/notifications/templates/notifications/alert/scan_added_empty.tpl similarity index 100% rename from dojo/templates/notifications/alert/scan_added_empty.tpl rename to dojo/notifications/templates/notifications/alert/scan_added_empty.tpl diff --git a/dojo/templates/notifications/alert/sla_breach.tpl b/dojo/notifications/templates/notifications/alert/sla_breach.tpl similarity index 100% rename from dojo/templates/notifications/alert/sla_breach.tpl rename to dojo/notifications/templates/notifications/alert/sla_breach.tpl diff --git a/dojo/templates/notifications/alert/test_added.tpl b/dojo/notifications/templates/notifications/alert/test_added.tpl similarity index 100% rename from dojo/templates/notifications/alert/test_added.tpl rename to dojo/notifications/templates/notifications/alert/test_added.tpl diff --git a/dojo/templates/notifications/alert/upcoming_engagement.tpl b/dojo/notifications/templates/notifications/alert/upcoming_engagement.tpl similarity index 100% rename from dojo/templates/notifications/alert/upcoming_engagement.tpl rename to dojo/notifications/templates/notifications/alert/upcoming_engagement.tpl diff --git a/dojo/templates/notifications/alert/user_mentioned.tpl b/dojo/notifications/templates/notifications/alert/user_mentioned.tpl similarity index 100% rename from dojo/templates/notifications/alert/user_mentioned.tpl rename to dojo/notifications/templates/notifications/alert/user_mentioned.tpl diff --git a/dojo/templates/dojo/alerts.html b/dojo/notifications/templates/notifications/alerts.html similarity index 100% rename from dojo/templates/dojo/alerts.html rename to dojo/notifications/templates/notifications/alerts.html diff --git a/dojo/templates/dojo/delete_alerts.html b/dojo/notifications/templates/notifications/delete_alerts.html similarity index 100% rename from dojo/templates/dojo/delete_alerts.html rename to dojo/notifications/templates/notifications/delete_alerts.html diff --git a/dojo/templates/dojo/delete_notification_webhook.html b/dojo/notifications/templates/notifications/delete_notification_webhook.html similarity index 100% rename from dojo/templates/dojo/delete_notification_webhook.html rename to dojo/notifications/templates/notifications/delete_notification_webhook.html diff --git a/dojo/templates/dojo/edit_notification_webhook.html b/dojo/notifications/templates/notifications/edit_notification_webhook.html similarity index 100% rename from dojo/templates/dojo/edit_notification_webhook.html rename to dojo/notifications/templates/notifications/edit_notification_webhook.html diff --git a/dojo/templates/notifications/mail/engagement_added.tpl b/dojo/notifications/templates/notifications/mail/engagement_added.tpl similarity index 100% rename from dojo/templates/notifications/mail/engagement_added.tpl rename to dojo/notifications/templates/notifications/mail/engagement_added.tpl diff --git a/dojo/templates/notifications/mail/engagement_closed.tpl b/dojo/notifications/templates/notifications/mail/engagement_closed.tpl similarity index 100% rename from dojo/templates/notifications/mail/engagement_closed.tpl rename to dojo/notifications/templates/notifications/mail/engagement_closed.tpl diff --git a/dojo/templates/notifications/mail/other.tpl b/dojo/notifications/templates/notifications/mail/other.tpl similarity index 100% rename from dojo/templates/notifications/mail/other.tpl rename to dojo/notifications/templates/notifications/mail/other.tpl diff --git a/dojo/templates/notifications/mail/product_added.tpl b/dojo/notifications/templates/notifications/mail/product_added.tpl similarity index 100% rename from dojo/templates/notifications/mail/product_added.tpl rename to dojo/notifications/templates/notifications/mail/product_added.tpl diff --git a/dojo/templates/notifications/mail/product_type_added.tpl b/dojo/notifications/templates/notifications/mail/product_type_added.tpl similarity index 100% rename from dojo/templates/notifications/mail/product_type_added.tpl rename to dojo/notifications/templates/notifications/mail/product_type_added.tpl diff --git a/dojo/templates/notifications/mail/review_requested.tpl b/dojo/notifications/templates/notifications/mail/review_requested.tpl similarity index 100% rename from dojo/templates/notifications/mail/review_requested.tpl rename to dojo/notifications/templates/notifications/mail/review_requested.tpl diff --git a/dojo/templates/notifications/mail/risk_acceptance_expiration.tpl b/dojo/notifications/templates/notifications/mail/risk_acceptance_expiration.tpl similarity index 100% rename from dojo/templates/notifications/mail/risk_acceptance_expiration.tpl rename to dojo/notifications/templates/notifications/mail/risk_acceptance_expiration.tpl diff --git a/dojo/templates/notifications/mail/scan_added.tpl b/dojo/notifications/templates/notifications/mail/scan_added.tpl similarity index 100% rename from dojo/templates/notifications/mail/scan_added.tpl rename to dojo/notifications/templates/notifications/mail/scan_added.tpl diff --git a/dojo/templates/notifications/mail/scan_added_empty.tpl b/dojo/notifications/templates/notifications/mail/scan_added_empty.tpl similarity index 100% rename from dojo/templates/notifications/mail/scan_added_empty.tpl rename to dojo/notifications/templates/notifications/mail/scan_added_empty.tpl diff --git a/dojo/templates/notifications/mail/sla_breach.tpl b/dojo/notifications/templates/notifications/mail/sla_breach.tpl similarity index 100% rename from dojo/templates/notifications/mail/sla_breach.tpl rename to dojo/notifications/templates/notifications/mail/sla_breach.tpl diff --git a/dojo/templates/notifications/mail/sla_breach_combined.tpl b/dojo/notifications/templates/notifications/mail/sla_breach_combined.tpl similarity index 100% rename from dojo/templates/notifications/mail/sla_breach_combined.tpl rename to dojo/notifications/templates/notifications/mail/sla_breach_combined.tpl diff --git a/dojo/templates/notifications/mail/test_added.tpl b/dojo/notifications/templates/notifications/mail/test_added.tpl similarity index 100% rename from dojo/templates/notifications/mail/test_added.tpl rename to dojo/notifications/templates/notifications/mail/test_added.tpl diff --git a/dojo/templates/notifications/mail/upcoming_engagement.tpl b/dojo/notifications/templates/notifications/mail/upcoming_engagement.tpl similarity index 100% rename from dojo/templates/notifications/mail/upcoming_engagement.tpl rename to dojo/notifications/templates/notifications/mail/upcoming_engagement.tpl diff --git a/dojo/templates/notifications/mail/user_mentioned.tpl b/dojo/notifications/templates/notifications/mail/user_mentioned.tpl similarity index 100% rename from dojo/templates/notifications/mail/user_mentioned.tpl rename to dojo/notifications/templates/notifications/mail/user_mentioned.tpl diff --git a/dojo/templates/notifications/msteams/engagement_added.tpl b/dojo/notifications/templates/notifications/msteams/engagement_added.tpl similarity index 100% rename from dojo/templates/notifications/msteams/engagement_added.tpl rename to dojo/notifications/templates/notifications/msteams/engagement_added.tpl diff --git a/dojo/templates/notifications/msteams/engagement_closed.tpl b/dojo/notifications/templates/notifications/msteams/engagement_closed.tpl similarity index 100% rename from dojo/templates/notifications/msteams/engagement_closed.tpl rename to dojo/notifications/templates/notifications/msteams/engagement_closed.tpl diff --git a/dojo/templates/notifications/msteams/other.tpl b/dojo/notifications/templates/notifications/msteams/other.tpl similarity index 100% rename from dojo/templates/notifications/msteams/other.tpl rename to dojo/notifications/templates/notifications/msteams/other.tpl diff --git a/dojo/templates/notifications/msteams/product_added.tpl b/dojo/notifications/templates/notifications/msteams/product_added.tpl similarity index 100% rename from dojo/templates/notifications/msteams/product_added.tpl rename to dojo/notifications/templates/notifications/msteams/product_added.tpl diff --git a/dojo/templates/notifications/msteams/product_type_added.tpl b/dojo/notifications/templates/notifications/msteams/product_type_added.tpl similarity index 100% rename from dojo/templates/notifications/msteams/product_type_added.tpl rename to dojo/notifications/templates/notifications/msteams/product_type_added.tpl diff --git a/dojo/templates/notifications/msteams/review_requested.tpl b/dojo/notifications/templates/notifications/msteams/review_requested.tpl similarity index 100% rename from dojo/templates/notifications/msteams/review_requested.tpl rename to dojo/notifications/templates/notifications/msteams/review_requested.tpl diff --git a/dojo/templates/notifications/msteams/risk_acceptance_expiration.tpl b/dojo/notifications/templates/notifications/msteams/risk_acceptance_expiration.tpl similarity index 100% rename from dojo/templates/notifications/msteams/risk_acceptance_expiration.tpl rename to dojo/notifications/templates/notifications/msteams/risk_acceptance_expiration.tpl diff --git a/dojo/templates/notifications/msteams/scan_added.tpl b/dojo/notifications/templates/notifications/msteams/scan_added.tpl similarity index 100% rename from dojo/templates/notifications/msteams/scan_added.tpl rename to dojo/notifications/templates/notifications/msteams/scan_added.tpl diff --git a/dojo/templates/notifications/msteams/scan_added_empty.tpl b/dojo/notifications/templates/notifications/msteams/scan_added_empty.tpl similarity index 100% rename from dojo/templates/notifications/msteams/scan_added_empty.tpl rename to dojo/notifications/templates/notifications/msteams/scan_added_empty.tpl diff --git a/dojo/templates/notifications/msteams/sla_breach.tpl b/dojo/notifications/templates/notifications/msteams/sla_breach.tpl similarity index 100% rename from dojo/templates/notifications/msteams/sla_breach.tpl rename to dojo/notifications/templates/notifications/msteams/sla_breach.tpl diff --git a/dojo/templates/notifications/msteams/test_added.tpl b/dojo/notifications/templates/notifications/msteams/test_added.tpl similarity index 100% rename from dojo/templates/notifications/msteams/test_added.tpl rename to dojo/notifications/templates/notifications/msteams/test_added.tpl diff --git a/dojo/templates/notifications/msteams/upcoming_engagement.tpl b/dojo/notifications/templates/notifications/msteams/upcoming_engagement.tpl similarity index 100% rename from dojo/templates/notifications/msteams/upcoming_engagement.tpl rename to dojo/notifications/templates/notifications/msteams/upcoming_engagement.tpl diff --git a/dojo/templates/notifications/msteams/user_mentioned.tpl b/dojo/notifications/templates/notifications/msteams/user_mentioned.tpl similarity index 100% rename from dojo/templates/notifications/msteams/user_mentioned.tpl rename to dojo/notifications/templates/notifications/msteams/user_mentioned.tpl diff --git a/dojo/templates/dojo/notifications.html b/dojo/notifications/templates/notifications/notifications.html similarity index 100% rename from dojo/templates/dojo/notifications.html rename to dojo/notifications/templates/notifications/notifications.html diff --git a/dojo/templates/notifications/slack/engagement_added.tpl b/dojo/notifications/templates/notifications/slack/engagement_added.tpl similarity index 100% rename from dojo/templates/notifications/slack/engagement_added.tpl rename to dojo/notifications/templates/notifications/slack/engagement_added.tpl diff --git a/dojo/templates/notifications/slack/engagement_closed.tpl b/dojo/notifications/templates/notifications/slack/engagement_closed.tpl similarity index 100% rename from dojo/templates/notifications/slack/engagement_closed.tpl rename to dojo/notifications/templates/notifications/slack/engagement_closed.tpl diff --git a/dojo/templates/notifications/slack/other.tpl b/dojo/notifications/templates/notifications/slack/other.tpl similarity index 100% rename from dojo/templates/notifications/slack/other.tpl rename to dojo/notifications/templates/notifications/slack/other.tpl diff --git a/dojo/templates/notifications/slack/product_added.tpl b/dojo/notifications/templates/notifications/slack/product_added.tpl similarity index 100% rename from dojo/templates/notifications/slack/product_added.tpl rename to dojo/notifications/templates/notifications/slack/product_added.tpl diff --git a/dojo/templates/notifications/slack/product_type_added.tpl b/dojo/notifications/templates/notifications/slack/product_type_added.tpl similarity index 100% rename from dojo/templates/notifications/slack/product_type_added.tpl rename to dojo/notifications/templates/notifications/slack/product_type_added.tpl diff --git a/dojo/templates/notifications/slack/report_created.tpl b/dojo/notifications/templates/notifications/slack/report_created.tpl similarity index 100% rename from dojo/templates/notifications/slack/report_created.tpl rename to dojo/notifications/templates/notifications/slack/report_created.tpl diff --git a/dojo/templates/notifications/slack/review_requested.tpl b/dojo/notifications/templates/notifications/slack/review_requested.tpl similarity index 100% rename from dojo/templates/notifications/slack/review_requested.tpl rename to dojo/notifications/templates/notifications/slack/review_requested.tpl diff --git a/dojo/templates/notifications/slack/risk_acceptance_expiration.tpl b/dojo/notifications/templates/notifications/slack/risk_acceptance_expiration.tpl similarity index 100% rename from dojo/templates/notifications/slack/risk_acceptance_expiration.tpl rename to dojo/notifications/templates/notifications/slack/risk_acceptance_expiration.tpl diff --git a/dojo/templates/notifications/slack/scan_added.tpl b/dojo/notifications/templates/notifications/slack/scan_added.tpl similarity index 100% rename from dojo/templates/notifications/slack/scan_added.tpl rename to dojo/notifications/templates/notifications/slack/scan_added.tpl diff --git a/dojo/templates/notifications/slack/scan_added_empty.tpl b/dojo/notifications/templates/notifications/slack/scan_added_empty.tpl similarity index 100% rename from dojo/templates/notifications/slack/scan_added_empty.tpl rename to dojo/notifications/templates/notifications/slack/scan_added_empty.tpl diff --git a/dojo/templates/notifications/slack/sla_breach.tpl b/dojo/notifications/templates/notifications/slack/sla_breach.tpl similarity index 100% rename from dojo/templates/notifications/slack/sla_breach.tpl rename to dojo/notifications/templates/notifications/slack/sla_breach.tpl diff --git a/dojo/templates/notifications/slack/test_added.tpl b/dojo/notifications/templates/notifications/slack/test_added.tpl similarity index 100% rename from dojo/templates/notifications/slack/test_added.tpl rename to dojo/notifications/templates/notifications/slack/test_added.tpl diff --git a/dojo/templates/notifications/slack/upcoming_engagement.tpl b/dojo/notifications/templates/notifications/slack/upcoming_engagement.tpl similarity index 100% rename from dojo/templates/notifications/slack/upcoming_engagement.tpl rename to dojo/notifications/templates/notifications/slack/upcoming_engagement.tpl diff --git a/dojo/templates/notifications/slack/user_mentioned.tpl b/dojo/notifications/templates/notifications/slack/user_mentioned.tpl similarity index 100% rename from dojo/templates/notifications/slack/user_mentioned.tpl rename to dojo/notifications/templates/notifications/slack/user_mentioned.tpl diff --git a/dojo/templates/dojo/view_notification_webhooks.html b/dojo/notifications/templates/notifications/view_notification_webhooks.html similarity index 100% rename from dojo/templates/dojo/view_notification_webhooks.html rename to dojo/notifications/templates/notifications/view_notification_webhooks.html diff --git a/dojo/templates/notifications/webhooks/engagement_added.tpl b/dojo/notifications/templates/notifications/webhooks/engagement_added.tpl similarity index 100% rename from dojo/templates/notifications/webhooks/engagement_added.tpl rename to dojo/notifications/templates/notifications/webhooks/engagement_added.tpl diff --git a/dojo/templates/notifications/webhooks/other.tpl b/dojo/notifications/templates/notifications/webhooks/other.tpl similarity index 100% rename from dojo/templates/notifications/webhooks/other.tpl rename to dojo/notifications/templates/notifications/webhooks/other.tpl diff --git a/dojo/templates/notifications/webhooks/product_added.tpl b/dojo/notifications/templates/notifications/webhooks/product_added.tpl similarity index 100% rename from dojo/templates/notifications/webhooks/product_added.tpl rename to dojo/notifications/templates/notifications/webhooks/product_added.tpl diff --git a/dojo/templates/notifications/webhooks/product_type_added.tpl b/dojo/notifications/templates/notifications/webhooks/product_type_added.tpl similarity index 100% rename from dojo/templates/notifications/webhooks/product_type_added.tpl rename to dojo/notifications/templates/notifications/webhooks/product_type_added.tpl diff --git a/dojo/templates/notifications/webhooks/scan_added.tpl b/dojo/notifications/templates/notifications/webhooks/scan_added.tpl similarity index 100% rename from dojo/templates/notifications/webhooks/scan_added.tpl rename to dojo/notifications/templates/notifications/webhooks/scan_added.tpl diff --git a/dojo/templates/notifications/webhooks/scan_added_empty.tpl b/dojo/notifications/templates/notifications/webhooks/scan_added_empty.tpl similarity index 100% rename from dojo/templates/notifications/webhooks/scan_added_empty.tpl rename to dojo/notifications/templates/notifications/webhooks/scan_added_empty.tpl diff --git a/dojo/templates/notifications/webhooks/subtemplates/base.tpl b/dojo/notifications/templates/notifications/webhooks/subtemplates/base.tpl similarity index 100% rename from dojo/templates/notifications/webhooks/subtemplates/base.tpl rename to dojo/notifications/templates/notifications/webhooks/subtemplates/base.tpl diff --git a/dojo/templates/notifications/webhooks/subtemplates/engagement.tpl b/dojo/notifications/templates/notifications/webhooks/subtemplates/engagement.tpl similarity index 100% rename from dojo/templates/notifications/webhooks/subtemplates/engagement.tpl rename to dojo/notifications/templates/notifications/webhooks/subtemplates/engagement.tpl diff --git a/dojo/templates/notifications/webhooks/subtemplates/findings_list.tpl b/dojo/notifications/templates/notifications/webhooks/subtemplates/findings_list.tpl similarity index 100% rename from dojo/templates/notifications/webhooks/subtemplates/findings_list.tpl rename to dojo/notifications/templates/notifications/webhooks/subtemplates/findings_list.tpl diff --git a/dojo/templates/notifications/webhooks/subtemplates/product.tpl b/dojo/notifications/templates/notifications/webhooks/subtemplates/product.tpl similarity index 100% rename from dojo/templates/notifications/webhooks/subtemplates/product.tpl rename to dojo/notifications/templates/notifications/webhooks/subtemplates/product.tpl diff --git a/dojo/templates/notifications/webhooks/subtemplates/product_type.tpl b/dojo/notifications/templates/notifications/webhooks/subtemplates/product_type.tpl similarity index 100% rename from dojo/templates/notifications/webhooks/subtemplates/product_type.tpl rename to dojo/notifications/templates/notifications/webhooks/subtemplates/product_type.tpl diff --git a/dojo/templates/notifications/webhooks/subtemplates/test.tpl b/dojo/notifications/templates/notifications/webhooks/subtemplates/test.tpl similarity index 100% rename from dojo/templates/notifications/webhooks/subtemplates/test.tpl rename to dojo/notifications/templates/notifications/webhooks/subtemplates/test.tpl diff --git a/dojo/templates/notifications/webhooks/subtemplates/user.tpl b/dojo/notifications/templates/notifications/webhooks/subtemplates/user.tpl similarity index 100% rename from dojo/templates/notifications/webhooks/subtemplates/user.tpl rename to dojo/notifications/templates/notifications/webhooks/subtemplates/user.tpl diff --git a/dojo/templates/notifications/webhooks/test_added.tpl b/dojo/notifications/templates/notifications/webhooks/test_added.tpl similarity index 100% rename from dojo/templates/notifications/webhooks/test_added.tpl rename to dojo/notifications/templates/notifications/webhooks/test_added.tpl diff --git a/dojo/notifications/ui/__init__.py b/dojo/notifications/ui/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/dojo/notifications/ui/forms.py b/dojo/notifications/ui/forms.py new file mode 100644 index 00000000000..e598fb020fa --- /dev/null +++ b/dojo/notifications/ui/forms.py @@ -0,0 +1,54 @@ +from django import forms + +from dojo.notifications.models import Notification_Webhooks, Notifications + + +class NotificationsForm(forms.ModelForm): + + class Meta: + model = Notifications + exclude = ["template"] + + +class NotificationsWebhookForm(forms.ModelForm): + class Meta: + model = Notification_Webhooks + exclude = [] + + def __init__(self, *args, **kwargs): + is_superuser = kwargs.pop("is_superuser", False) + super().__init__(*args, **kwargs) + if not is_superuser: # Only superadmins can edit owner + self.fields["owner"].disabled = True # TODO: needs to be tested + + +class DeleteNotificationsWebhookForm(forms.ModelForm): + id = forms.IntegerField(required=True, + widget=forms.widgets.HiddenInput()) + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.fields["name"].disabled = True + self.fields["url"].disabled = True + + class Meta: + model = Notification_Webhooks + fields = ["id", "name", "url"] + + +class ProductNotificationsForm(forms.ModelForm): + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + if not self.instance.id: + self.initial["engagement_added"] = "" + self.initial["close_engagement"] = "" + 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", "sla_breach_combined", "risk_acceptance_expiration"] diff --git a/dojo/notifications/urls.py b/dojo/notifications/ui/urls.py similarity index 100% rename from dojo/notifications/urls.py rename to dojo/notifications/ui/urls.py diff --git a/dojo/notifications/views.py b/dojo/notifications/ui/views.py similarity index 96% rename from dojo/notifications/views.py rename to dojo/notifications/ui/views.py index 323a8b0ccf2..d9c1ce1a90d 100644 --- a/dojo/notifications/views.py +++ b/dojo/notifications/ui/views.py @@ -10,9 +10,13 @@ from django.utils.translation import gettext as _ from django.views import View -from dojo.forms import DeleteNotificationsWebhookForm, NotificationsForm, NotificationsWebhookForm -from dojo.models import Notification_Webhooks, Notifications from dojo.notifications.helper import NotificationManagerHelpers, WebhookNotificationManger +from dojo.notifications.models import Notification_Webhooks, Notifications +from dojo.notifications.ui.forms import ( + DeleteNotificationsWebhookForm, + NotificationsForm, + NotificationsWebhookForm, +) from dojo.utils import add_breadcrumb, get_enabled_notifications_list, get_system_setting logger = logging.getLogger(__name__) @@ -63,7 +67,7 @@ def process_form(self, request: HttpRequest, context: dict): return request, False def get_template(self): - return "dojo/notifications.html" + return "notifications/notifications.html" def get_scope(self): return "system" @@ -166,7 +170,7 @@ def preprocess_request(self, request: HttpRequest): class ListNotificationWebhooksView(NotificationWebhooksView): - template = "dojo/view_notification_webhooks.html" + template = "notifications/view_notification_webhooks.html" permission = "dojo.view_notification_webhooks" breadcrumb = "Notification Webhook List" @@ -197,7 +201,7 @@ def get(self, request: HttpRequest): class AddNotificationWebhooksView(NotificationWebhooksView): - template = "dojo/add_notification_webhook.html" + template = "notifications/add_notification_webhook.html" permission = "dojo.add_notification_webhooks" breadcrumb = "Add Notification Webhook" @@ -267,7 +271,7 @@ def post(self, request: HttpRequest): class EditNotificationWebhooksView(NotificationWebhooksView): - template = "dojo/edit_notification_webhook.html" + template = "notifications/edit_notification_webhook.html" permission = "dojo.change_notification_webhooks" # TODO: this could be better: @user_is_authorized(Finding, Permissions.Finding_Delete, 'fid') breadcrumb = "Edit Notification Webhook" @@ -357,7 +361,7 @@ def post(self, request: HttpRequest, nwhid: int): class DeleteNotificationWebhooksView(NotificationWebhooksView): - template = "dojo/delete_notification_webhook.html" + template = "notifications/delete_notification_webhook.html" permission = "dojo.delete_notification_webhooks" # TODO: this could be better: @user_is_authorized(Finding, Permissions.Finding_Delete, 'fid') breadcrumb = "Edit Notification Webhook" diff --git a/dojo/settings/settings.dist.py b/dojo/settings/settings.dist.py index 2c828aa9582..69fdfdc0d90 100644 --- a/dojo/settings/settings.dist.py +++ b/dojo/settings/settings.dist.py @@ -20,6 +20,12 @@ from netaddr import IPNetwork, IPSet from dojo import __version__ +from dojo.notifications.settings import ( + NOTIFICATIONS_ENV_DEFAULTS, +) +from dojo.notifications.settings import ( + populate_settings as _populate_notifications_settings, +) logger = logging.getLogger(__name__) @@ -248,13 +254,7 @@ # SLA Notifications via alerts and JIRA comments # enable either DD_SLA_NOTIFY_ACTIVE or DD_SLA_NOTIFY_ACTIVE_VERIFIED_ONLY to enable the feature. # If desired you can enable to only notify for Findings that are linked to JIRA issues. - # All three flags are moved to system_settings, will be removed from settings file - DD_SLA_NOTIFY_ACTIVE=(bool, False), - DD_SLA_NOTIFY_ACTIVE_VERIFIED_ONLY=(bool, False), - DD_SLA_NOTIFY_WITH_JIRA_ONLY=(bool, False), - # finetuning settings for when enabled - DD_SLA_NOTIFY_PRE_BREACH=(int, 3), - DD_SLA_NOTIFY_POST_BREACH=(int, 7), + # SLA + alert + notification env-var schema lives in dojo/notifications/settings.py. # maximum number of result in search as search can be an expensive operation DD_SEARCH_MAX_RESULTS=(int, 100), DD_SIMILAR_FINDINGS_MAX_RESULTS=(int, 25), @@ -282,10 +282,6 @@ DD_LOGGING_HANDLER=(str, "console"), # If true, drf-spectacular will load CSS & JS from default CDN, otherwise from static resources DD_DEFAULT_SWAGGER_UI=(bool, False), - DD_ALERT_REFRESH=(bool, True), - DD_DISABLE_ALERT_COUNTER=(bool, False), - # to disable deleting alerts per user set value to -1 - DD_MAX_ALERTS_PER_USER=(int, 999), DD_TAG_PREFETCHING=(bool, True), DD_QUALYS_WAS_WEAKNESS_IS_VULN=(bool, False), # regular expression to exclude one or more parsers @@ -368,8 +364,6 @@ # When set to True, use the older version of the qualys parser that is a more heavy handed in setting severity # with the use of CVSS scores to potentially override the severity found in the report produced by the tool DD_QUALYS_LEGACY_SEVERITY_PARSING=(bool, True), - # Use System notification settings to override user's notification settings - DD_NOTIFICATIONS_SYSTEM_LEVEL_TRUMP=(list, ["user_mentioned", "review_requested"]), # When enabled, force the password field to be required for creating/updating users DD_REQUIRE_PASSWORD_ON_USER=(bool, True), # For HTTP requests, how long connection is open before timeout @@ -379,6 +373,8 @@ DD_V3_FEATURE_LOCATIONS=(bool, False), # Dictates if v3 org/asset relabeling (+url routing) will be enabled DD_ENABLE_V3_ORGANIZATION_ASSET_RELABEL=(bool, False), + # Notification env-vars (SLA notify, alert refresh/counter/cap, system-level trump). Defined in dojo.notifications.settings. + **NOTIFICATIONS_ENV_DEFAULTS, ) @@ -446,9 +442,7 @@ def generate_url(scheme, double_slashes, user, password, host, port, path, param TEST_RUNNER = env("DD_TEST_RUNNER") -ALERT_REFRESH = env("DD_ALERT_REFRESH") -DISABLE_ALERT_COUNTER = env("DD_DISABLE_ALERT_COUNTER") -MAX_ALERTS_PER_USER = env("DD_MAX_ALERTS_PER_USER") +_populate_notifications_settings(env, globals()) TAG_PREFETCHING = env("DD_TAG_PREFETCHING") # Tag bulk add batch size (used by dojo.tag_utils.bulk_add_tag_to_instances) @@ -725,12 +719,7 @@ def generate_url(scheme, double_slashes, user, password, host, port, path, param # If you import thousands of Active findings through your pipeline everyday, # and make the choice of enabling SLA notifications for non-verified findings, # be mindful of performance. -# 'SLA_NOTIFY_ACTIVE', 'SLA_NOTIFY_ACTIVE_VERIFIED_ONLY' and 'SLA_NOTIFY_WITH_JIRA_ONLY' are moved to system settings, will be removed here -SLA_NOTIFY_ACTIVE = env("DD_SLA_NOTIFY_ACTIVE") # this will include 'verified' findings as well as non-verified. -SLA_NOTIFY_ACTIVE_VERIFIED_ONLY = env("DD_SLA_NOTIFY_ACTIVE_VERIFIED_ONLY") -SLA_NOTIFY_WITH_JIRA_ONLY = env("DD_SLA_NOTIFY_WITH_JIRA_ONLY") # Based on the 2 above, but only with a JIRA link -SLA_NOTIFY_PRE_BREACH = env("DD_SLA_NOTIFY_PRE_BREACH") # in days, notify between dayofbreach minus this number until dayofbreach -SLA_NOTIFY_POST_BREACH = env("DD_SLA_NOTIFY_POST_BREACH") # in days, skip notifications for findings that go past dayofbreach plus this number +# SLA_NOTIFY_* are populated by dojo.notifications.settings.populate_settings (see ALERT_REFRESH section above). SEARCH_MAX_RESULTS = env("DD_SEARCH_MAX_RESULTS") @@ -946,6 +935,7 @@ def generate_url(scheme, double_slashes, user, password, host, port, path, param TEMPLATES = [ { "BACKEND": "django.template.backends.django.DjangoTemplates", + "DIRS": [Path(DOJO_ROOT) / "notifications" / "templates"], "APP_DIRS": True, "OPTIONS": { "debug": env("DD_DEBUG"), @@ -958,9 +948,9 @@ def generate_url(scheme, double_slashes, user, password, host, port, path, param "social_django.context_processors.login_redirect", "dojo.context_processors.globalize_vars", "dojo.context_processors.bind_system_settings", - "dojo.context_processors.bind_alert_count", + "dojo.notifications.context_processors.bind_alert_count", "dojo.context_processors.bind_announcement", - "dojo.context_processors.session_expiry_notification", + "dojo.notifications.context_processors.session_expiry_notification", "dojo.context_processors.labels", ], }, @@ -1289,7 +1279,7 @@ def saml2_attrib_map_format(din): # Celery beat scheduled tasks CELERY_BEAT_SCHEDULE = { "add-alerts": { - "task": "dojo.tasks.add_alerts", + "task": "dojo.notifications.tasks.add_alerts", "schedule": timedelta(hours=1), "args": [timedelta(hours=1)], "options": { @@ -1297,7 +1287,7 @@ def saml2_attrib_map_format(din): }, }, "cleanup-alerts": { - "task": "dojo.tasks.cleanup_alerts", + "task": "dojo.notifications.tasks.cleanup_alerts", "schedule": timedelta(hours=8), "options": { "expires": int(60 * 60 * 8 * 1.2), # If a task is not executed within 9.6 hours, it should be dropped from the queue. Two more tasks should be scheduled in the meantime. @@ -1325,7 +1315,7 @@ def saml2_attrib_map_format(din): }, }, "compute-sla-age-and-notify": { - "task": "dojo.tasks.async_sla_compute_and_notify_task", + "task": "dojo.notifications.tasks.async_sla_compute_and_notify_task", "schedule": crontab(hour=7, minute=30), "options": { "expires": int(60 * 60 * 24 * 1.2), # If a task is not executed within 28.8 hours, it should be dropped from the queue. Two more tasks should be scheduled in the meantime. @@ -1339,7 +1329,7 @@ def saml2_attrib_map_format(din): }, }, "notification_webhook_status_cleanup": { - "task": "dojo.notifications.helper.webhook_status_cleanup", + "task": "dojo.notifications.tasks.webhook_status_cleanup", "schedule": timedelta(minutes=1), "options": { "expires": int(60 * 1 * 1.2), # If a task is not executed within 72 seconds, it should be dropped from the queue. Two more tasks should be scheduled in the meantime. @@ -2096,7 +2086,7 @@ def saml2_attrib_map_format(din): # ------------------------------------------------------------------------------ # Notifications # ------------------------------------------------------------------------------ -NOTIFICATIONS_SYSTEM_LEVEL_TRUMP = env("DD_NOTIFICATIONS_SYSTEM_LEVEL_TRUMP") +# NOTIFICATIONS_SYSTEM_LEVEL_TRUMP is populated by dojo.notifications.settings.populate_settings. # ------------------------------------------------------------------------------ # Timeouts diff --git a/dojo/tasks.py b/dojo/tasks.py index dbc2135e560..e857ba7967a 100644 --- a/dojo/tasks.py +++ b/dojo/tasks.py @@ -1,5 +1,4 @@ import logging -from datetime import timedelta import pghistory from celery import Task @@ -9,90 +8,24 @@ from django.core.exceptions import SuspiciousOperation from django.core.management import call_command from django.db.models import Count, Prefetch -from django.urls import reverse -from django.utils import timezone from dojo.auditlog import run_flush_auditlog from dojo.celery import app from dojo.celery_dispatch import dojo_dispatch_task from dojo.finding.helper import fix_loop_duplicates from dojo.management.commands.jira_status_reconciliation import jira_status_reconciliation -from dojo.models import Alerts, Engagement, Finding, Product, System_Settings, User -from dojo.notifications.helper import create_notification -from dojo.utils import calculate_grade, sla_compute_and_notify +from dojo.models import Finding, System_Settings +from dojo.utils import calculate_grade logger = get_task_logger(__name__) deduplicationLogger = logging.getLogger("dojo.specific-loggers.deduplication") -# Logs the error to the alerts table, which appears in the notification toolbar -def log_generic_alert(source, title, description): - create_notification(event="other", title=title, description=description, - icon="bullseye", source=source) - - -@app.task(bind=True) -def add_alerts(self, runinterval, *args, **kwargs): - now = timezone.now() - - upcoming_engagements = Engagement.objects.filter(target_start__gt=now + timedelta(days=3), target_start__lt=now + timedelta(days=3) + runinterval).order_by("target_start") - for engagement in upcoming_engagements: - create_notification(event="upcoming_engagement", - title=f"Upcoming engagement: {engagement.name}", - engagement=engagement, - recipients=[engagement.lead], - url=reverse("view_engagement", args=(engagement.id,))) - - stale_engagements = Engagement.objects.filter( - target_start__gt=now - runinterval, - target_end__lt=now, - status="In Progress").order_by("-target_end") - for eng in stale_engagements: - create_notification(event="stale_engagement", - title=f"Stale Engagement: {eng.name}", - description='The engagement "{}" is stale. Target end was {}.'.format(eng.name, eng.target_end.strftime("%b. %d, %Y")), - url=reverse("view_engagement", args=(eng.id,)), - recipients=[eng.lead]) - - system_settings = System_Settings.objects.get() - if system_settings.engagement_auto_close: - # Close Engagements older than user defined days - close_days = system_settings.engagement_auto_close_days - unclosed_engagements = Engagement.objects.filter(target_end__lte=now - timedelta(days=close_days), - status="In Progress").order_by("target_end") - - for eng in unclosed_engagements: - create_notification(event="auto_close_engagement", - title=eng.name, - description='The engagement "{}" has auto-closed. Target end was {}.'.format(eng.name, eng.target_end.strftime("%b. %d, %Y")), - url=reverse("view_engagement", args=(eng.id,)), - recipients=[eng.lead]) - - unclosed_engagements.update(status="Completed", active=False, updated=timezone.now()) - - # Calculate grade - if system_settings.enable_product_grade: - products = Product.objects.all() - for product in products: - dojo_dispatch_task(calculate_grade, product.id) - - -@app.task(bind=True) -def cleanup_alerts(*args, **kwargs): - try: - max_alerts_per_user = settings.MAX_ALERTS_PER_USER - except System_Settings.DoesNotExist: - max_alerts_per_user = -1 - - if max_alerts_per_user > -1: - total_deleted_count = 0 - logger.info("start deleting oldest alerts if a user has more than %s alerts", max_alerts_per_user) - users = User.objects.all() - for user in users: - alerts_to_delete = Alerts.objects.filter(user_id=user.id).order_by("-created")[max_alerts_per_user:].values_list("id", flat=True) - total_deleted_count += len(alerts_to_delete) - Alerts.objects.filter(pk__in=list(alerts_to_delete)).delete() - logger.info("total number of alerts deleted: %s", total_deleted_count) +from dojo.notifications.tasks import ( # noqa: E402, F401 -- backward compat + add_alerts, + cleanup_alerts, + log_generic_alert, +) @app.task(bind=True) @@ -189,15 +122,7 @@ def celery_status(): return True -@app.task -def async_sla_compute_and_notify_task(*args, **kwargs): - logger.debug("Computing SLAs and notifying as needed") - try: - system_settings = System_Settings.objects.get() - if system_settings.enable_finding_sla: - sla_compute_and_notify(*args, **kwargs) - except Exception: - logger.exception("An unexpected error was thrown calling the SLA code") +from dojo.notifications.tasks import async_sla_compute_and_notify_task # noqa: E402, F401 -- backward compat @app.task diff --git a/dojo/urls.py b/dojo/urls.py index 87ad7d85c36..ed4b2195d29 100644 --- a/dojo/urls.py +++ b/dojo/urls.py @@ -41,8 +41,6 @@ NetworkLocationsViewset, NotesViewSet, NoteTypeViewSet, - NotificationsViewSet, - NotificationWebhooksViewSet, ProductAPIScanConfigurationViewSet, ProductGroupViewSet, ProductMemberViewSet, @@ -95,7 +93,8 @@ from dojo.metrics.urls import urlpatterns as metrics_urls from dojo.note_type.urls import urlpatterns as note_type_urls from dojo.notes.urls import urlpatterns as notes_urls -from dojo.notifications.urls import urlpatterns as notifications_urls +from dojo.notifications.api.urls import add_notifications_urls +from dojo.notifications.ui.urls import urlpatterns as notifications_urls from dojo.object.urls import urlpatterns as object_urls from dojo.organization.api.urls import add_organization_urls from dojo.organization.urls import urlpatterns as organization_urls @@ -152,8 +151,7 @@ v2_api.register(r"network_locations", NetworkLocationsViewset, basename="network_locations") v2_api.register(r"notes", NotesViewSet, basename="notes") v2_api.register(r"note_type", NoteTypeViewSet, basename="note_type") -v2_api.register(r"notifications", NotificationsViewSet, basename="notifications") -v2_api.register(r"notification_webhooks", NotificationWebhooksViewSet) +add_notifications_urls(v2_api) v2_api.register(r"products", ProductViewSet, basename="product") v2_api.register(r"product_api_scan_configurations", ProductAPIScanConfigurationViewSet, basename="product_api_scan_configuration") v2_api.register(r"product_groups", ProductGroupViewSet, basename="product_group") diff --git a/dojo/user/views.py b/dojo/user/views.py index dd732b5c91e..9ccbebd48d6 100644 --- a/dojo/user/views.py +++ b/dojo/user/views.py @@ -195,7 +195,7 @@ def alerts(request): add_breadcrumb(title=alert_title, top_level=True, request=request) return render(request, - "dojo/alerts.html", + "notifications/alerts.html", {"alerts": paged_alerts}) @@ -211,7 +211,7 @@ def delete_alerts(request): extra_tags="alert-success") return HttpResponseRedirect("alerts") - return render(request, "dojo/delete_alerts.html", { + return render(request, "notifications/delete_alerts.html", { "alerts": alerts, "delete_preview": get_setting("DELETE_PREVIEW"), }) diff --git a/dojo/utils.py b/dojo/utils.py index 605fb11b55c..3c18a0d0c5e 100644 --- a/dojo/utils.py +++ b/dojo/utils.py @@ -39,7 +39,7 @@ from django.dispatch import receiver from django.http import FileResponse, HttpResponseRedirect from django.shortcuts import redirect as django_redirect -from django.urls import get_resolver, get_script_prefix, reverse +from django.urls import get_resolver, reverse from django.utils import timezone from django.utils.http import url_has_allowed_host_and_scheme from django.utils.translation import gettext as _ @@ -70,7 +70,6 @@ Finding_Template, Language_Type, Languages, - Notifications, Product, Product_Type, System_Settings, @@ -78,7 +77,6 @@ Test_Type, User, ) -from dojo.notifications.helper import create_notification logger = logging.getLogger(__name__) deduplicationLogger = logging.getLogger("dojo.specific-loggers.deduplication") @@ -961,30 +959,7 @@ def reopen_external_issue(finding_id, note, external_issue_provider, **kwargs): reopen_external_issue_github(finding, note, prod, eng) -def process_tag_notifications(request, note, parent_url, parent_title): - regex = re.compile(r"(?:\A|\s)@(\w+)\b") - - usernames_to_check = set(un.lower() for un in regex.findall(note.entry)) # noqa: C401 - - users_to_notify = [ - User.objects.filter(username=username).get() - for username in usernames_to_check - if User.objects.filter(is_active=True, username=username).exists() - ] - - if len(note.entry) > 200: - note.entry = note.entry[:200] - note.entry += "..." - - create_notification( - event="user_mentioned", - section=parent_title, - note=note, - title=f"{request.user} jotted a note", - url=parent_url, - icon="commenting", - recipients=users_to_notify, - requested_by=get_current_user()) +from dojo.notifications.helper import process_tag_notifications # noqa: E402, F401 -- backward compat def encrypt(key, iv, plaintext): @@ -1424,23 +1399,9 @@ def get_site_url(): @receiver(post_save, sender=User) @receiver(post_save, sender=Dojo_User) def user_post_save(sender, instance, created, **kwargs): - # For new users we create a Notifications object so the default 'alert' notifications work and - # assign them to a default group if specified in the system settings. - # This needs to be a signal to make it also work for users created via ldap, oauth and other - # authentication backends + # Default-Notifications row creation for new users now lives in + # dojo.notifications.signals.create_default_notifications. if created: - try: - notifications = Notifications.objects.get(template=True) - notifications.pk = None - notifications.template = False - notifications.user = instance - logger.info("creating default set (from template) of notifications for: " + str(instance)) - except Exception: - notifications = Notifications(user=instance) - logger.info("creating default set of notifications for: " + str(instance)) - - notifications.save() - system_settings = System_Settings.objects.get() if system_settings.default_group and system_settings.default_group_role: if (system_settings.default_group_email_pattern and re.fullmatch(system_settings.default_group_email_pattern, instance.email)) or \ @@ -1509,217 +1470,7 @@ def queryset_check(query): return query if isinstance(query, QuerySet) else query.qs -def sla_compute_and_notify(*args, **kwargs): - """ - The SLA computation and notification will be disabled if the user opts out - of the Findings SLA on the System Settings page. - - Notifications are managed the usual way, so you'd have to opt-in. - Exception is for JIRA issues, which would get a comment anyways. - """ - from dojo.jira import services as jira_services # noqa: PLC0415 circular import - - 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) - - 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 = f"Finding {finding.id} - " - if kind == "breached": - abs_sla_age = abs(sla_age) - period = "day" - if abs_sla_age > 1: - period = "days" - title += f"SLA breached by {abs_sla_age} {period}! Overdue notice" - elif kind == "prebreach": - title += f"SLA pre-breach warning - {sla_age} day(s) left" - elif kind == "breaching": - title += "SLA is breaching today" - - return title - - def _create_notifications(): - for prodtype, comb_notif_prodtype in combined_notifications.items(): - for prod, comb_notif_prod in comb_notif_prodtype.items(): - for kind, comb_notif_kind in comb_notif_prod.items(): - # 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 comb_notif_kind: - sla_age = n.finding.sla_days_remaining() - title = _notification_title_for_finding(n.finding, kind, sla_age) - create_notification( - event="sla_breach", - title=title, - finding=n.finding, - sla_age=sla_age, - 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_services.add_simple_comment(jira_instance, n.jira_issue, title) - - findings_list.append(n.finding) - - # producing a "combined" SLA breach notification - title_combined = f"SLA alert ({kind}): " + labels.ORG_WITH_NAME_LABEL % {"name": prodtype} + ", " + labels.ASSET_WITH_NAME_LABEL % {"name": prod} - product = comb_notif_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() - if not system_settings.enable_notify_sla_active and not system_settings.enable_notify_sla_active_verified: - logger.info("Will not notify on SLA breach per user configured settings") - return - - 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.") - logger.debug(f"Active {system_settings.enable_notify_sla_active}, Verified {system_settings.enable_notify_sla_active_verified}, Has JIRA {system_settings.enable_notify_sla_jira_only}, pre-breach {settings.SLA_NOTIFY_PRE_BREACH}, post-breach {settings.SLA_NOTIFY_POST_BREACH}") - - query = None - if system_settings.enable_notify_sla_active_verified: - query = Q(active=True, verified=True, is_mitigated=False, duplicate=False) - elif system_settings.enable_notify_sla_active: - query = Q(active=True, is_mitigated=False, duplicate=False) - logger.debug("My query: %s", query) - - no_jira_findings = {} - if system_settings.enable_notify_sla_jira_only: - logger.debug("Ignoring findings that are not linked to a JIRA issue") - no_jira_findings = Finding.objects.exclude(jira_issue__isnull=False) - - total_count = 0 - pre_breach_count = 0 - post_breach_count = 0 - post_breach_no_notify_count = 0 - jira_count = 0 - at_breach_count = 0 - - # Taking away for now, since the prefetch is not efficient - # .select_related('jira_issue') \ - # .prefetch_related(Prefetch('test__engagement__product__jira_project_set__jira_instance')) \ - # A finding with 'Info' severity will not be considered for SLA notifications (not in model) - findings = Finding.objects \ - .filter(query) \ - .exclude(severity="Info") \ - .exclude(id__in=no_jira_findings) - - for finding in findings: - total_count += 1 - sla_age = finding.sla_days_remaining() - - # get the sla enforcement for the severity and, if the severity setting is not enforced, do not notify - # resolves an issue where notifications are always sent for the severity of SLA that is not enforced - severity, enforce = finding.get_sla_period() - if not enforce: - logger.debug(f"SLA is not enforced for Finding {finding.id} of {severity} severity, skipping notification.") - continue - - # if SLA is set to 0 in settings, it's a null. And setting at 0 means no SLA apparently. - if sla_age is None: - sla_age = 0 - - if (sla_age < 0) and (abs(sla_age) > settings.SLA_NOTIFY_POST_BREACH): - post_breach_no_notify_count += 1 - # Skip finding notification if breached for too long - logger.debug(f"Finding {finding.id} breached the SLA {abs(sla_age)} days ago. Skipping notifications.") - continue - - do_jira_sla_comment = False - jira_issue = None - if finding.has_jira_issue: - jira_issue = finding.jira_issue - elif finding.has_jira_group_issue: - jira_issue = finding.finding_group.jira_issue - - if jira_issue: - jira_count += 1 - jira_instance = jira_services.get_instance(finding) - if jira_instance is not None: - logger.debug("JIRA config for finding is %s", jira_instance) - # global config or product config set, product level takes precedence - try: - # TODO: see new property from #2649 to then replace, somehow not working with prefetching though. - product_jira_sla_comment_enabled = jira_services.get_project(finding).product_jira_sla_notification - except Exception as e: - logger.error("The product is not linked to a JIRA configuration! Something is weird here.") - logger.error("Error is: %s", e) - - jiraconfig_sla_notification_enabled = jira_instance.global_jira_sla_notification - - if jiraconfig_sla_notification_enabled or product_jira_sla_comment_enabled: - logger.debug("Global setting %s -- Product setting %s", jiraconfig_sla_notification_enabled, product_jira_sla_comment_enabled) - do_jira_sla_comment = True - logger.debug(f"JIRA issue is {jira_issue.jira_key}") - - logger.debug(f"Finding {finding.id} has {sla_age} days left to breach SLA.") - if (sla_age < 0): - post_breach_count += 1 - logger.info(f"Finding {finding.id} has breached by {abs(sla_age)} days.") - 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): - _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(f"Security SLA pre-breach warning for finding ID {finding.id}. Days remaining: {sla_age}") - _add_notification(finding, "prebreach") - # The finding breaches the SLA today - elif (sla_age == 0): - at_breach_count += 1 - logger.info(f"Security SLA breach warning. Finding ID {finding.id} breaching today ({sla_age})") - _add_notification(finding, "breaching") - - _create_notifications() - logger.info("SLA run results: Pre-breach: %s, at-breach: %s, post-breach: %s, post-breach-no-notify: %s, with-jira: %s, TOTAL: %s", pre_breach_count, at_breach_count, post_breach_count, post_breach_no_notify_count, jira_count, total_count) - - except System_Settings.DoesNotExist: - logger.info("Findings SLA is not enabled.") +from dojo.notifications.helper import sla_compute_and_notify # noqa: E402, F401 -- backward compat def get_words_for_field(model, fieldname): diff --git a/unittests/test_notifications.py b/unittests/test_notifications.py index f5f561021de..5b8b587a3c2 100644 --- a/unittests/test_notifications.py +++ b/unittests/test_notifications.py @@ -875,7 +875,7 @@ def test_webhook_status_cleanup(self): wh.note = "Response status code: 503" wh.save() - with self.assertLogs("dojo.notifications.helper", level="DEBUG") as cm: + with self.assertLogs("dojo.notifications", level="DEBUG") as cm: webhook_status_cleanup() updated_wh = Notification_Webhooks.objects.filter(owner=None).first() @@ -911,7 +911,7 @@ def test_webhook_status_cleanup(self): wh.note = "Response status code: 503" wh.save() - with self.assertLogs("dojo.notifications.helper", level="DEBUG") as cm: + with self.assertLogs("dojo.notifications", level="DEBUG") as cm: webhook_status_cleanup() updated_wh = Notification_Webhooks.objects.filter(owner=None).first() diff --git a/unittests/test_rest_framework.py b/unittests/test_rest_framework.py index 2fcb52feed7..ddaa59a4549 100644 --- a/unittests/test_rest_framework.py +++ b/unittests/test_rest_framework.py @@ -60,8 +60,6 @@ LanguageViewSet, NotesViewSet, NoteTypeViewSet, - NotificationsViewSet, - NotificationWebhooksViewSet, ProductAPIScanConfigurationViewSet, ProductGroupViewSet, ProductMemberViewSet, @@ -149,6 +147,7 @@ User, UserContactInfo, ) +from dojo.notifications.api.views import NotificationsViewSet, NotificationWebhooksViewSet from dojo.organization.api.views import ( OrganizationGroupViewSet, OrganizationMemberViewSet, diff --git a/unittests/test_utils.py b/unittests/test_utils.py index 98aa3d0589a..5a27e0e2029 100644 --- a/unittests/test_utils.py +++ b/unittests/test_utils.py @@ -25,6 +25,7 @@ Test_Import, Test_Import_Finding_Action, ) +from dojo.notifications.signals import create_default_notifications from dojo.utils import dojo_crypto_encrypt, prepare_for_view, user_post_save from .dojo_test_case import DojoTestCase @@ -57,74 +58,37 @@ def test_encryption(self): test_output = prepare_for_view(encrypt) self.assertEqual(test_input, test_output) - @patch("dojo.models.System_Settings.objects") - @patch("dojo.utils.Dojo_Group_Member") - @patch("dojo.utils.Notifications") - def test_user_post_save_without_template(self, mock_notifications, mock_member, mock_settings): + @patch("dojo.notifications.signals.Notifications") + def test_create_default_notifications_without_template(self, mock_notifications): user = Dojo_User() user.id = 1 - group = Dojo_Group() - group.id = 1 - - role = Role.objects.get(id=Roles.Reader) - - system_settings_group = System_Settings() - system_settings_group.default_group = group - system_settings_group.default_group_role = role - - mock_settings.get.return_value = system_settings_group - save_mock_member = Mock(return_value=Dojo_Group_Member()) - mock_member.return_value = save_mock_member - save_mock_notifications = Mock(return_value=Notifications()) mock_notifications.return_value = save_mock_notifications - mock_notifications.objects.get.side_effect = Exception("Mock no templates") + mock_notifications.DoesNotExist = Notifications.DoesNotExist + mock_notifications.objects.get.side_effect = Notifications.DoesNotExist - user_post_save(None, user, created=True) - - mock_member.assert_called_with(group=group, user=user, role=role) - save_mock_member.save.assert_called_once() + create_default_notifications(None, user, created=True) mock_notifications.assert_called_with(user=user) save_mock_notifications.save.assert_called_once() - @patch("dojo.models.System_Settings.objects") - @patch("dojo.utils.Dojo_Group_Member") - @patch("dojo.utils.Notifications") - def test_user_post_save_with_template(self, mock_notifications, mock_member, mock_settings): + @patch("dojo.notifications.signals.Notifications") + def test_create_default_notifications_with_template(self, mock_notifications): user = Dojo_User() user.id = 1 - group = Dojo_Group() - group.id = 1 - template = Mock(Notifications(template=False, user=user)) - - role = Role.objects.get(id=Roles.Reader) - - system_settings_group = System_Settings() - system_settings_group.default_group = group - system_settings_group.default_group_role = role - - mock_settings.get.return_value = system_settings_group - save_mock_member = Mock(return_value=Dojo_Group_Member()) - mock_member.return_value = save_mock_member - mock_notifications.objects.get.return_value = template - user_post_save(None, user, created=True) - - mock_member.assert_called_with(group=group, user=user, role=role) - save_mock_member.save.assert_called_once() + create_default_notifications(None, user, created=True) mock_notifications.objects.get.assert_called_with(template=True) template.save.assert_called_once() @patch("dojo.models.System_Settings.objects") @patch("dojo.utils.Dojo_Group_Member") - @patch("dojo.utils.Notifications") - def test_user_post_save_email_pattern_matches(self, mock_notifications, mock_member, mock_settings): + def test_user_post_save_email_pattern_matches(self, mock_member, mock_settings): user = Dojo_User() user.id = 1 user.email = "john.doe@example.com" @@ -142,9 +106,6 @@ def test_user_post_save_email_pattern_matches(self, mock_notifications, mock_mem mock_settings.get.return_value = system_settings_group save_mock_member = Mock(return_value=Dojo_Group_Member()) mock_member.return_value = save_mock_member - save_mock_notifications = Mock(return_value=Notifications()) - mock_notifications.return_value = save_mock_notifications - mock_notifications.objects.get.side_effect = Exception("Mock no templates") user_post_save(None, user, created=True) @@ -153,8 +114,7 @@ def test_user_post_save_email_pattern_matches(self, mock_notifications, mock_mem @patch("dojo.models.System_Settings.objects") @patch("dojo.utils.Dojo_Group_Member") - @patch("dojo.utils.Notifications") - def test_user_post_save_email_pattern_does_not_match(self, mock_notifications, mock_member, mock_settings): + def test_user_post_save_email_pattern_does_not_match(self, mock_member, mock_settings): user = Dojo_User() user.id = 1 user.email = "john.doe@partner.example.com" @@ -168,9 +128,6 @@ def test_user_post_save_email_pattern_does_not_match(self, mock_notifications, m system_settings_group.default_group = group system_settings_group.default_group_role = role system_settings_group.default_group_email_pattern = ".*@example.com" - save_mock_notifications = Mock(return_value=Notifications()) - mock_notifications.return_value = save_mock_notifications - mock_notifications.objects.get.side_effect = Exception("Mock no templates") mock_settings.get.return_value = system_settings_group save_mock_member = Mock(return_value=Dojo_Group_Member())