diff --git a/.github/workflows/rest-framework-tests.yml b/.github/workflows/rest-framework-tests.yml index 907ecf92968..f153a368ba9 100644 --- a/.github/workflows/rest-framework-tests.yml +++ b/.github/workflows/rest-framework-tests.yml @@ -34,8 +34,8 @@ jobs: run: docker/setEnv.sh unit_tests_cicd # phased startup so we can use the exit code from unit test container - - name: Start Postgres - run: docker compose up -d postgres + - name: Start Postgres and webhook.endpoint + run: docker compose up -d postgres webhook.endpoint # no celery or initializer needed for unit tests - name: Unit tests diff --git a/docker-compose.override.dev.yml b/docker-compose.override.dev.yml index f3a281af061..c2beee1ec21 100644 --- a/docker-compose.override.dev.yml +++ b/docker-compose.override.dev.yml @@ -53,3 +53,5 @@ services: published: 8025 protocol: tcp mode: host + "webhook.endpoint": + image: mccutchen/go-httpbin:v2.14.1@sha256:2612f203b1be154524b0dbb65212a158776f0643e2f6e1dad45984fc93413d97 diff --git a/docker-compose.override.unit_tests.yml b/docker-compose.override.unit_tests.yml index 164d7a87084..7cf73e8f1f7 100644 --- a/docker-compose.override.unit_tests.yml +++ b/docker-compose.override.unit_tests.yml @@ -51,6 +51,8 @@ services: redis: image: busybox:1.36.1-musl entrypoint: ['echo', 'skipping', 'redis'] + "webhook.endpoint": + image: mccutchen/go-httpbin:v2.14.1@sha256:2612f203b1be154524b0dbb65212a158776f0643e2f6e1dad45984fc93413d97 volumes: defectdojo_postgres_unit_tests: {} defectdojo_media_unit_tests: {} diff --git a/docker-compose.override.unit_tests_cicd.yml b/docker-compose.override.unit_tests_cicd.yml index b39f4cf034d..c0448794d51 100644 --- a/docker-compose.override.unit_tests_cicd.yml +++ b/docker-compose.override.unit_tests_cicd.yml @@ -50,6 +50,8 @@ services: redis: image: busybox:1.36.1-musl entrypoint: ['echo', 'skipping', 'redis'] + "webhook.endpoint": + image: mccutchen/go-httpbin:v2.14.1@sha256:2612f203b1be154524b0dbb65212a158776f0643e2f6e1dad45984fc93413d97 volumes: defectdojo_postgres_unit_tests: {} defectdojo_media_unit_tests: {} diff --git a/docs/content/en/integrations/burp-plugin.md b/docs/content/en/integrations/burp-plugin.md index 400b37c0f2a..ab3285ceda4 100644 --- a/docs/content/en/integrations/burp-plugin.md +++ b/docs/content/en/integrations/burp-plugin.md @@ -2,7 +2,7 @@ title: "Defect Dojo Burp plugin" description: "Export findings directly from Burp to DefectDojo." draft: false -weight: 8 +weight: 9 --- **Please note: The DefectDojo Burp Plugin has been sunset and is no longer a supported feature.** diff --git a/docs/content/en/integrations/exporting.md b/docs/content/en/integrations/exporting.md index da17df7d93b..7a42d27b17e 100644 --- a/docs/content/en/integrations/exporting.md +++ b/docs/content/en/integrations/exporting.md @@ -2,7 +2,7 @@ title: "Exporting" description: "DefectDojo has the ability to export findings." draft: false -weight: 11 +weight: 12 --- diff --git a/docs/content/en/integrations/google-sheets-sync.md b/docs/content/en/integrations/google-sheets-sync.md index b6e97f72f84..456a694fc6e 100644 --- a/docs/content/en/integrations/google-sheets-sync.md +++ b/docs/content/en/integrations/google-sheets-sync.md @@ -2,7 +2,7 @@ title: "Google Sheets synchronisation" description: "Export finding details to Google Sheets and upload changes from Google Sheets." draft: false -weight: 7 +weight: 8 --- **Please note - the Google Sheets feature has been deprecated as of DefectDojo version 2.21.0 - these documents are for reference only.** diff --git a/docs/content/en/integrations/languages.md b/docs/content/en/integrations/languages.md index 17a322c8f90..a78ed137e69 100644 --- a/docs/content/en/integrations/languages.md +++ b/docs/content/en/integrations/languages.md @@ -2,7 +2,7 @@ title: "Languages and lines of code" description: "You can import an analysis of languages used in a project, including lines of code." draft: false -weight: 9 +weight: 10 --- ## Import of languages for a project diff --git a/docs/content/en/integrations/notification_webhooks/_index.md b/docs/content/en/integrations/notification_webhooks/_index.md new file mode 100644 index 00000000000..d8fe606cffa --- /dev/null +++ b/docs/content/en/integrations/notification_webhooks/_index.md @@ -0,0 +1,79 @@ +--- +title: "Notification Webhooks (experimental)" +description: "How to setup and use webhooks" +weight: 7 +chapter: true +--- + +Webhooks are HTTP requests coming from the DefectDojo instance towards user-defined webserver which expects this kind of incoming traffic. + +## Transition graph: + +It is not unusual that in some cases webhook can not be performed. It is usually connected to network issues, server misconfiguration, or running upgrades on the server. DefectDojo needs to react to these outages. It might temporarily or permanently disable related endpoints. The following graph shows how it might change the status of the webhook definition based on HTTP responses (or manual user interaction). + +```mermaid +flowchart TD + + START{{Endpoint created}} + ALL{All states} + STATUS_ACTIVE([STATUS_ACTIVE]) + STATUS_INACTIVE_TMP + STATUS_INACTIVE_PERMANENT + STATUS_ACTIVE_TMP([STATUS_ACTIVE_TMP]) + END{{Endpoint removed}} + + START ==> STATUS_ACTIVE + STATUS_ACTIVE --HTTP 200 or 201 --> STATUS_ACTIVE + STATUS_ACTIVE --HTTP 5xx
or HTTP 429
or Timeout--> STATUS_INACTIVE_TMP + STATUS_ACTIVE --Any HTTP 4xx response
or any other HTTP response
or non-HTTP error--> STATUS_INACTIVE_PERMANENT + STATUS_INACTIVE_TMP -.After 60s.-> STATUS_ACTIVE_TMP + STATUS_ACTIVE_TMP --HTTP 5xx
or HTTP 429
or Timeout
within 24h
from the first error-->STATUS_INACTIVE_TMP + STATUS_ACTIVE_TMP -.After 24h.-> STATUS_ACTIVE + STATUS_ACTIVE_TMP --HTTP 200 or 201 --> STATUS_ACTIVE_TMP + STATUS_ACTIVE_TMP --HTTP 5xx
or HTTP 429
or Timeout
within 24h from the first error
or any other HTTP response or error--> STATUS_INACTIVE_PERMANENT + ALL ==Activation by user==> STATUS_ACTIVE + ALL ==Deactivation by user==> STATUS_INACTIVE_PERMANENT + ALL ==Removal of endpoint by user==> END +``` + +Notes: + +1. Transitions: + - bold: manual changes by user + - dotted: automated by celery + - others: based on responses on webhooks +1. Nodes: + - Stadium-shaped: Active - following webhook can be sent + - Rectangles: Inactive - performing of webhook will fail (and not retried) + - Hexagonal: Initial and final states + - Rhombus: All states (meta node to make the graph more readable) + +## Body and Headers + +The body of each request is JSON which contains data about related events like names and IDs of affected elements. +Examples of bodies are on pages related to each event (see below). + +Each request contains the following headers. They might be useful for better handling of events by server this process events. + +```yaml +User-Agent: DefectDojo- +X-DefectDojo-Event: +X-DefectDojo-Instance: +``` +## Disclaimer + +This functionality is new and in experimental mode. This means Functionality might generate breaking changes in following DefectDojo releases and might not be considered final. + +However, the community is open to feedback to make this functionality better and transform it stable as soon as possible. + +## Roadmap + +There are a couple of known issues that are expected to be implemented as soon as core functionality is considered ready. + +- Support events - Not only adding products, product types, engagements, tests, or upload of new scans but also events around SLA +- User webhook - right now only admins can define webhooks; in the future also users will be able to define their own +- Improvement in UI - add filtering and pagination of webhook endpoints + +## Events + + \ No newline at end of file diff --git a/docs/content/en/integrations/notification_webhooks/engagement_added.md b/docs/content/en/integrations/notification_webhooks/engagement_added.md new file mode 100644 index 00000000000..64fd7746ec2 --- /dev/null +++ b/docs/content/en/integrations/notification_webhooks/engagement_added.md @@ -0,0 +1,38 @@ +--- +title: "Event: engagement_added" +weight: 3 +chapter: true +--- + +## Event HTTP header +```yaml +X-DefectDojo-Event: engagement_added +``` + +## Event HTTP body +```json +{ + "description": null, + "engagement": { + "id": 7, + "name": "notif eng", + "url_api": "http://localhost:8080/api/v2/engagements/7/", + "url_ui": "http://localhost:8080/engagement/7" + }, + "product": { + "id": 4, + "name": "notif prod", + "url_api": "http://localhost:8080/api/v2/products/4/", + "url_ui": "http://localhost:8080/product/4" + }, + "product_type": { + "id": 4, + "name": "notif prod type", + "url_api": "http://localhost:8080/api/v2/product_types/4/", + "url_ui": "http://localhost:8080/product/type/4" + }, + "url_api": "http://localhost:8080/api/v2/engagements/7/", + "url_ui": "http://localhost:8080/engagement/7", + "user": null +} +``` \ No newline at end of file diff --git a/docs/content/en/integrations/notification_webhooks/product_added.md b/docs/content/en/integrations/notification_webhooks/product_added.md new file mode 100644 index 00000000000..2d90a6a681f --- /dev/null +++ b/docs/content/en/integrations/notification_webhooks/product_added.md @@ -0,0 +1,32 @@ +--- +title: "Event: product_added" +weight: 2 +chapter: true +--- + +## Event HTTP header +```yaml +X-DefectDojo-Event: product_added +``` + +## Event HTTP body +```json +{ + "description": null, + "product": { + "id": 4, + "name": "notif prod", + "url_api": "http://localhost:8080/api/v2/products/4/", + "url_ui": "http://localhost:8080/product/4" + }, + "product_type": { + "id": 4, + "name": "notif prod type", + "url_api": "http://localhost:8080/api/v2/product_types/4/", + "url_ui": "http://localhost:8080/product/type/4" + }, + "url_api": "http://localhost:8080/api/v2/products/4/", + "url_ui": "http://localhost:8080/product/4", + "user": null +} +``` \ No newline at end of file diff --git a/docs/content/en/integrations/notification_webhooks/product_type_added.md b/docs/content/en/integrations/notification_webhooks/product_type_added.md new file mode 100644 index 00000000000..1171f513831 --- /dev/null +++ b/docs/content/en/integrations/notification_webhooks/product_type_added.md @@ -0,0 +1,26 @@ +--- +title: "Event: product_type_added" +weight: 1 +chapter: true +--- + +## Event HTTP header +```yaml +X-DefectDojo-Event: product_type_added +``` + +## Event HTTP body +```json +{ + "description": null, + "product_type": { + "id": 4, + "name": "notif prod type", + "url_api": "http://localhost:8080/api/v2/product_types/4/", + "url_ui": "http://localhost:8080/product/type/4" + }, + "url_api": "http://localhost:8080/api/v2/product_types/4/", + "url_ui": "http://localhost:8080/product/type/4", + "user": null +} +``` \ No newline at end of file diff --git a/docs/content/en/integrations/notification_webhooks/scan_added.md b/docs/content/en/integrations/notification_webhooks/scan_added.md new file mode 100644 index 00000000000..27a40e6cab1 --- /dev/null +++ b/docs/content/en/integrations/notification_webhooks/scan_added.md @@ -0,0 +1,90 @@ +--- +title: "Event: scan_added and scan_added_empty" +weight: 5 +chapter: true +--- + +Event `scan_added_empty` describes a situation when reimport did not affect the existing test (no finding has been created or closed). + +## Event HTTP header for scan_added +```yaml +X-DefectDojo-Event: scan_added +``` + +## Event HTTP header for scan_added_empty +```yaml +X-DefectDojo-Event: scan_added_empty +``` + +## Event HTTP body +```json +{ + "description": null, + "engagement": { + "id": 7, + "name": "notif eng", + "url_api": "http://localhost:8080/api/v2/engagements/7/", + "url_ui": "http://localhost:8080/engagement/7" + }, + "finding_count": 4, + "findings": { + "mitigated": [ + { + "id": 233, + "severity": "Medium", + "title": "Mitigated Finding", + "url_api": "http://localhost:8080/api/v2/findings/233/", + "url_ui": "http://localhost:8080/finding/233" + } + ], + "new": [ + { + "id": 232, + "severity": "Critical", + "title": "New Finding", + "url_api": "http://localhost:8080/api/v2/findings/232/", + "url_ui": "http://localhost:8080/finding/232" + } + ], + "reactivated": [ + { + "id": 234, + "severity": "Low", + "title": "Reactivated Finding", + "url_api": "http://localhost:8080/api/v2/findings/234/", + "url_ui": "http://localhost:8080/finding/234" + } + ], + "untouched": [ + { + "id": 235, + "severity": "Info", + "title": "Untouched Finding", + "url_api": "http://localhost:8080/api/v2/findings/235/", + "url_ui": "http://localhost:8080/finding/235" + } + ] + }, + "product": { + "id": 4, + "name": "notif prod", + "url_api": "http://localhost:8080/api/v2/products/4/", + "url_ui": "http://localhost:8080/product/4" + }, + "product_type": { + "id": 4, + "name": "notif prod type", + "url_api": "http://localhost:8080/api/v2/product_types/4/", + "url_ui": "http://localhost:8080/product/type/4" + }, + "test": { + "id": 90, + "title": "notif test", + "url_api": "http://localhost:8080/api/v2/tests/90/", + "url_ui": "http://localhost:8080/test/90" + }, + "url_api": "http://localhost:8080/api/v2/tests/90/", + "url_ui": "http://localhost:8080/test/90", + "user": null +} +``` \ No newline at end of file diff --git a/docs/content/en/integrations/notification_webhooks/test_added.md b/docs/content/en/integrations/notification_webhooks/test_added.md new file mode 100644 index 00000000000..8614a80e0a6 --- /dev/null +++ b/docs/content/en/integrations/notification_webhooks/test_added.md @@ -0,0 +1,44 @@ +--- +title: "Event: test_added" +weight: 4 +chapter: true +--- + +## Event HTTP header +```yaml +X-DefectDojo-Event: test_added +``` + +## Event HTTP body +```json +{ + "description": null, + "engagement": { + "id": 7, + "name": "notif eng", + "url_api": "http://localhost:8080/api/v2/engagements/7/", + "url_ui": "http://localhost:8080/engagement/7" + }, + "product": { + "id": 4, + "name": "notif prod", + "url_api": "http://localhost:8080/api/v2/products/4/", + "url_ui": "http://localhost:8080/product/4" + }, + "product_type": { + "id": 4, + "name": "notif prod type", + "url_api": "http://localhost:8080/api/v2/product_types/4/", + "url_ui": "http://localhost:8080/product/type/4" + }, + "test": { + "id": 90, + "title": "notif test", + "url_api": "http://localhost:8080/api/v2/tests/90/", + "url_ui": "http://localhost:8080/test/90" + }, + "url_api": "http://localhost:8080/api/v2/tests/90/", + "url_ui": "http://localhost:8080/test/90", + "user": null +} +``` \ No newline at end of file diff --git a/docs/content/en/integrations/notifications.md b/docs/content/en/integrations/notifications.md index d5af295f0eb..803388797cd 100644 --- a/docs/content/en/integrations/notifications.md +++ b/docs/content/en/integrations/notifications.md @@ -18,6 +18,7 @@ The following notification methods currently exist: - Email - Slack - Microsoft Teams + - Webhooks - Alerts within DefectDojo (default) You can set these notifications on a global scope (if you have @@ -124,4 +125,8 @@ However, there is a specific use-case when the user decides to disable notificat The scope of this setting is customizable (see environmental variable `DD_NOTIFICATIONS_SYSTEM_LEVEL_TRUMP`). -For more information about this behavior see the [related pull request #9699](https://github.com/DefectDojo/django-DefectDojo/pull/9699/) \ No newline at end of file +For more information about this behavior see the [related pull request #9699](https://github.com/DefectDojo/django-DefectDojo/pull/9699/) + +## Webhooks (experimental) + +DefectDojo also supports webhooks that follow the same events as other notifications (you can be notified in the same situations). Details about setup are described in [related page](../notification_webhooks/). diff --git a/docs/content/en/integrations/rate_limiting.md b/docs/content/en/integrations/rate_limiting.md index 0cac784c5f5..1ea76ace5b3 100644 --- a/docs/content/en/integrations/rate_limiting.md +++ b/docs/content/en/integrations/rate_limiting.md @@ -2,7 +2,7 @@ title: "Rate Limiting" description: "Configurable rate limiting on the login page to mitigate brute force attacks" draft: false -weight: 9 +weight: 11 --- diff --git a/dojo/api_v2/serializers.py b/dojo/api_v2/serializers.py index c9a87a8362d..dc8acb40285 100644 --- a/dojo/api_v2/serializers.py +++ b/dojo/api_v2/serializers.py @@ -77,6 +77,7 @@ Note_Type, NoteHistory, Notes, + Notification_Webhooks, Notifications, Product, Product_API_Scan_Configuration, @@ -3172,3 +3173,9 @@ def create(self, validated_data): raise serializers.ValidationError(msg) else: raise + + +class NotificationWebhooksSerializer(serializers.ModelSerializer): + class Meta: + model = Notification_Webhooks + fields = "__all__" diff --git a/dojo/api_v2/views.py b/dojo/api_v2/views.py index 05d16521069..7ae9925479a 100644 --- a/dojo/api_v2/views.py +++ b/dojo/api_v2/views.py @@ -111,6 +111,7 @@ Network_Locations, Note_Type, Notes, + Notification_Webhooks, Notifications, Product, Product_API_Scan_Configuration, @@ -3332,3 +3333,13 @@ 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/db_migrations/0215_webhooks_notifications.py b/dojo/db_migrations/0215_webhooks_notifications.py new file mode 100644 index 00000000000..cc65ce43f1b --- /dev/null +++ b/dojo/db_migrations/0215_webhooks_notifications.py @@ -0,0 +1,130 @@ +# Generated by Django 5.0.8 on 2024-08-16 17:07 + +import django.db.models.deletion +import multiselectfield.db.fields +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('dojo', '0214_test_type_dynamically_generated'), + ] + + operations = [ + migrations.AddField( + model_name='system_settings', + name='enable_webhooks_notifications', + field=models.BooleanField(default=False, verbose_name='Enable Webhook notifications'), + ), + migrations.AddField( + model_name='system_settings', + name='webhooks_notifications_timeout', + field=models.IntegerField(default=10, help_text='How many seconds will DefectDojo waits for response from webhook endpoint'), + ), + migrations.AlterField( + model_name='notifications', + name='auto_close_engagement', + field=multiselectfield.db.fields.MultiSelectField(blank=True, choices=[('slack', 'slack'), ('msteams', 'msteams'), ('mail', 'mail'), ('webhooks', 'webhooks'), ('alert', 'alert')], default=('alert', 'alert'), max_length=33), + ), + migrations.AlterField( + model_name='notifications', + name='close_engagement', + field=multiselectfield.db.fields.MultiSelectField(blank=True, choices=[('slack', 'slack'), ('msteams', 'msteams'), ('mail', 'mail'), ('webhooks', 'webhooks'), ('alert', 'alert')], default=('alert', 'alert'), max_length=33), + ), + migrations.AlterField( + model_name='notifications', + name='code_review', + field=multiselectfield.db.fields.MultiSelectField(blank=True, choices=[('slack', 'slack'), ('msteams', 'msteams'), ('mail', 'mail'), ('webhooks', 'webhooks'), ('alert', 'alert')], default=('alert', 'alert'), max_length=33), + ), + migrations.AlterField( + model_name='notifications', + name='engagement_added', + field=multiselectfield.db.fields.MultiSelectField(blank=True, choices=[('slack', 'slack'), ('msteams', 'msteams'), ('mail', 'mail'), ('webhooks', 'webhooks'), ('alert', 'alert')], default=('alert', 'alert'), max_length=33), + ), + migrations.AlterField( + model_name='notifications', + name='jira_update', + field=multiselectfield.db.fields.MultiSelectField(blank=True, choices=[('slack', 'slack'), ('msteams', 'msteams'), ('mail', 'mail'), ('webhooks', 'webhooks'), ('alert', 'alert')], default=('alert', 'alert'), help_text='JIRA sync happens in the background, errors will be shown as notifications/alerts so make sure to subscribe', max_length=33, verbose_name='JIRA problems'), + ), + migrations.AlterField( + model_name='notifications', + name='other', + field=multiselectfield.db.fields.MultiSelectField(blank=True, choices=[('slack', 'slack'), ('msteams', 'msteams'), ('mail', 'mail'), ('webhooks', 'webhooks'), ('alert', 'alert')], default=('alert', 'alert'), max_length=33), + ), + migrations.AlterField( + model_name='notifications', + name='product_added', + field=multiselectfield.db.fields.MultiSelectField(blank=True, choices=[('slack', 'slack'), ('msteams', 'msteams'), ('mail', 'mail'), ('webhooks', 'webhooks'), ('alert', 'alert')], default=('alert', 'alert'), max_length=33), + ), + migrations.AlterField( + model_name='notifications', + name='product_type_added', + field=multiselectfield.db.fields.MultiSelectField(blank=True, choices=[('slack', 'slack'), ('msteams', 'msteams'), ('mail', 'mail'), ('webhooks', 'webhooks'), ('alert', 'alert')], default=('alert', 'alert'), max_length=33), + ), + migrations.AlterField( + model_name='notifications', + name='review_requested', + field=multiselectfield.db.fields.MultiSelectField(blank=True, choices=[('slack', 'slack'), ('msteams', 'msteams'), ('mail', 'mail'), ('webhooks', 'webhooks'), ('alert', 'alert')], default=('alert', 'alert'), max_length=33), + ), + migrations.AlterField( + model_name='notifications', + name='risk_acceptance_expiration', + field=multiselectfield.db.fields.MultiSelectField(blank=True, choices=[('slack', 'slack'), ('msteams', 'msteams'), ('mail', 'mail'), ('webhooks', 'webhooks'), ('alert', 'alert')], default=('alert', 'alert'), help_text='Get notified of (upcoming) Risk Acceptance expiries', max_length=33, verbose_name='Risk Acceptance Expiration'), + ), + migrations.AlterField( + model_name='notifications', + name='scan_added', + field=multiselectfield.db.fields.MultiSelectField(blank=True, choices=[('slack', 'slack'), ('msteams', 'msteams'), ('mail', 'mail'), ('webhooks', 'webhooks'), ('alert', 'alert')], default=('alert', 'alert'), help_text='Triggered whenever an (re-)import has been done that created/updated/closed findings.', max_length=33), + ), + migrations.AlterField( + model_name='notifications', + name='scan_added_empty', + field=multiselectfield.db.fields.MultiSelectField(blank=True, choices=[('slack', 'slack'), ('msteams', 'msteams'), ('mail', 'mail'), ('webhooks', 'webhooks'), ('alert', 'alert')], default=[], help_text='Triggered whenever an (re-)import has been done (even if that created/updated/closed no findings).', max_length=33), + ), + migrations.AlterField( + model_name='notifications', + name='sla_breach', + field=multiselectfield.db.fields.MultiSelectField(blank=True, choices=[('slack', 'slack'), ('msteams', 'msteams'), ('mail', 'mail'), ('webhooks', 'webhooks'), ('alert', 'alert')], default=('alert', 'alert'), help_text='Get notified of (upcoming) SLA breaches', max_length=33, verbose_name='SLA breach'), + ), + migrations.AlterField( + model_name='notifications', + name='sla_breach_combined', + field=multiselectfield.db.fields.MultiSelectField(blank=True, choices=[('slack', 'slack'), ('msteams', 'msteams'), ('mail', 'mail'), ('webhooks', 'webhooks'), ('alert', 'alert')], default=('alert', 'alert'), help_text='Get notified of (upcoming) SLA breaches (a message per project)', max_length=33, verbose_name='SLA breach (combined)'), + ), + migrations.AlterField( + model_name='notifications', + name='stale_engagement', + field=multiselectfield.db.fields.MultiSelectField(blank=True, choices=[('slack', 'slack'), ('msteams', 'msteams'), ('mail', 'mail'), ('webhooks', 'webhooks'), ('alert', 'alert')], default=('alert', 'alert'), max_length=33), + ), + migrations.AlterField( + model_name='notifications', + name='test_added', + field=multiselectfield.db.fields.MultiSelectField(blank=True, choices=[('slack', 'slack'), ('msteams', 'msteams'), ('mail', 'mail'), ('webhooks', 'webhooks'), ('alert', 'alert')], default=('alert', 'alert'), max_length=33), + ), + migrations.AlterField( + model_name='notifications', + name='upcoming_engagement', + field=multiselectfield.db.fields.MultiSelectField(blank=True, choices=[('slack', 'slack'), ('msteams', 'msteams'), ('mail', 'mail'), ('webhooks', 'webhooks'), ('alert', 'alert')], default=('alert', 'alert'), max_length=33), + ), + migrations.AlterField( + model_name='notifications', + name='user_mentioned', + field=multiselectfield.db.fields.MultiSelectField(blank=True, choices=[('slack', 'slack'), ('msteams', 'msteams'), ('mail', 'mail'), ('webhooks', 'webhooks'), ('alert', 'alert')], default=('alert', 'alert'), max_length=33), + ), + migrations.CreateModel( + name='Notification_Webhooks', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('name', models.CharField(default='', help_text='Name of the incoming webhook', max_length=100, unique=True)), + ('url', models.URLField(default='', help_text='The full URL of the incoming webhook')), + ('header_name', models.CharField(blank=True, default='', help_text='Name of the header required for interacting with Webhook endpoint', max_length=100, null=True)), + ('header_value', models.CharField(blank=True, default='', help_text='Content of the header required for interacting with Webhook endpoint', max_length=100, null=True)), + ('status', models.CharField(choices=[('active', 'Active'), ('active_tmp', 'Active but 5xx (or similar) error detected'), ('inactive_tmp', 'Temporary inactive because of 5xx (or similar) error'), ('inactive_permanent', 'Permanently inactive')], default='active', editable=False, help_text='Status of the incoming webhook', max_length=20)), + ('first_error', models.DateTimeField(blank=True, editable=False, help_text='If endpoint is active, when error happened first time', null=True)), + ('last_error', models.DateTimeField(blank=True, editable=False, help_text='If endpoint is active, when error happened last time', null=True)), + ('note', models.CharField(blank=True, default='', editable=False, help_text='Description of the latest error', max_length=1000, null=True)), + ('owner', models.ForeignKey(blank=True, help_text='Owner/receiver of notification, if empty processed as system notification', null=True, on_delete=django.db.models.deletion.CASCADE, to='dojo.dojo_user')), + ], + ), + ] diff --git a/dojo/engagement/signals.py b/dojo/engagement/signals.py index c2f09c9abbd..7b95d6fe87b 100644 --- a/dojo/engagement/signals.py +++ b/dojo/engagement/signals.py @@ -16,7 +16,7 @@ def engagement_post_save(sender, instance, created, **kwargs): if created: title = _('Engagement created for "%(product)s": %(name)s') % {"product": instance.product, "name": instance.name} create_notification(event="engagement_added", title=title, engagement=instance, product=instance.product, - url=reverse("view_engagement", args=(instance.id,))) + url=reverse("view_engagement", args=(instance.id,)), url_api=reverse("engagement-detail", args=(instance.id,))) @receiver(pre_save, sender=Engagement) diff --git a/dojo/fixtures/dojo_testdata.json b/dojo/fixtures/dojo_testdata.json index 62486cb90cf..ae550f8bf81 100644 --- a/dojo/fixtures/dojo_testdata.json +++ b/dojo/fixtures/dojo_testdata.json @@ -227,6 +227,7 @@ "url_prefix": "", "enable_slack_notifications": false, "enable_mail_notifications": false, + "enable_webhooks_notifications": true, "email_from": "no-reply@example.com", "false_positive_history": false, "msteams_url": "", @@ -2926,11 +2927,27 @@ "pk": 1, "model": "dojo.notifications", "fields": { - "product": 1, - "user": 2, - "product_type_added": [ - "slack" - ] + "product": null, + "user": null, + "template": false, + "product_type_added": "webhooks,alert", + "product_added": "webhooks,alert", + "engagement_added": "webhooks,alert", + "test_added": "webhooks,alert", + "scan_added": "webhooks,alert", + "scan_added_empty": "webhooks", + "jira_update": "alert", + "upcoming_engagement": "alert", + "stale_engagement": "alert", + "auto_close_engagement": "alert", + "close_engagement": "alert", + "user_mentioned": "alert", + "code_review": "alert", + "review_requested": "alert", + "other": "alert", + "sla_breach": "alert", + "risk_acceptance_expiration": "alert", + "sla_breach_combined": "alert" } }, { @@ -3045,5 +3062,35 @@ "dismissable": true, "style": "danger" } + }, + { + "model": "dojo.notification_webhooks", + "pk": 1, + "fields": { + "name": "My webhook endpoint", + "url": "http://webhook.endpoint:8080/post", + "header_name": "Auth", + "header_value": "Token xxx", + "status": "active", + "first_error": null, + "last_error": null, + "note": null, + "owner": null + } + }, + { + "model": "dojo.notification_webhooks", + "pk": 2, + "fields": { + "name": "My personal webhook endpoint", + "url": "http://webhook.endpoint:8080/post", + "header_name": "Auth", + "header_value": "Token secret", + "status": "active", + "first_error": null, + "last_error": null, + "note": null, + "owner": 2 + } } ] \ No newline at end of file diff --git a/dojo/forms.py b/dojo/forms.py index dde58a38b61..acf3546285b 100644 --- a/dojo/forms.py +++ b/dojo/forms.py @@ -72,6 +72,7 @@ JIRA_Project, Note_Type, Notes, + Notification_Webhooks, Notifications, Objects_Product, Product, @@ -2778,6 +2779,32 @@ class Meta: 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): diff --git a/dojo/models.py b/dojo/models.py index 5048f30427f..308db965228 100644 --- a/dojo/models.py +++ b/dojo/models.py @@ -353,6 +353,13 @@ class System_Settings(models.Model): mail_notifications_to = models.CharField(max_length=200, default="", blank=True) + enable_webhooks_notifications = \ + models.BooleanField(default=False, + verbose_name=_("Enable Webhook notifications"), + blank=False) + webhooks_notifications_timeout = models.IntegerField(default=10, + help_text=_("How many seconds will DefectDojo waits for response from webhook endpoint")) + false_positive_history = models.BooleanField( default=False, help_text=_( "(EXPERIMENTAL) DefectDojo will automatically mark the finding as a " @@ -4015,12 +4022,14 @@ def set_obj(self, obj): 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, ) @@ -4109,6 +4118,33 @@ def get_list_display(self, request): 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) description = models.CharField(max_length=2000, null=True, blank=True) @@ -4581,6 +4617,7 @@ def __str__(self): auditlog.register(Risk_Acceptance) auditlog.register(Finding_Template) auditlog.register(Cred_User, exclude_fields=["password"]) + auditlog.register(Notification_Webhooks, exclude_fields=["header_name", "header_value"]) from dojo.utils import calculate_grade, to_str_typed # noqa: E402 # there is issue due to a circular import @@ -4642,6 +4679,7 @@ def __str__(self): 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) diff --git a/dojo/notifications/helper.py b/dojo/notifications/helper.py index 5a7ccf0dc60..9acbf94d215 100644 --- a/dojo/notifications/helper.py +++ b/dojo/notifications/helper.py @@ -1,6 +1,9 @@ +import json import logging +from datetime import timedelta import requests +import yaml from django.conf import settings from django.core.exceptions import FieldDoesNotExist from django.core.mail import EmailMessage @@ -10,10 +13,19 @@ from django.urls import 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.decorators import dojo_async_task, we_want_async -from dojo.models import Alerts, Dojo_User, Notifications, System_Settings, UserContactInfo +from dojo.models import ( + Alerts, + Dojo_User, + Notification_Webhooks, + Notifications, + System_Settings, + UserContactInfo, + get_current_datetime, +) from dojo.user.queries import get_authorized_users_for_product_and_product_type, get_authorized_users_for_product_type logger = logging.getLogger(__name__) @@ -144,8 +156,9 @@ def create_notification_message(event, user, notification_type, *args, **kwargs) try: notification_message = render_to_string(template, kwargs) logger.debug("Rendering from the template %s", template) - except TemplateDoesNotExist: - logger.debug("template not found or not implemented yet: %s", template) + except TemplateDoesNotExist as e: + # In some cases, template includes another templates, if the interior one is missing, we will see it in "specifically" section + logger.debug(f"template not found or not implemented yet: {template} (specifically: {e.args})") except Exception as e: logger.error("error during rendering of template %s exception is %s", template, e) finally: @@ -170,6 +183,7 @@ def process_notifications(event, notifications=None, **kwargs): slack_enabled = get_system_setting("enable_slack_notifications") msteams_enabled = get_system_setting("enable_msteams_notifications") mail_enabled = get_system_setting("enable_mail_notifications") + webhooks_enabled = get_system_setting("enable_webhooks_notifications") if slack_enabled and "slack" in getattr(notifications, event, getattr(notifications, "other")): logger.debug("Sending Slack Notification") @@ -183,6 +197,10 @@ def process_notifications(event, notifications=None, **kwargs): logger.debug("Sending Mail Notification") send_mail_notification(event, notifications.user, **kwargs) + if webhooks_enabled and "webhooks" in getattr(notifications, event, getattr(notifications, "other")): + logger.debug("Sending Webhooks Notification") + send_webhooks_notification(event, notifications.user, **kwargs) + if "alert" in getattr(notifications, event, getattr(notifications, "other")): logger.debug(f"Sending Alert to {notifications.user}") send_alert_notification(event, notifications.user, **kwargs) @@ -309,6 +327,157 @@ def send_mail_notification(event, user=None, *args, **kwargs): log_alert(e, "Email Notification", title=kwargs["title"], description=str(e), url=kwargs["url"]) +def webhooks_notification_request(endpoint, event, *args, **kwargs): + from dojo.utils import get_system_setting + + headers = { + "User-Agent": f"DefectDojo-{dd_version}", + "X-DefectDojo-Event": event, + "X-DefectDojo-Instance": settings.SITE_URL, + "Accept": "application/json", + } + if endpoint.header_name is not None: + headers[endpoint.header_name] = endpoint.header_value + yaml_data = create_notification_message(event, endpoint.owner, "webhooks", *args, **kwargs) + data = yaml.safe_load(yaml_data) + + timeout = get_system_setting("webhooks_notifications_timeout") + + res = requests.request( + method="POST", + url=endpoint.url, + headers=headers, + json=data, + timeout=timeout, + ) + return res + + +def test_webhooks_notification(endpoint): + res = webhooks_notification_request(endpoint, "ping", description="Test webhook notification") + res.raise_for_status() + # in "send_webhooks_notification", we are doing deeper analysis, why it failed + # for now, "raise_for_status" should be enough + + +@app.task(ignore_result=True) +def webhook_reactivation(endpoint_id: int, *args, **kwargs): + endpoint = Notification_Webhooks.objects.get(pk=endpoint_id) + + # User already changed status of endpoint + if endpoint.status != Notification_Webhooks.Status.STATUS_INACTIVE_TMP: + return + + endpoint.status = Notification_Webhooks.Status.STATUS_ACTIVE_TMP + endpoint.save() + logger.debug(f"Webhook endpoint '{endpoint.name}' reactivated to '{Notification_Webhooks.Status.STATUS_ACTIVE_TMP}'") + + +@app.task(ignore_result=True) +def webhook_status_cleanup(*args, **kwargs): + # 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: + webhook_reactivation(endpoint_id=endpoint.pk) + + +@dojo_async_task +@app.task +def send_webhooks_notification(event, user=None, *args, **kwargs): + + ERROR_PERMANENT = "permanent" + ERROR_TEMPORARY = "temporary" + + endpoints = Notification_Webhooks.objects.filter(owner=user) + + if not endpoints: + if user: + logger.info(f"URLs for Webhooks not configured for user '{user}': skipping user notification") + else: + logger.info("URLs for Webhooks not configured: skipping system notification") + return + + for endpoint in endpoints: + + error = None + if endpoint.status not in [Notification_Webhooks.Status.STATUS_ACTIVE, Notification_Webhooks.Status.STATUS_ACTIVE_TMP]: + logger.info(f"URL for Webhook '{endpoint.name}' is not active: {endpoint.get_status_display()} ({endpoint.status})") + continue + + try: + logger.debug(f"Sending webhook message to endpoint '{endpoint.name}'") + res = webhooks_notification_request(endpoint, event, *args, **kwargs) + + if 200 <= res.status_code < 300: + logger.debug(f"Message sent to endpoint '{endpoint.name}' successfully.") + continue + + # HTTP request passed successfully but we still need to check status code + if 500 <= res.status_code < 600 or res.status_code == 429: + error = ERROR_TEMPORARY + else: + error = ERROR_PERMANENT + + endpoint.note = f"Response status code: {res.status_code}" + logger.error(f"Error when sending message to Webhooks '{endpoint.name}' (status: {res.status_code}): {res.text}") + + except requests.exceptions.Timeout as e: + error = ERROR_TEMPORARY + endpoint.note = f"Requests exception: {e}" + logger.error(f"Timeout when sending message to Webhook '{endpoint.name}'") + + except Exception as e: + error = ERROR_PERMANENT + endpoint.note = f"Exception: {e}"[:1000] + logger.exception(e) + log_alert(e, "Webhooks Notification") + + now = get_current_datetime() + + if error == ERROR_TEMPORARY: + + # If endpoint is unstable for more then one day, it needs to be deactivated + if endpoint.first_error is not None and (now - endpoint.first_error).total_seconds() > 60 * 60 * 24: + endpoint.status = Notification_Webhooks.Status.STATUS_INACTIVE_PERMANENT + + else: + # We need to monitor when outage started + if endpoint.status == Notification_Webhooks.Status.STATUS_ACTIVE: + endpoint.first_error = now + + endpoint.status = Notification_Webhooks.Status.STATUS_INACTIVE_TMP + + # In case of failure within one day, endpoint can be deactivated temporally only for one minute + webhook_reactivation.apply_async(kwargs={"endpoint_id": endpoint.pk}, countdown=60) + + # There is no reason to keep endpoint active if it is returning 4xx errors + else: + endpoint.status = Notification_Webhooks.Status.STATUS_INACTIVE_PERMANENT + endpoint.first_error = now + + endpoint.last_error = now + endpoint.save() + + def send_alert_notification(event, user=None, *args, **kwargs): logger.debug("sending alert notification to %s", user) try: @@ -335,7 +504,6 @@ def send_alert_notification(event, user=None, *args, **kwargs): def get_slack_user_id(user_email): - import json from dojo.utils import get_system_setting @@ -390,7 +558,7 @@ def log_alert(e, notification_type=None, *args, **kwargs): def notify_test_created(test): title = "Test created for " + str(test.engagement.product) + ": " + str(test.engagement.name) + ": " + str(test) create_notification(event="test_added", title=title, test=test, engagement=test.engagement, product=test.engagement.product, - url=reverse("view_test", args=(test.id,))) + url=reverse("view_test", args=(test.id,)), url_api=reverse("test-detail", args=(test.id,))) def notify_scan_added(test, updated_count, new_findings=[], findings_mitigated=[], findings_reactivated=[], findings_untouched=[]): @@ -410,4 +578,4 @@ def notify_scan_added(test, updated_count, new_findings=[], findings_mitigated=[ create_notification(event=event, title=title, findings_new=new_findings, findings_mitigated=findings_mitigated, findings_reactivated=findings_reactivated, finding_count=updated_count, test=test, engagement=test.engagement, product=test.engagement.product, findings_untouched=findings_untouched, - url=reverse("view_test", args=(test.id,))) + url=reverse("view_test", args=(test.id,)), url_api=reverse("test-detail", args=(test.id,))) diff --git a/dojo/notifications/urls.py b/dojo/notifications/urls.py index dc91f7a04e2..6f4cba7bb64 100644 --- a/dojo/notifications/urls.py +++ b/dojo/notifications/urls.py @@ -7,4 +7,8 @@ re_path(r"^notifications/system$", views.SystemNotificationsView.as_view(), name="system_notifications"), re_path(r"^notifications/personal$", views.PersonalNotificationsView.as_view(), name="personal_notifications"), re_path(r"^notifications/template$", views.TemplateNotificationsView.as_view(), name="template_notifications"), + re_path(r"^notifications/webhooks$", views.ListNotificationWebhooksView.as_view(), name="notification_webhooks"), + re_path(r"^notifications/webhooks/add$", views.AddNotificationWebhooksView.as_view(), name="add_notification_webhook"), + re_path(r"^notifications/webhooks/(?P\d+)/edit$", views.EditNotificationWebhooksView.as_view(), name="edit_notification_webhook"), + re_path(r"^notifications/webhooks/(?P\d+)/delete$", views.DeleteNotificationWebhooksView.as_view(), name="delete_notification_webhook"), ] diff --git a/dojo/notifications/views.py b/dojo/notifications/views.py index 8a94d2ad7c5..6a2495330d7 100644 --- a/dojo/notifications/views.py +++ b/dojo/notifications/views.py @@ -1,15 +1,18 @@ import logging +import requests from django.contrib import messages from django.core.exceptions import PermissionDenied -from django.http import HttpRequest -from django.shortcuts import render +from django.http import Http404, HttpRequest, HttpResponseRedirect +from django.shortcuts import get_object_or_404, render +from django.urls import reverse from django.utils.translation import gettext as _ from django.views import View -from dojo.forms import NotificationsForm -from dojo.models import Notifications -from dojo.utils import add_breadcrumb, get_enabled_notifications_list +from dojo.forms import DeleteNotificationsWebhookForm, NotificationsForm, NotificationsWebhookForm +from dojo.models import Notification_Webhooks, Notifications +from dojo.notifications.helper import test_webhooks_notification +from dojo.utils import add_breadcrumb, get_enabled_notifications_list, get_system_setting logger = logging.getLogger(__name__) @@ -129,3 +132,295 @@ def get_scope(self): def set_breadcrumbs(self, request: HttpRequest): add_breadcrumb(title=_("Template notification settings"), top_level=False, request=request) return request + + +class NotificationWebhooksView(View): + + def check_webhooks_enabled(self): + if not get_system_setting("enable_webhooks_notifications"): + raise Http404 + + def check_user_permissions(self, request: HttpRequest): + if not request.user.is_superuser: + raise PermissionDenied + # TODO: finished access for other users + # if not user_has_configuration_permission(request.user, self.permission): + # raise PermissionDenied() + + def set_breadcrumbs(self, request: HttpRequest): + add_breadcrumb(title=self.breadcrumb, top_level=False, request=request) + return request + + def get_form( + self, + request: HttpRequest, + **kwargs: dict, + ) -> NotificationsWebhookForm: + if request.method == "POST": + return NotificationsWebhookForm(request.POST, is_superuser=request.user.is_superuser, **kwargs) + else: + return NotificationsWebhookForm(is_superuser=request.user.is_superuser, **kwargs) + + def preprocess_request(self, request: HttpRequest): + # Check Webhook notifications are enabled + self.check_webhooks_enabled() + # Check permissions + self.check_user_permissions(request) + + +class ListNotificationWebhooksView(NotificationWebhooksView): + template = "dojo/view_notification_webhooks.html" + permission = "dojo.view_notification_webhooks" + breadcrumb = "Notification Webhook List" + + def get_initial_context(self, request: HttpRequest, nwhs: Notification_Webhooks): + return { + "name": "Notification Webhook List", + "metric": False, + "user": request.user, + "nwhs": nwhs, + } + + def get_notification_webhooks(self, request: HttpRequest): + nwhs = Notification_Webhooks.objects.all().order_by("name") + # TODO: finished pagination + # TODO: restrict based on user - not only superadmins have access and they see everything + return nwhs + + def get(self, request: HttpRequest): + # Run common checks + super().preprocess_request(request) + # Get Notification Webhooks + nwhs = self.get_notification_webhooks(request) + # Set up the initial context + context = self.get_initial_context(request, nwhs) + # Add any breadcrumbs + request = self.set_breadcrumbs(request) + # Render the page + return render(request, self.template, context) + + +class AddNotificationWebhooksView(NotificationWebhooksView): + template = "dojo/add_notification_webhook.html" + permission = "dojo.add_notification_webhooks" + breadcrumb = "Add Notification Webhook" + + # TODO: Disable Owner if not superadmin + + def get_initial_context(self, request: HttpRequest): + return { + "name": "Add Notification Webhook", + "user": request.user, + "form": self.get_form(request), + } + + def process_form(self, request: HttpRequest, context: dict): + form = context["form"] + if form.is_valid(): + try: + test_webhooks_notification(form.instance) + except requests.exceptions.RequestException as e: + messages.add_message( + request, + messages.ERROR, + _("Test of endpoint was not successful: %(error)s") % {"error": str(e)}, + extra_tags="alert-danger", + ) + return request, False + else: + # User can put here what ever he want + # we override it with our only valid defaults + nwh = form.save(commit=False) + nwh.status = Notification_Webhooks.Status.STATUS_ACTIVE + nwh.first_error = None + nwh.last_error = None + nwh.note = None + nwh.save() + messages.add_message( + request, + messages.SUCCESS, + _("Notification Webhook added successfully."), + extra_tags="alert-success", + ) + return request, True + return request, False + + def get(self, request: HttpRequest): + # Run common checks + super().preprocess_request(request) + # Set up the initial context + context = self.get_initial_context(request) + # Add any breadcrumbs + request = self.set_breadcrumbs(request) + # Render the page + return render(request, self.template, context) + + def post(self, request: HttpRequest): + # Run common checks + super().preprocess_request(request) + # Set up the initial context + context = self.get_initial_context(request) + # Determine the validity of the form + request, success = self.process_form(request, context) + if success: + return HttpResponseRedirect(reverse("notification_webhooks")) + # Add any breadcrumbs + request = self.set_breadcrumbs(request) + # Render the page + return render(request, self.template, context) + + +class EditNotificationWebhooksView(NotificationWebhooksView): + template = "dojo/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" + + def get_notification_webhook(self, nwhid: int): + return get_object_or_404(Notification_Webhooks, id=nwhid) + + # TODO: Disable Owner if not superadmin + + def get_initial_context(self, request: HttpRequest, nwh: Notification_Webhooks): + return { + "name": "Edit Notification Webhook", + "user": request.user, + "form": self.get_form(request, instance=nwh), + "nwh": nwh, + } + + def process_form(self, request: HttpRequest, nwh: Notification_Webhooks, context: dict): + form = context["form"] + if "deactivate_webhook" in request.POST: # TODO: add this to API as well + nwh.status = Notification_Webhooks.Status.STATUS_INACTIVE_PERMANENT + nwh.first_error = None + nwh.last_error = None + nwh.note = "Deactivate from UI" + nwh.save() + messages.add_message( + request, + messages.SUCCESS, + _("Notification Webhook deactivated successfully."), + extra_tags="alert-success", + ) + return request, True + + if form.is_valid(): + try: + test_webhooks_notification(form.instance) + except requests.exceptions.RequestException as e: + messages.add_message( + request, + messages.ERROR, + _("Test of endpoint was not successful: %(error)s") % {"error": str(e)}, + extra_tags="alert-danger") + return request, False + else: + # correct definition reset defaults + nwh = form.save(commit=False) + nwh.status = Notification_Webhooks.Status.STATUS_ACTIVE + nwh.first_error = None + nwh.last_error = None + nwh.note = None + nwh.save() + messages.add_message( + request, + messages.SUCCESS, + _("Notification Webhook updated successfully."), + extra_tags="alert-success", + ) + return request, True + return request, False + + def get(self, request: HttpRequest, nwhid: int): + # Run common checks + super().preprocess_request(request) + nwh = self.get_notification_webhook(nwhid) + # Set up the initial context + context = self.get_initial_context(request, nwh) + # Add any breadcrumbs + request = self.set_breadcrumbs(request) + # Render the page + return render(request, self.template, context) + + def post(self, request: HttpRequest, nwhid: int): + # Run common checks + super().preprocess_request(request) + nwh = self.get_notification_webhook(nwhid) + # Set up the initial context + context = self.get_initial_context(request, nwh) + # Determine the validity of the form + request, success = self.process_form(request, nwh, context) + if success: + return HttpResponseRedirect(reverse("notification_webhooks")) + # Add any breadcrumbs + request = self.set_breadcrumbs(request) + # Render the page + return render(request, self.template, context) + + +class DeleteNotificationWebhooksView(NotificationWebhooksView): + template = "dojo/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" + + def get_notification_webhook(self, nwhid: int): + return get_object_or_404(Notification_Webhooks, id=nwhid) + + # TODO: Disable Owner if not superadmin + + def get_form( + self, + request: HttpRequest, + **kwargs: dict, + ) -> NotificationsWebhookForm: + if request.method == "POST": + return DeleteNotificationsWebhookForm(request.POST, **kwargs) + else: + return DeleteNotificationsWebhookForm(**kwargs) + + def get_initial_context(self, request: HttpRequest, nwh: Notification_Webhooks): + return { + "form": self.get_form(request, instance=nwh), + "nwh": nwh, + } + + def process_form(self, request: HttpRequest, nwh: Notification_Webhooks, context: dict): + form = context["form"] + if form.is_valid(): + nwh.delete() + messages.add_message( + request, + messages.SUCCESS, + _("Notification Webhook deleted successfully."), + extra_tags="alert-success", + ) + return request, True + return request, False + + def get(self, request: HttpRequest, nwhid: int): + # Run common checks + super().preprocess_request(request) + nwh = self.get_notification_webhook(nwhid) + # Set up the initial context + context = self.get_initial_context(request, nwh) + # Add any breadcrumbs + request = self.set_breadcrumbs(request) + # Render the page + return render(request, self.template, context) + + def post(self, request: HttpRequest, nwhid: int): + # Run common checks + super().preprocess_request(request) + nwh = self.get_notification_webhook(nwhid) + # Set up the initial context + context = self.get_initial_context(request, nwh) + # Determine the validity of the form + request, success = self.process_form(request, nwh, context) + if success: + return HttpResponseRedirect(reverse("notification_webhooks")) + # Add any breadcrumbs + request = self.set_breadcrumbs(request) + # Render the page + return render(request, self.template, context) diff --git a/dojo/product/signals.py b/dojo/product/signals.py index 6871f5490d2..72e9771e82c 100644 --- a/dojo/product/signals.py +++ b/dojo/product/signals.py @@ -16,7 +16,9 @@ def product_post_save(sender, instance, created, **kwargs): create_notification(event="product_added", title=instance.name, product=instance, - url=reverse("view_product", args=(instance.id,))) + url=reverse("view_product", args=(instance.id,)), + url_api=reverse("product-detail", args=(instance.id,)), + ) @receiver(post_delete, sender=Product) diff --git a/dojo/product_type/signals.py b/dojo/product_type/signals.py index dde3ff502cd..743995768eb 100644 --- a/dojo/product_type/signals.py +++ b/dojo/product_type/signals.py @@ -16,7 +16,9 @@ def product_type_post_save(sender, instance, created, **kwargs): create_notification(event="product_type_added", title=instance.name, product_type=instance, - url=reverse("view_product_type", args=(instance.id,))) + url=reverse("view_product_type", args=(instance.id,)), + url_api=reverse("product_type-detail", args=(instance.id,)), + ) @receiver(post_delete, sender=Product_Type) diff --git a/dojo/settings/.settings.dist.py.sha256sum b/dojo/settings/.settings.dist.py.sha256sum index 878a104af54..4686d63afe2 100644 --- a/dojo/settings/.settings.dist.py.sha256sum +++ b/dojo/settings/.settings.dist.py.sha256sum @@ -1 +1 @@ -5adedc433a342d675492b86dc18786f72e167115f9718a397dc9b91c5fdc9c94 +8cd4668bdc4dec192dd5bd3fd767b87a4f6d5441ae8d4a001d2ba61c452e59aa diff --git a/dojo/settings/settings.dist.py b/dojo/settings/settings.dist.py index ebf0283dd6a..3a01d935431 100644 --- a/dojo/settings/settings.dist.py +++ b/dojo/settings/settings.dist.py @@ -1142,6 +1142,10 @@ def saml2_attrib_map_format(dict): "task": "dojo.risk_acceptance.helper.expiration_handler", "schedule": crontab(minute=0, hour="*/3"), # every 3 hours }, + "notification_webhook_status_cleanup": { + "task": "dojo.notifications.helper.webhook_status_cleanup", + "schedule": timedelta(minutes=1), + }, # 'jira_status_reconciliation': { # 'task': 'dojo.tasks.jira_status_reconciliation_task', # 'schedule': timedelta(hours=12), @@ -1152,7 +1156,6 @@ def saml2_attrib_map_format(dict): # 'schedule': timedelta(hours=12) # }, - } # ------------------------------------ diff --git a/dojo/templates/base.html b/dojo/templates/base.html index 765ec10dc55..722656ae6a9 100644 --- a/dojo/templates/base.html +++ b/dojo/templates/base.html @@ -541,6 +541,13 @@ {% trans "Notifications" %} + {% if system_settings.enable_webhooks_notifications and "dojo.view_notification_webhooks"|has_configuration_permission:request %} +
  • + + {% trans "Notification Webhooks" %} + +
  • + {% endif %}
  • {% trans "Regulations" %} diff --git a/dojo/templates/dojo/add_notification_webhook.html b/dojo/templates/dojo/add_notification_webhook.html new file mode 100644 index 00000000000..12056373af4 --- /dev/null +++ b/dojo/templates/dojo/add_notification_webhook.html @@ -0,0 +1,13 @@ +{% extends "base.html" %} +{% block content %} + {{ block.super }} +

    Add a new Notification Webhook

    +
    {% csrf_token %} + {% include "dojo/form_fields.html" with form=form %} +
    +
    + +
    +
    +
    +{% endblock %} diff --git a/dojo/templates/dojo/delete_notification_webhook.html b/dojo/templates/dojo/delete_notification_webhook.html new file mode 100644 index 00000000000..f196ad94fc9 --- /dev/null +++ b/dojo/templates/dojo/delete_notification_webhook.html @@ -0,0 +1,12 @@ +{% extends "base.html" %} +{% block content %} +

    Delete Notification Webhook

    +
    {% csrf_token %} + {% include "dojo/form_fields.html" with form=form %} +
    +
    + +
    +
    +
    +{% endblock %} diff --git a/dojo/templates/dojo/edit_notification_webhook.html b/dojo/templates/dojo/edit_notification_webhook.html new file mode 100644 index 00000000000..94bd56c2307 --- /dev/null +++ b/dojo/templates/dojo/edit_notification_webhook.html @@ -0,0 +1,15 @@ +{% extends "base.html" %} + {% block content %} + {{ block.super }} +

    Edit Notification Webhook

    +
    {% csrf_token %} + {% include "dojo/form_fields.html" with form=form %} +
    +
    + + +
    +
    +
    + {% endblock %} + \ No newline at end of file diff --git a/dojo/templates/dojo/notifications.html b/dojo/templates/dojo/notifications.html index 52d87393c45..81fac49d5cc 100644 --- a/dojo/templates/dojo/notifications.html +++ b/dojo/templates/dojo/notifications.html @@ -89,6 +89,9 @@

    {% if 'mail' in enabled_notifications %} {% trans "Mail" %} {% endif %} + {% if 'webhooks' in enabled_notifications %} + {% trans "Webhooks" %} + {% endif %} {% trans "Alert" %} diff --git a/dojo/templates/dojo/system_settings.html b/dojo/templates/dojo/system_settings.html index 693abe712f0..02510452e16 100644 --- a/dojo/templates/dojo/system_settings.html +++ b/dojo/templates/dojo/system_settings.html @@ -62,7 +62,7 @@

    System Settings

    } $(function () { - $.each(['slack','msteams','mail', 'grade'], function (index, value) { + $.each(['slack','msteams','mail','webhooks','grade'], function (index, value) { updatenotificationsgroup(value); $('#id_enable_' + value + '_notifications').change(function() { updatenotificationsgroup(value)}); }); diff --git a/dojo/templates/dojo/view_notification_webhooks.html b/dojo/templates/dojo/view_notification_webhooks.html new file mode 100644 index 00000000000..6b02c0888d3 --- /dev/null +++ b/dojo/templates/dojo/view_notification_webhooks.html @@ -0,0 +1,101 @@ +{% extends "base.html" %} +{% load navigation_tags %} +{% load display_tags %} +{% load i18n %} +{% load authorization_tags %} +{% block content %} + {{ block.super }} +
    +{% endblock %} +{% block postscript %} + {{ block.super }} + {% include "dojo/filter_js_snippet.html" %} +{% endblock %} diff --git a/dojo/templates/dojo/view_product_details.html b/dojo/templates/dojo/view_product_details.html index 30dd863fc3c..076215121f5 100644 --- a/dojo/templates/dojo/view_product_details.html +++ b/dojo/templates/dojo/view_product_details.html @@ -668,7 +668,7 @@