diff --git a/dojo/api_v2/serializers.py b/dojo/api_v2/serializers.py index 2f884b3bb4a..d2157616d42 100644 --- a/dojo/api_v2/serializers.py +++ b/dojo/api_v2/serializers.py @@ -1508,15 +1508,18 @@ class Meta: class RiskAcceptanceSerializer(serializers.ModelSerializer): + product = serializers.PrimaryKeyRelatedField( + queryset=Product.objects.all(), required=True, + ) path = serializers.SerializerMethodField() def create(self, validated_data): instance = super().create(validated_data) user = getattr(self.context.get("request", None), "user", None) - ra_helper.add_findings_to_risk_acceptance(user, instance, instance.accepted_findings.all()) + ra_helper.add_findings_to_risk_acceptance(user, instance, instance.accepted_findings.all()) # TODO: check this if needed return instance - def update(self, instance, validated_data): + def update(self, instance, validated_data): # TODO: check what is happening here as well # Determine findings to risk accept, and findings to unaccept risk existing_findings = Finding.objects.filter(risk_acceptance=self.instance.id) new_findings_ids = [x.id for x in validated_data.get("accepted_findings", [])] @@ -1537,13 +1540,10 @@ def update(self, instance, validated_data): @extend_schema_field(serializers.CharField()) def get_path(self, obj): - engagement = Engagement.objects.filter( - risk_acceptance__id__in=[obj.id], - ).first() path = "No proof has been supplied" - if engagement and obj.filename() is not None: + if obj.product and obj.filename() is not None: path = reverse( - "download_risk_acceptance", args=(engagement.id, obj.id), + "download_risk_acceptance", args=(obj.id,), ) request = self.context.get("request") if request: @@ -1551,19 +1551,23 @@ def get_path(self, obj): return path @extend_schema_field(serializers.IntegerField()) - def get_engagement(self, obj): - engagement = Engagement.objects.filter( - risk_acceptance__id__in=[obj.id], - ).first() - return EngagementSerializer(read_only=True).to_representation( - engagement, + def get_product(self, obj): + return ProductSerializer(read_only=True).to_representation( + obj.product, ) def validate(self, data): - def validate_findings_have_same_engagement(finding_objects: list[Finding]): - engagements = finding_objects.values_list("test__engagement__id", flat=True).distinct().count() - if engagements > 1: - msg = "You are not permitted to add findings from multiple engagements" + super().validate(data) + + if self.context["request"].method != "POST": + if "product" in data and data["product"] != self.instance.product: + msg = f"Change of product is not possible. Current: {self.instance.product.pk}, new: {data["product"].pk}." + raise serializers.ValidationError(msg) + + def validate_findings_have_same_product(finding_objects: list[Finding]): # TODO: 'clean' might be enought? + products = finding_objects.values_list("test__engagement__product__id", flat=True).distinct().count() + if products > 1: + msg = "You are not permitted to add findings from multiple products" raise PermissionDenied(msg) findings = data.get("accepted_findings", []) @@ -1574,11 +1578,11 @@ def validate_findings_have_same_engagement(finding_objects: list[Finding]): msg = "You are not permitted to add one or more selected findings to this risk acceptance" raise PermissionDenied(msg) if self.context["request"].method == "POST": - validate_findings_have_same_engagement(finding_objects) + validate_findings_have_same_product(finding_objects) elif self.context["request"].method in {"PATCH", "PUT"}: existing_findings = Finding.objects.filter(risk_acceptance=self.instance.id) existing_and_new_findings = existing_findings | finding_objects - validate_findings_have_same_engagement(existing_and_new_findings) + validate_findings_have_same_product(existing_and_new_findings) return data class Meta: @@ -1751,6 +1755,7 @@ def process_risk_acceptance(self, data): if not isinstance(is_risk_accepted, bool): return # Determine how to proceed based on the value of `risk_accepted` + # TODO: this should be more sofisticated: better resnonses if is_risk_accepted and not self.instance.risk_accepted and self.instance.test.engagement.product.enable_simple_risk_acceptance and not data.get("active", False): ra_helper.simple_risk_accept(self.context["request"].user, self.instance) elif not is_risk_accepted and self.instance.risk_accepted: # turning off risk_accepted diff --git a/dojo/api_v2/views.py b/dojo/api_v2/views.py index bdde57955f2..3b88f807a2f 100644 --- a/dojo/api_v2/views.py +++ b/dojo/api_v2/views.py @@ -431,7 +431,7 @@ def destroy(self, request, *args, **kwargs): def get_queryset(self): return ( get_authorized_engagements(Permissions.Engagement_View) - .prefetch_related("notes", "risk_acceptance", "files") + .prefetch_related("notes", "files") .distinct() ) @@ -722,7 +722,7 @@ def get_queryset(self): return ( get_authorized_risk_acceptances(Permissions.Risk_Acceptance) .prefetch_related( - "notes", "engagement_set", "owner", "accepted_findings", + "notes", "product", "owner", "accepted_findings", ) .distinct() ) @@ -1735,7 +1735,7 @@ class ProductViewSet( ) def get_queryset(self): - return get_authorized_products(Permissions.Product_View).distinct() + return get_authorized_products(Permissions.Product_View).prefetch_related("risk_acceptances").distinct() # TODO: Test this, and is it needed? def destroy(self, request, *args, **kwargs): instance = self.get_object() diff --git a/dojo/asset/urls.py b/dojo/asset/urls.py index e248348b74b..6c640030764 100644 --- a/dojo/asset/urls.py +++ b/dojo/asset/urls.py @@ -23,6 +23,11 @@ views.view_product_components, name="view_product_components", ), + re_path( + r"^asset/(?P\d+)/risk_acceptance$", + views.view_product_risk_acceptances, + name="view_product_risk_acceptances", + ), re_path( r"^asset/(?P\d+)/engagements$", views.view_engagements, @@ -177,6 +182,7 @@ re_path(r"^product$", redirect_view("product")), re_path(r"^product/(?P\d+)$", redirect_view("view_product")), re_path(r"^product/(?P\d+)/components$", redirect_view("view_product_components")), + re_path(r"^product/(?P\d+)/risk_acceptance$", redirect_view("view_product_risk_acceptances")), re_path(r"^product/(?P\d+)/engagements$", redirect_view("view_engagements")), re_path(r"^product/(?P\d+)/import_scan_results$", redirect_view("import_scan_results_prod")), re_path(r"^product/(?P\d+)/metrics$", redirect_view("view_product_metrics")), @@ -214,6 +220,8 @@ name="view_product"), re_path(r"^product/(?P\d+)/components$", views.view_product_components, name="view_product_components"), + re_path(r"^product/(?P\d+)/risk_acceptance$", views.view_product_risk_acceptances, + name="view_product_risk_acceptances"), re_path(r"^product/(?P\d+)/engagements$", views.view_engagements, name="view_engagements"), re_path( @@ -283,6 +291,7 @@ re_path(r"^asset$", redirect_view("product")), re_path(r"^asset/(?P\d+)$", redirect_view("view_product")), re_path(r"^asset/(?P\d+)/components$", redirect_view("view_product_components")), + re_path(r"^asset/(?P\d+)/risk_acceptance$", redirect_view("view_product_risk_acceptances")), re_path(r"^asset/(?P\d+)/engagements$", redirect_view("view_engagements")), re_path(r"^asset/(?P\d+)/import_scan_results$", redirect_view("import_scan_results_prod")), re_path(r"^asset/(?P\d+)/metrics$", redirect_view("view_product_metrics")), diff --git a/dojo/db_migrations/0249_risk_acceptance_add_product_field.py b/dojo/db_migrations/0249_risk_acceptance_add_product_field.py new file mode 100644 index 00000000000..188e683f2c9 --- /dev/null +++ b/dojo/db_migrations/0249_risk_acceptance_add_product_field.py @@ -0,0 +1,50 @@ +# Generated migration - Step 1: Add product field to Risk_Acceptance + +from django.db import migrations, models +import django.db.models.deletion +import pgtrigger + + +class Migration(migrations.Migration): + + dependencies = [ + ('dojo', '0248_alter_general_survey_expiration'), + ] + + operations = [ + # Add product field (nullable initially so we can populate it) + migrations.AddField( + model_name='risk_acceptance', + name='product', + field=models.ForeignKey(editable=False, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='risk_acceptances', to='dojo.product'), + ), + migrations.AddField( + model_name='risk_acceptanceevent', + name='product', + field=models.ForeignKey(db_constraint=False, db_index=False, editable=False, on_delete=django.db.models.deletion.DO_NOTHING, related_name='+', related_query_name='+', to='dojo.product'), + ), + pgtrigger.migrations.RemoveTrigger( + model_name='risk_acceptance', + name='insert_insert', + ), + pgtrigger.migrations.RemoveTrigger( + model_name='risk_acceptance', + name='update_update', + ), + pgtrigger.migrations.RemoveTrigger( + model_name='risk_acceptance', + name='delete_delete', + ), + pgtrigger.migrations.AddTrigger( + model_name='risk_acceptance', + trigger=pgtrigger.compiler.Trigger(name='insert_insert', sql=pgtrigger.compiler.UpsertTriggerSql(func='INSERT INTO "dojo_risk_acceptanceevent" ("accepted_by", "created", "decision", "decision_details", "expiration_date", "expiration_date_handled", "expiration_date_warned", "id", "name", "owner_id", "path", "pgh_context_id", "pgh_created_at", "pgh_label", "pgh_obj_id", "product_id", "reactivate_expired", "recommendation", "recommendation_details", "restart_sla_expired", "updated") VALUES (NEW."accepted_by", NEW."created", NEW."decision", NEW."decision_details", NEW."expiration_date", NEW."expiration_date_handled", NEW."expiration_date_warned", NEW."id", NEW."name", NEW."owner_id", NEW."path", _pgh_attach_context(), NOW(), \'insert\', NEW."id", NEW."product_id", NEW."reactivate_expired", NEW."recommendation", NEW."recommendation_details", NEW."restart_sla_expired", NEW."updated"); RETURN NULL;', hash='83d5189fd3362f9e91757621240964180e09bf95', operation='INSERT', pgid='pgtrigger_insert_insert_d29bd', table='dojo_risk_acceptance', when='AFTER')), + ), + pgtrigger.migrations.AddTrigger( + model_name='risk_acceptance', + trigger=pgtrigger.compiler.Trigger(name='update_update', sql=pgtrigger.compiler.UpsertTriggerSql(condition='WHEN (OLD."accepted_by" IS DISTINCT FROM (NEW."accepted_by") OR OLD."decision" IS DISTINCT FROM (NEW."decision") OR OLD."decision_details" IS DISTINCT FROM (NEW."decision_details") OR OLD."expiration_date" IS DISTINCT FROM (NEW."expiration_date") OR OLD."expiration_date_handled" IS DISTINCT FROM (NEW."expiration_date_handled") OR OLD."expiration_date_warned" IS DISTINCT FROM (NEW."expiration_date_warned") OR OLD."id" IS DISTINCT FROM (NEW."id") OR OLD."name" IS DISTINCT FROM (NEW."name") OR OLD."owner_id" IS DISTINCT FROM (NEW."owner_id") OR OLD."path" IS DISTINCT FROM (NEW."path") OR OLD."product_id" IS DISTINCT FROM (NEW."product_id") OR OLD."reactivate_expired" IS DISTINCT FROM (NEW."reactivate_expired") OR OLD."recommendation" IS DISTINCT FROM (NEW."recommendation") OR OLD."recommendation_details" IS DISTINCT FROM (NEW."recommendation_details") OR OLD."restart_sla_expired" IS DISTINCT FROM (NEW."restart_sla_expired"))', func='INSERT INTO "dojo_risk_acceptanceevent" ("accepted_by", "created", "decision", "decision_details", "expiration_date", "expiration_date_handled", "expiration_date_warned", "id", "name", "owner_id", "path", "pgh_context_id", "pgh_created_at", "pgh_label", "pgh_obj_id", "product_id", "reactivate_expired", "recommendation", "recommendation_details", "restart_sla_expired", "updated") VALUES (NEW."accepted_by", NEW."created", NEW."decision", NEW."decision_details", NEW."expiration_date", NEW."expiration_date_handled", NEW."expiration_date_warned", NEW."id", NEW."name", NEW."owner_id", NEW."path", _pgh_attach_context(), NOW(), \'update\', NEW."id", NEW."product_id", NEW."reactivate_expired", NEW."recommendation", NEW."recommendation_details", NEW."restart_sla_expired", NEW."updated"); RETURN NULL;', hash='6e5515509e5c952f582b91b5ac3aa7f5bed0f727', operation='UPDATE', pgid='pgtrigger_update_update_55e64', table='dojo_risk_acceptance', when='AFTER')), + ), + pgtrigger.migrations.AddTrigger( + model_name='risk_acceptance', + trigger=pgtrigger.compiler.Trigger(name='delete_delete', sql=pgtrigger.compiler.UpsertTriggerSql(func='INSERT INTO "dojo_risk_acceptanceevent" ("accepted_by", "created", "decision", "decision_details", "expiration_date", "expiration_date_handled", "expiration_date_warned", "id", "name", "owner_id", "path", "pgh_context_id", "pgh_created_at", "pgh_label", "pgh_obj_id", "product_id", "reactivate_expired", "recommendation", "recommendation_details", "restart_sla_expired", "updated") VALUES (OLD."accepted_by", OLD."created", OLD."decision", OLD."decision_details", OLD."expiration_date", OLD."expiration_date_handled", OLD."expiration_date_warned", OLD."id", OLD."name", OLD."owner_id", OLD."path", _pgh_attach_context(), NOW(), \'delete\', OLD."id", OLD."product_id", OLD."reactivate_expired", OLD."recommendation", OLD."recommendation_details", OLD."restart_sla_expired", OLD."updated"); RETURN NULL;', hash='68cfbb774b18823b974228d517729985c0087130', operation='DELETE', pgid='pgtrigger_delete_delete_7d103', table='dojo_risk_acceptance', when='AFTER')), + ), + ] diff --git a/dojo/db_migrations/0250_risk_acceptance_migrate_to_product.py b/dojo/db_migrations/0250_risk_acceptance_migrate_to_product.py new file mode 100644 index 00000000000..becf137ce0e --- /dev/null +++ b/dojo/db_migrations/0250_risk_acceptance_migrate_to_product.py @@ -0,0 +1,79 @@ +# Generated migration - Step 2: Migrate Risk_Acceptance data from Engagement to Product + +from django.db import migrations + +import logging + +logger = logging.getLogger(__name__) + +def migrate_risk_acceptance_to_product(apps, schema_editor): + """ + Migrate existing risk acceptances from engagement level to product level. + For each risk acceptance, find its engagement and set the product field. + """ + Risk_Acceptance = apps.get_model('dojo', 'Risk_Acceptance') + Engagement = apps.get_model('dojo', 'Engagement') + + # Get all risk acceptances that don't have a product set + risk_acceptances_updated = 0 + risk_acceptances_orphaned = 0 + + for risk_acceptance in Risk_Acceptance.objects.filter(product__isnull=True): + # Find the engagement that has this risk acceptance + engagement = Engagement.objects.filter(risk_acceptance=risk_acceptance).first() + if engagement: + # Set the product from the engagement + risk_acceptance.product = engagement.product + risk_acceptance.save() + risk_acceptances_updated += 1 + else: + # This shouldn't happen in normal cases, but if a risk acceptance has no engagement, + # we need to handle it. We should delete. + risk_acceptance.delete() + risk_acceptances_orphaned += 1 + logger.warning(f"Risk Acceptance {risk_acceptance.id} '{risk_acceptance.name}' has no associated engagement so it can be removed.") + + logger.debug(f"Migration complete: {risk_acceptances_updated} risk acceptances migrated to product level") + if risk_acceptances_orphaned > 0: + logger.warning(f"{risk_acceptances_orphaned} orphaned risk acceptances found (no associated engagement)") + + +def reverse_migrate_risk_acceptance_to_engagement(apps, schema_editor): + """ + Reverse migration: restore engagement associations based on the product field. + For each risk acceptance with a product, find an engagement in that product + and associate the risk acceptance with it. + """ + Risk_Acceptance = apps.get_model('dojo', 'Risk_Acceptance') + Engagement = apps.get_model('dojo', 'Engagement') + + risk_acceptances_restored = 0 + + # For each risk acceptance with a product, find an engagement in that product + # and associate the risk acceptance with it + for risk_acceptance in Risk_Acceptance.objects.filter(product__isnull=False): + # Find the first engagement in this product + engagement = Engagement.objects.filter(product=risk_acceptance.product).first() + if engagement: + # Add the risk acceptance to the engagement + engagement.risk_acceptance.add(risk_acceptance) + risk_acceptances_restored += 1 + else: + logger.warning(f"Could not find engagement for Risk Acceptance {risk_acceptance.id} in product {risk_acceptance.product.name}") + + logger.debug(f"Reverse migration complete: {risk_acceptances_restored} risk acceptances restored to engagement level") + + +class Migration(migrations.Migration): + + dependencies = [ + ('dojo', '0249_risk_acceptance_add_product_field'), + ] + + operations = [ + # Populate the product field from engagement relationships + migrations.RunPython( + migrate_risk_acceptance_to_product, + reverse_migrate_risk_acceptance_to_engagement + ), + ] diff --git a/dojo/db_migrations/0251_risk_acceptance_finalize_product_move.py b/dojo/db_migrations/0251_risk_acceptance_finalize_product_move.py new file mode 100644 index 00000000000..ba2764f3ea0 --- /dev/null +++ b/dojo/db_migrations/0251_risk_acceptance_finalize_product_move.py @@ -0,0 +1,24 @@ +# Generated migration - Step 3: Finalize Risk_Acceptance move to Product + +from django.db import migrations, models +import django.db.models.deletion +import pgtrigger + + +class Migration(migrations.Migration): + + dependencies = [ + ('dojo', '0250_risk_acceptance_migrate_to_product'), + ] + + operations = [ + migrations.AlterField( + model_name='risk_acceptance', + name='product', + field=models.ForeignKey(editable=False, on_delete=django.db.models.deletion.CASCADE, related_name='risk_acceptances', to='dojo.product'), + ), + migrations.RemoveField( + model_name='engagement', + name='risk_acceptance', + ), + ] diff --git a/dojo/engagement/urls.py b/dojo/engagement/urls.py index 0f33c3aa697..8b006de8f30 100644 --- a/dojo/engagement/urls.py +++ b/dojo/engagement/urls.py @@ -34,22 +34,6 @@ name="engagement_unlink_jira"), re_path(r"^engagement/(?P\d+)/complete_checklist$", views.complete_checklist, name="complete_checklist"), - re_path(r"^engagement/(?P\d+)/risk_acceptance/add$", - views.add_risk_acceptance, name="add_risk_acceptance"), - re_path(r"^engagement/(?P\d+)/risk_acceptance/add/(?P\d+)$", - views.add_risk_acceptance, name="add_risk_acceptance"), - re_path(r"^engagement/(?P\d+)/risk_acceptance/(?P\d+)$", - views.view_risk_acceptance, name="view_risk_acceptance"), - re_path(r"^engagement/(?P\d+)/risk_acceptance/(?P\d+)/edit$", - views.edit_risk_acceptance, name="edit_risk_acceptance"), - re_path(r"^engagement/(?P\d+)/risk_acceptance/(?P\d+)/expire$", - views.expire_risk_acceptance, name="expire_risk_acceptance"), - re_path(r"^engagement/(?P\d+)/risk_acceptance/(?P\d+)/reinstate$", - views.reinstate_risk_acceptance, name="reinstate_risk_acceptance"), - re_path(r"^engagement/(?P\d+)/risk_acceptance/(?P\d+)/delete$", - views.delete_risk_acceptance, name="delete_risk_acceptance"), - re_path(r"^engagement/(?P\d+)/risk_acceptance/(?P\d+)/download$", - views.download_risk_acceptance, name="download_risk_acceptance"), re_path(r"^engagement/(?P\d+)/threatmodel$", views.view_threatmodel, name="view_threatmodel"), re_path(r"^engagement/(?P\d+)/threatmodel/upload$", diff --git a/dojo/engagement/views.py b/dojo/engagement/views.py index 706210b569c..060c3f34e16 100644 --- a/dojo/engagement/views.py +++ b/dojo/engagement/views.py @@ -1,25 +1,22 @@ import csv import logging -import mimetypes import operator import re import time from datetime import datetime, timedelta from functools import partial, reduce -from pathlib import Path from tempfile import NamedTemporaryFile from time import strftime -from django.conf import settings from django.contrib import messages from django.contrib.admin.utils import NestedObjects from django.contrib.auth.models import User -from django.core.exceptions import PermissionDenied, ValidationError +from django.core.exceptions import ValidationError from django.db import DEFAULT_DB_ALIAS from django.db.models import OuterRef, Q, Value from django.db.models.functions import Coalesce from django.db.models.query import Prefetch, QuerySet -from django.http import HttpRequest, HttpResponse, HttpResponseRedirect, JsonResponse, QueryDict, StreamingHttpResponse +from django.http import HttpRequest, HttpResponse, HttpResponseRedirect, JsonResponse, QueryDict from django.shortcuts import get_object_or_404, render from django.urls import Resolver404, reverse from django.utils import timezone @@ -32,7 +29,6 @@ from openpyxl.styles import Font import dojo.jira_link.helper as jira_helper -import dojo.risk_acceptance.helper as ra_helper from dojo.authorization.authorization import user_has_permission_or_403 from dojo.authorization.authorization_decorators import user_is_authorized from dojo.authorization.roles_permissions import Permissions @@ -49,23 +45,18 @@ ProductEngagementsFilter, ProductEngagementsFilterWithoutObjectLookups, ) -from dojo.finding.helper import NOT_ACCEPTED_FINDINGS_QUERY from dojo.finding.views import find_available_notetypes from dojo.forms import ( - AddFindingsRiskAcceptanceForm, CheckForm, CredMappingForm, DeleteEngagementForm, DoneForm, - EditRiskAcceptanceForm, EngForm, ImportScanForm, JIRAEngagementForm, JIRAImportScanForm, JIRAProjectForm, NoteForm, - ReplaceRiskAcceptanceProofForm, - RiskAcceptanceForm, TestForm, TypedNoteForm, UploadThreatForm, @@ -81,10 +72,8 @@ Engagement, Finding, Note_Type, - Notes, Product, Product_API_Scan_Configuration, - Risk_Acceptance, System_Settings, Test, Test_Import, @@ -97,11 +86,9 @@ ScanTypeProductAnnouncement, ) from dojo.query_utils import build_count_subquery -from dojo.risk_acceptance.helper import prefetch_for_expiration from dojo.tools.factory import get_scan_types_sorted from dojo.user.queries import get_authorized_users from dojo.utils import ( - FileIterWrapper, Product_Tab, add_breadcrumb, add_error_message_to_response, @@ -111,7 +98,6 @@ generate_file_response_from_file_path, get_cal_event, get_page_items, - get_return_url, get_setting, get_system_setting, handle_uploaded_threat, @@ -424,14 +410,14 @@ class ViewEngagement(View): def get_template(self): return "dojo/view_eng.html" - def get_risks_accepted(self, eng): - accepted_findings_subquery = build_count_subquery( - Finding.objects.filter(risk_acceptance=OuterRef("pk")), - group_field="risk_acceptance", - ) - return eng.risk_acceptance.all().select_related("owner").annotate( - accepted_findings_count=Coalesce(accepted_findings_subquery, Value(0)), - ) + # def get_risks_accepted(self, eng): # TODO: Move this to product + # accepted_findings_subquery = build_count_subquery( + # Finding.objects.filter(risk_acceptance=OuterRef("pk")), + # group_field="risk_acceptance", + # ) + # return eng.risk_acceptance.all().select_related("owner").annotate( + # accepted_findings_count=Coalesce(accepted_findings_subquery, Value(0)), + # ) def get_filtered_tests( self, @@ -453,7 +439,6 @@ def get(self, request, eid, *args, **kwargs): paged_tests = get_page_items(request, tests_filter.qs, default_page_num) paged_tests.object_list = prefetch_for_view_tests(paged_tests.object_list) prod = eng.product - risks_accepted = self.get_risks_accepted(eng) preset_test_type = None network = None if eng.preset: @@ -500,7 +485,6 @@ def get(self, request, eid, *args, **kwargs): "form": form, "notes": notes, "files": files, - "risks_accepted": risks_accepted, "jissue": jissue, "jira_project": jira_project, "creds": creds, @@ -522,7 +506,6 @@ def post(self, request, eid, *args, **kwargs): paged_tests.object_list = prefetch_for_view_tests(paged_tests.object_list) prod = eng.product - risks_accepted = self.get_risks_accepted(eng) preset_test_type = None network = None if eng.preset: @@ -587,7 +570,6 @@ def post(self, request, eid, *args, **kwargs): "form": form, "notes": notes, "files": files, - "risks_accepted": risks_accepted, "jissue": jissue, "jira_project": jira_project, "creds": creds, @@ -1241,304 +1223,6 @@ def complete_checklist(request, eid): }) -@user_is_authorized(Engagement, Permissions.Risk_Acceptance, "eid") -def add_risk_acceptance(request, eid, fid=None): - eng = get_object_or_404(Engagement, id=eid) - finding = None - if fid: - finding = get_object_or_404(Finding, id=fid) - - if not eng.product.enable_full_risk_acceptance: - raise PermissionDenied - - if request.method == "POST": - form = RiskAcceptanceForm(request.POST, request.FILES) - if form.is_valid(): - # first capture notes param as it cannot be saved directly as m2m - notes = None - if form.cleaned_data["notes"]: - notes = Notes( - entry=form.cleaned_data["notes"], - author=request.user, - date=timezone.now()) - notes.save() - - del form.cleaned_data["notes"] - - try: - # we sometimes see a weird exception here, but are unable to reproduce. - # we add some logging in case it happens - risk_acceptance = form.save() - except Exception: - logger.debug(vars(request.POST)) - logger.error(vars(form)) - logger.exception("Creation of Risk Acc. is not possible") - raise - - # attach note to risk acceptance object now in database - if notes: - risk_acceptance.notes.add(notes) - - eng.risk_acceptance.add(risk_acceptance) - - findings = form.cleaned_data["accepted_findings"] - - risk_acceptance = ra_helper.add_findings_to_risk_acceptance(request.user, risk_acceptance, findings) - - messages.add_message( - request, - messages.SUCCESS, - "Risk acceptance saved.", - extra_tags="alert-success") - - return redirect_to_return_url_or_else(request, reverse("view_engagement", args=(eid, ))) - else: - risk_acceptance_title_suggestion = f"Accept: {finding}" - form = RiskAcceptanceForm(initial={"owner": request.user, "name": risk_acceptance_title_suggestion}) - - finding_choices = Finding.objects.filter(duplicate=False, test__engagement=eng).filter(NOT_ACCEPTED_FINDINGS_QUERY).prefetch_related("test", "finding_group_set").order_by("test__id", "numerical_severity", "title") - - form.fields["accepted_findings"].queryset = finding_choices - if fid: - # Set the initial selected finding - form.fields["accepted_findings"].initial = {fid} - # Change the label for each finding in the dropdown - form.fields["accepted_findings"].label_from_instance = lambda obj: f"({obj.test.scan_type}) - ({obj.severity}) - {obj.title} - {obj.date} - {obj.status()} - {obj.finding_group})" - product_tab = Product_Tab(eng.product, title="Risk Acceptance", tab="engagements") - product_tab.setEngagement(eng) - - return render(request, "dojo/add_risk_acceptance.html", { - "eng": eng, - "product_tab": product_tab, - "form": form, - }) - - -@user_is_authorized(Engagement, Permissions.Engagement_View, "eid") -def view_risk_acceptance(request, eid, raid): - return view_edit_risk_acceptance(request, eid=eid, raid=raid, edit_mode=False) - - -@user_is_authorized(Engagement, Permissions.Risk_Acceptance, "eid") -def edit_risk_acceptance(request, eid, raid): - return view_edit_risk_acceptance(request, eid=eid, raid=raid, edit_mode=True) - - -# will only be called by view_risk_acceptance and edit_risk_acceptance -def view_edit_risk_acceptance(request, eid, raid, *, edit_mode=False): - risk_acceptance = get_object_or_404(Risk_Acceptance, pk=raid) - eng = get_object_or_404(Engagement, pk=eid) - - if edit_mode and not eng.product.enable_full_risk_acceptance: - raise PermissionDenied - - risk_acceptance_form = None - errors = False - - if request.method == "POST": - # deleting before instantiating the form otherwise django messes up and we end up with an empty path value - if len(request.FILES) > 0: - logger.debug("new proof uploaded") - risk_acceptance.path.delete() - - if "decision" in request.POST: - old_expiration_date = risk_acceptance.expiration_date - risk_acceptance_form = EditRiskAcceptanceForm(request.POST, request.FILES, instance=risk_acceptance) - errors = errors or not risk_acceptance_form.is_valid() - if not errors: - logger.debug(f"path: {risk_acceptance_form.cleaned_data['path']}") - - risk_acceptance_form.save() - - if risk_acceptance.expiration_date != old_expiration_date: - # risk acceptance was changed, check if risk acceptance needs to be reinstated and findings made accepted again - ra_helper.reinstate(risk_acceptance, old_expiration_date) - - messages.add_message( - request, - messages.SUCCESS, - "Risk Acceptance saved successfully.", - extra_tags="alert-success") - - if "entry" in request.POST: - note_form = NoteForm(request.POST) - errors = errors or not note_form.is_valid() - if not errors: - new_note = note_form.save(commit=False) - new_note.author = request.user - new_note.date = timezone.now() - new_note.save() - risk_acceptance.notes.add(new_note) - messages.add_message( - request, - messages.SUCCESS, - "Note added successfully.", - extra_tags="alert-success") - - if "delete_note" in request.POST: - note = get_object_or_404(Notes, pk=request.POST["delete_note_id"]) - if note.author.username == request.user.username: - risk_acceptance.notes.remove(note) - note.delete() - messages.add_message( - request, - messages.SUCCESS, - "Note deleted successfully.", - extra_tags="alert-success") - else: - messages.add_message( - request, - messages.ERROR, - "Since you are not the note's author, it was not deleted.", - extra_tags="alert-danger") - - if "remove_finding" in request.POST: - finding = get_object_or_404( - Finding, pk=request.POST["remove_finding_id"]) - - ra_helper.remove_finding_from_risk_acceptance(request.user, risk_acceptance, finding) - - messages.add_message( - request, - messages.SUCCESS, - "Finding removed successfully from risk acceptance.", - extra_tags="alert-success") - - if "replace_file" in request.POST: - replace_form = ReplaceRiskAcceptanceProofForm( - request.POST, request.FILES, instance=risk_acceptance) - - errors = errors or not replace_form.is_valid() - if not errors: - replace_form.save() - - messages.add_message( - request, - messages.SUCCESS, - "New Proof uploaded successfully.", - extra_tags="alert-success") - else: - logger.error(replace_form.errors) - - if "add_findings" in request.POST: - add_findings_form = AddFindingsRiskAcceptanceForm( - request.POST, request.FILES, instance=risk_acceptance) - errors = errors or not add_findings_form.is_valid() - if not errors: - findings = add_findings_form.cleaned_data["accepted_findings"] - - ra_helper.add_findings_to_risk_acceptance(request.user, risk_acceptance, findings) - - messages.add_message( - request, - messages.SUCCESS, - f"Finding{'s' if len(findings) > 1 else ''} added successfully.", - extra_tags="alert-success") - if not errors: - logger.debug("redirecting to return_url") - return redirect_to_return_url_or_else(request, reverse("view_risk_acceptance", args=(eid, raid))) - logger.error("errors found") - - elif edit_mode: - risk_acceptance_form = EditRiskAcceptanceForm(instance=risk_acceptance) - - note_form = NoteForm() - replace_form = ReplaceRiskAcceptanceProofForm(instance=risk_acceptance) - add_findings_form = AddFindingsRiskAcceptanceForm(instance=risk_acceptance) - - accepted_findings = risk_acceptance.accepted_findings.order_by("numerical_severity") - fpage = get_page_items(request, accepted_findings, 15) - - unaccepted_findings = Finding.objects.filter(test__in=eng.test_set.all(), risk_accepted=False) \ - .exclude(id__in=accepted_findings).order_by("title") - add_fpage = get_page_items(request, unaccepted_findings, 25, "apage") - # on this page we need to add unaccepted findings as possible findings to add as accepted - - add_findings_form.fields[ - "accepted_findings"].queryset = add_fpage.object_list - - add_findings_form.fields["accepted_findings"].widget.request = request - add_findings_form.fields["accepted_findings"].widget.findings = unaccepted_findings - add_findings_form.fields["accepted_findings"].widget.page_number = add_fpage.number - - product_tab = Product_Tab(eng.product, title="Risk Acceptance", tab="engagements") - product_tab.setEngagement(eng) - return render( - request, "dojo/view_risk_acceptance.html", { - "risk_acceptance": risk_acceptance, - "engagement": eng, - "product_tab": product_tab, - "accepted_findings": fpage, - "notes": risk_acceptance.notes.all(), - "eng": eng, - "edit_mode": edit_mode, - "risk_acceptance_form": risk_acceptance_form, - "note_form": note_form, - "replace_form": replace_form, - "add_findings_form": add_findings_form, - # 'show_add_findings_form': len(unaccepted_findings), - "request": request, - "add_findings": add_fpage, - "return_url": get_return_url(request), - "enable_table_filtering": get_system_setting("enable_ui_table_based_searching"), - }) - - -@user_is_authorized(Engagement, Permissions.Risk_Acceptance, "eid") -def expire_risk_acceptance(request, eid, raid): - risk_acceptance = get_object_or_404(prefetch_for_expiration(Risk_Acceptance.objects.all()), pk=raid) - # Validate the engagement ID exists before moving forward - get_object_or_404(Engagement, pk=eid) - - ra_helper.expire_now(risk_acceptance) - - return redirect_to_return_url_or_else(request, reverse("view_risk_acceptance", args=(eid, raid))) - - -@user_is_authorized(Engagement, Permissions.Risk_Acceptance, "eid") -def reinstate_risk_acceptance(request, eid, raid): - risk_acceptance = get_object_or_404(prefetch_for_expiration(Risk_Acceptance.objects.all()), pk=raid) - eng = get_object_or_404(Engagement, pk=eid) - - if not eng.product.enable_full_risk_acceptance: - raise PermissionDenied - - ra_helper.reinstate(risk_acceptance, risk_acceptance.expiration_date) - - return redirect_to_return_url_or_else(request, reverse("view_risk_acceptance", args=(eid, raid))) - - -@user_is_authorized(Engagement, Permissions.Risk_Acceptance, "eid") -def delete_risk_acceptance(request, eid, raid): - risk_acceptance = get_object_or_404(Risk_Acceptance, pk=raid) - eng = get_object_or_404(Engagement, pk=eid) - - ra_helper.delete(eng, risk_acceptance) - - messages.add_message( - request, - messages.SUCCESS, - "Risk acceptance deleted successfully.", - extra_tags="alert-success") - return HttpResponseRedirect(reverse("view_engagement", args=(eng.id, ))) - - -@user_is_authorized(Engagement, Permissions.Engagement_View, "eid") -def download_risk_acceptance(request, eid, raid): - mimetypes.init() - risk_acceptance = get_object_or_404(Risk_Acceptance, pk=raid) - # Ensure the risk acceptance is under the supplied engagement - if not Engagement.objects.filter(risk_acceptance=risk_acceptance, id=eid).exists(): - raise PermissionDenied - response = StreamingHttpResponse( - FileIterWrapper( - (Path(settings.MEDIA_ROOT) / "risk_acceptance.path.name").open(mode="rb"))) - response["Content-Disposition"] = f'attachment; filename="{risk_acceptance.filename()}"' - mimetype, _encoding = mimetypes.guess_type(risk_acceptance.path.name) - response["Content-Type"] = mimetype - return response - - """ Greg status: in production diff --git a/dojo/fixtures/defect_dojo_sample_data.json b/dojo/fixtures/defect_dojo_sample_data.json index 4dd8181df5f..b31366a8ccc 100644 --- a/dojo/fixtures/defect_dojo_sample_data.json +++ b/dojo/fixtures/defect_dojo_sample_data.json @@ -2783,7 +2783,6 @@ "deduplication_on_engagement": false, "notes": [], "files": [], - "risk_acceptance": [], "tags": [], "inherited_tags": [] } @@ -2830,7 +2829,6 @@ "deduplication_on_engagement": false, "notes": [], "files": [], - "risk_acceptance": [], "tags": [], "inherited_tags": [] } @@ -2877,7 +2875,6 @@ "deduplication_on_engagement": false, "notes": [], "files": [], - "risk_acceptance": [], "tags": [], "inherited_tags": [] } @@ -2924,7 +2921,6 @@ "deduplication_on_engagement": false, "notes": [], "files": [], - "risk_acceptance": [], "tags": [], "inherited_tags": [] } @@ -2971,7 +2967,6 @@ "deduplication_on_engagement": false, "notes": [], "files": [], - "risk_acceptance": [], "tags": [ "pci" ], @@ -3018,7 +3013,6 @@ "deduplication_on_engagement": false, "notes": [], "files": [], - "risk_acceptance": [], "tags": [], "inherited_tags": [] } @@ -3065,7 +3059,6 @@ "deduplication_on_engagement": false, "notes": [], "files": [], - "risk_acceptance": [], "tags": [], "inherited_tags": [] } @@ -3112,7 +3105,6 @@ "deduplication_on_engagement": false, "notes": [], "files": [], - "risk_acceptance": [], "tags": [ "pci" ], @@ -3161,7 +3153,6 @@ "deduplication_on_engagement": false, "notes": [], "files": [], - "risk_acceptance": [], "tags": [], "inherited_tags": [] } @@ -3208,7 +3199,6 @@ "deduplication_on_engagement": false, "notes": [], "files": [], - "risk_acceptance": [], "tags": [], "inherited_tags": [] } @@ -3253,7 +3243,6 @@ "deduplication_on_engagement": false, "notes": [], "files": [], - "risk_acceptance": [], "tags": [], "inherited_tags": [] } @@ -33262,6 +33251,7 @@ "accepted_findings": [ 2 ], + "product": 2, "notes": [] } }, diff --git a/dojo/fixtures/dojo_testdata.json b/dojo/fixtures/dojo_testdata.json index d5b2d4f4538..ffdfdb80ad7 100644 --- a/dojo/fixtures/dojo_testdata.json +++ b/dojo/fixtures/dojo_testdata.json @@ -531,7 +531,6 @@ "report_type": null, "first_contacted": null, "tmodel_path": "none", - "risk_acceptance": [], "lead": 2, "version": null, "progress": "threat_model", @@ -561,9 +560,6 @@ "report_type": null, "first_contacted": null, "tmodel_path": "none", - "risk_acceptance": [ - 1 - ], "lead": 1, "version": null, "progress": "threat_model", @@ -593,7 +589,6 @@ "report_type": null, "first_contacted": null, "tmodel_path": "none", - "risk_acceptance": [], "lead": 2, "version": null, "progress": "threat_model", @@ -626,7 +621,6 @@ "report_type": null, "first_contacted": null, "tmodel_path": "none", - "risk_acceptance": [], "lead": 1, "version": null, "progress": "threat_model", @@ -656,7 +650,6 @@ "report_type": null, "first_contacted": null, "tmodel_path": "none", - "risk_acceptance": [], "lead": 1, "version": null, "progress": "threat_model", @@ -699,6 +692,7 @@ "accepted_findings": [ 226 ], + "product": 1, "notes": [] } }, @@ -711,7 +705,6 @@ "report_type": null, "first_contacted": null, "tmodel_path": "none", - "risk_acceptance": [], "lead": 1, "version": null, "progress": "threat_model", diff --git a/dojo/fixtures/questionnaire_testdata.json b/dojo/fixtures/questionnaire_testdata.json index 2e75807c2ac..0ebed32d577 100644 --- a/dojo/fixtures/questionnaire_testdata.json +++ b/dojo/fixtures/questionnaire_testdata.json @@ -108,7 +108,6 @@ "report_type": null, "first_contacted": null, "tmodel_path": "none", - "risk_acceptance": [], "lead": 2, "version": null, "progress": "threat_model", @@ -138,7 +137,6 @@ "report_type": null, "first_contacted": null, "tmodel_path": "none", - "risk_acceptance": [], "lead": 1, "version": null, "progress": "threat_model", diff --git a/dojo/fixtures/unit_limit_reqresp.json b/dojo/fixtures/unit_limit_reqresp.json index 85fbd23e2b2..2f251be92e4 100644 --- a/dojo/fixtures/unit_limit_reqresp.json +++ b/dojo/fixtures/unit_limit_reqresp.json @@ -82,7 +82,6 @@ "report_type": null, "first_contacted": null, "tmodel_path": "none", - "risk_acceptance": [], "lead": 1, "version": null, "progress": "threat_model", diff --git a/dojo/metrics/utils.py b/dojo/metrics/utils.py index c3fa891aeb9..498d4c6c807 100644 --- a/dojo/metrics/utils.py +++ b/dojo/metrics/utils.py @@ -56,7 +56,7 @@ def finding_queries( "test__engagement__product__prod_type", ).prefetch_related( "risk_acceptance_set", - "test__engagement__risk_acceptance", + "test__engagement__product__risk_acceptances", "test__test_type", ) @@ -174,7 +174,7 @@ def endpoint_queries( ).prefetch_related( "finding__test__engagement__product", "finding__test__engagement__product__prod_type", - "finding__test__engagement__risk_acceptance", + "finding__test__engagement__product__risk_acceptances", "finding__risk_acceptance_set", "finding__reporter", ) diff --git a/dojo/metrics/views.py b/dojo/metrics/views.py index 88788660b1c..cbb3f5de253 100644 --- a/dojo/metrics/views.py +++ b/dojo/metrics/views.py @@ -351,7 +351,7 @@ def product_type_counts(request): "Critical", "High", "Medium", "Low")).prefetch_related( "test__engagement__product", "test__engagement__product__prod_type", - "test__engagement__risk_acceptance", + "test__engagement__product__risk_acceptances", "reporter").order_by( "numerical_severity") @@ -401,7 +401,7 @@ def product_type_counts(request): "Critical", "High", "Medium", "Low")).prefetch_related( "test__engagement__product", "test__engagement__product__prod_type", - "test__engagement__risk_acceptance", + "test__engagement__product__risk_acceptances", "reporter").order_by( "numerical_severity") @@ -562,7 +562,7 @@ def product_tag_counts(request): "Critical", "High", "Medium", "Low")).prefetch_related( "test__engagement__product", "test__engagement__product__prod_type", - "test__engagement__risk_acceptance", + "test__engagement__product__risk_acceptances", "reporter").order_by( "numerical_severity") @@ -616,7 +616,7 @@ def product_tag_counts(request): "Critical", "High", "Medium", "Low")).prefetch_related( "test__engagement__product", "test__engagement__product__prod_type", - "test__engagement__risk_acceptance", + "test__engagement__product__risk_acceptances", "reporter").order_by( "numerical_severity") diff --git a/dojo/models.py b/dojo/models.py index 282a8c4d667..53fbf99ac19 100644 --- a/dojo/models.py +++ b/dojo/models.py @@ -1542,10 +1542,6 @@ class Engagement(models.Model): default="threat_model", editable=False) tmodel_path = models.CharField(max_length=1000, default="none", editable=False, blank=True, null=True) - risk_acceptance = models.ManyToManyField("Risk_Acceptance", - default=None, - editable=False, - blank=True) done_testing = models.BooleanField(default=False, editable=False) engagement_type = models.CharField(editable=True, max_length=30, default="Interactive", null=True, @@ -1586,7 +1582,6 @@ def copy(self): old_notes = list(self.notes.all()) old_files = list(self.files.all()) old_tags = list(self.tags.all()) - old_risk_acceptances = list(self.risk_acceptance.all()) old_tests = list(Test.objects.filter(engagement=self)) # Save the object before setting any ManyToMany relationships copy.save() @@ -1599,9 +1594,6 @@ def copy(self): # Copy the tests for test in old_tests: test.copy(engagement=copy) - # Copy the risk_acceptances - for risk_acceptance in old_risk_acceptances: - copy.risk_acceptance.add(risk_acceptance.copy(engagement=copy)) # Assign any tags copy.tags.set(old_tags) @@ -1632,7 +1624,7 @@ def unaccepted_open_findings(self): return findings def accept_risks(self, accepted_risks): - self.risk_acceptance.add(*accepted_risks) + self.product.risk_acceptances.add(*accepted_risks) @property def has_jira_issue(self): @@ -2213,7 +2205,7 @@ def unaccepted_open_findings(self): return findings def accept_risks(self, accepted_risks): - self.engagement.risk_acceptance.add(*accepted_risks) + self.engagement.product.risk_acceptances.add(*accepted_risks) @property def deduplication_algorithm(self): @@ -3791,6 +3783,8 @@ class Risk_Acceptance(models.Model): name = models.CharField(max_length=300, null=False, blank=False, help_text=_("Descriptive name which in the future may also be used to group risk acceptances together across engagements and products")) + product = models.ForeignKey(Product, on_delete=models.CASCADE, related_name="risk_acceptances", editable=False) + accepted_findings = models.ManyToManyField(Finding) recommendation = models.CharField(choices=TREATMENT_CHOICES, max_length=2, null=False, default=TREATMENT_FIX, help_text=_("Recommendation from the security team."), verbose_name=_("Security Recommendation")) @@ -3821,6 +3815,14 @@ class Risk_Acceptance(models.Model): def __str__(self): return str(self.name) + def clean(self): + super().clean() # TODO: test this + if self.pk: # to avoid 'ValueError: "..." needs to have a value for field "id" before this many-to-many relationship can be used.' + findings_product_ids = self.accepted_findings.values_list("test__engagement__product_id", flat=True).distinct() + if findings_product_ids and (len(findings_product_ids) > 1 or findings_product_ids[0] != self.product_id): + msg = "All findings must belong to the same product as the risk_acc." + raise ValidationError(msg) + def filename(self): # logger.debug('path: "%s"', self.path) if not self.path: @@ -3832,26 +3834,16 @@ def name_and_expiration_info(self): return str(self.name) + (" (expired " if self.is_expired else " (expires ") + (timezone.localtime(self.expiration_date).strftime("%b %d, %Y") if self.expiration_date else "Never") + ")" def get_breadcrumbs(self): - bc = self.engagement_set.first().get_breadcrumbs() + bc = self.product.get_breadcrumbs() bc += [{"title": str(self), - "url": reverse("view_risk_acceptance", args=( - self.engagement_set.first().product.id, self.id))}] + "url": reverse("view_risk_acceptance", args=(self.id,))}] return bc @property def is_expired(self): return self.expiration_date_handled is not None - # relationship is many to many, but we use it as one-to-many - @property - def engagement(self): - engs = self.engagement_set.all() - if engs: - return engs[0] - - return None - - def copy(self, engagement=None): + def copy(self, product=None): copy = copy_model_util(self) # Save the necessary ManyToMany relationships old_notes = list(self.notes.all()) @@ -3862,8 +3854,8 @@ def copy(self, engagement=None): for notes in old_notes: copy.notes.add(notes.copy()) # Assign any accepted findings - if engagement: - new_accepted_findings = Finding.objects.filter(test__engagement=engagement, hash_code__in=old_accepted_findings_hash_codes, risk_accepted=True).distinct() + if product: + new_accepted_findings = Finding.objects.filter(test__engagement__product=product, hash_code__in=old_accepted_findings_hash_codes, risk_accepted=True).distinct() copy.accepted_findings.set(new_accepted_findings) return copy diff --git a/dojo/product/views.py b/dojo/product/views.py index 6884877398a..ffbfa3fb5ee 100644 --- a/dojo/product/views.py +++ b/dojo/product/views.py @@ -91,6 +91,7 @@ Product_Group, Product_Member, Product_Type, + Risk_Acceptance, System_Settings, Test, Test_Import, @@ -378,6 +379,24 @@ def view_product_components(request, pid): }) +@user_is_authorized(Product, Permissions.Risk_Acceptance, "pid") +def view_product_risk_acceptances(request, pid): + prod = get_object_or_404(Product, id=pid) + product_tab = Product_Tab(prod, title=_("Product"), tab="risk_acceptance") + + # Get all risk acceptances for this product + risk_acceptances = Risk_Acceptance.objects.filter(product=prod).select_related("owner").annotate( + accepted_findings_count=Count("accepted_findings__id"), + ).order_by("-created") + + return render(request, "dojo/risk_acceptances.html", { + "prod": prod, + "product_tab": product_tab, + "risk_acceptances": risk_acceptances, + "enable_table_filtering": get_system_setting("enable_ui_table_based_searching"), + }) + + def identify_view(request): get_data = request.GET view = get_data.get("type", None) @@ -403,7 +422,7 @@ def finding_queries(request, prod): # prefetch only what's needed to avoid lots of repeated queries findings_query = findings_query.prefetch_related( # 'test__engagement', - # 'test__engagement__risk_acceptance', + # 'test__engagement__product__risk_acceptance', # 'found_by', # 'test', # 'test__test_type', @@ -472,7 +491,7 @@ def endpoint_queries(request, prod): finding__severity__in=( "Critical", "High", "Medium", "Low", "Info")).prefetch_related( "finding__test__engagement", - "finding__test__engagement__risk_acceptance", + "finding__test__engagement__product__risk_acceptances", "finding__risk_acceptance_set", "finding__reporter").annotate(severity=F("finding__severity")) filter_string_matching = get_system_setting("filter_string_matching", False) diff --git a/dojo/risk_acceptance/api.py b/dojo/risk_acceptance/api.py index 2fdaadf0afb..eb40d07537b 100644 --- a/dojo/risk_acceptance/api.py +++ b/dojo/risk_acceptance/api.py @@ -92,7 +92,7 @@ def _accept_risks(accepted_risks: list[AcceptedRisk], base_findings: QuerySet, o # TODO: we could use risk.vulnerability_id to name the risk_acceptance, but would need to check for existing risk_acceptances in that case # so for now we add some timestamp based suffix name = risk.vulnerability_id + " via api at " + timezone.now().strftime("%b %d, %Y, %H:%M:%S") - acceptance = Risk_Acceptance.objects.create(owner=owner, name=name[:100], + acceptance = Risk_Acceptance.objects.create(owner=owner, name=name[:100], product=findings[0].test.engagement.product, decision=Risk_Acceptance.TREATMENT_ACCEPT, decision_details=risk.justification, accepted_by=risk.accepted_by[:200]) diff --git a/dojo/risk_acceptance/helper.py b/dojo/risk_acceptance/helper.py index 1aa09e82669..95f58506156 100644 --- a/dojo/risk_acceptance/helper.py +++ b/dojo/risk_acceptance/helper.py @@ -51,12 +51,12 @@ def expire_now(risk_acceptance): accepted_findings = risk_acceptance.accepted_findings.all() title = "Risk acceptance with " + str(len(accepted_findings)) + " accepted findings has expired for " + \ - str(risk_acceptance.engagement.product) + ": " + str(risk_acceptance.engagement.name) + str(risk_acceptance.product) create_notification(event="risk_acceptance_expiration", title=title, risk_acceptance=risk_acceptance, accepted_findings=accepted_findings, - reactivated_findings=reactivated_findings, engagement=risk_acceptance.engagement, - product=risk_acceptance.engagement.product, - url=reverse("view_risk_acceptance", args=(risk_acceptance.engagement.id, risk_acceptance.id))) + reactivated_findings=reactivated_findings, + product=risk_acceptance.product, + url=reverse("view_risk_acceptance", args=(risk_acceptance.id,))) def reinstate(risk_acceptance, old_expiration_date): @@ -91,7 +91,7 @@ def reinstate(risk_acceptance, old_expiration_date): risk_acceptance.save() -def delete(eng, risk_acceptance): +def delete(product, risk_acceptance): findings = risk_acceptance.accepted_findings.all() for finding in findings: finding.active = True @@ -104,8 +104,6 @@ def delete(eng, risk_acceptance): post_jira_comments(risk_acceptance, findings, unaccepted_message_creator) risk_acceptance.accepted_findings.clear() - eng.risk_acceptance.remove(risk_acceptance) - eng.save() risk_acceptance.path.delete() risk_acceptance.delete() @@ -195,14 +193,14 @@ def expiration_handler(*args, **kwargs): for risk_acceptance in risk_acceptances: logger.debug("notifying for risk acceptance %i:%s with %i findings", risk_acceptance.id, risk_acceptance, len(risk_acceptance.accepted_findings.all())) - notification_title = "Risk acceptance with " + str(len(risk_acceptance.accepted_findings.all())) + " accepted findings will expire on " + \ - timezone.localtime(risk_acceptance.expiration_date).strftime("%b %d, %Y") + " for " + \ - str(risk_acceptance.engagement.product) + ": " + str(risk_acceptance.engagement.name) + finding_count = len(risk_acceptance.accepted_findings.all()) + ra_date = timezone.localtime(risk_acceptance.expiration_date).strftime("%b %d, %Y") + notification_title = f"Risk acceptance with {finding_count} accepted findings will expire on {ra_date} for {risk_acceptance.product}" create_notification(event="risk_acceptance_expiration", title=notification_title, risk_acceptance=risk_acceptance, - accepted_findings=risk_acceptance.accepted_findings.all(), engagement=risk_acceptance.engagement, - product=risk_acceptance.engagement.product, - url=reverse("view_risk_acceptance", args=(risk_acceptance.engagement.id, risk_acceptance.id))) + accepted_findings=risk_acceptance.accepted_findings.all(), + product=risk_acceptance.product, + url=reverse("view_risk_acceptance", args=(risk_acceptance.id, ))) post_jira_comments(risk_acceptance, risk_acceptance.accepted_findings.all(), expiration_warning_message_creator, heads_up_days) @@ -212,10 +210,10 @@ def expiration_handler(*args, **kwargs): def get_view_risk_acceptance(risk_acceptance: Risk_Acceptance) -> str: """Return the full qualified URL of the view risk acceptance page.""" - # Suppressing this error because it does not happen under most circumstances that a risk acceptance does not have engagement + # Suppressing this error because it does not happen under most circumstances that a risk acceptance does not have product with suppress(AttributeError): - get_full_url( - reverse("view_risk_acceptance", args=(risk_acceptance.engagement.id, risk_acceptance.id)), + return get_full_url( + reverse("view_risk_acceptance", args=(risk_acceptance.id,)), ) return "" @@ -223,21 +221,21 @@ def get_view_risk_acceptance(risk_acceptance: Risk_Acceptance) -> str: def expiration_message_creator(risk_acceptance, heads_up_days=0): return "Risk acceptance [({})|{}] with {} findings has expired".format( escape_for_jira(risk_acceptance.name), - get_full_url(reverse("view_risk_acceptance", args=(risk_acceptance.engagement.id, risk_acceptance.id))), + get_full_url(reverse("view_risk_acceptance", args=(risk_acceptance.id,))), len(risk_acceptance.accepted_findings.all())) def expiration_warning_message_creator(risk_acceptance, heads_up_days=0): return "Risk acceptance [({})|{}] with {} findings will expire in {} days".format( escape_for_jira(risk_acceptance.name), - get_full_url(reverse("view_risk_acceptance", args=(risk_acceptance.engagement.id, risk_acceptance.id))), + get_full_url(reverse("view_risk_acceptance", args=(risk_acceptance.id,))), len(risk_acceptance.accepted_findings.all()), heads_up_days) def reinstation_message_creator(risk_acceptance, heads_up_days=0): return "Risk acceptance [({})|{}] with {} findings has been reinstated (expires on {})".format( escape_for_jira(risk_acceptance.name), - get_full_url(reverse("view_risk_acceptance", args=(risk_acceptance.engagement.id, risk_acceptance.id))), + get_full_url(reverse("view_risk_acceptance", args=(risk_acceptance.id,))), len(risk_acceptance.accepted_findings.all()), timezone.localtime(risk_acceptance.expiration_date).strftime("%b %d, %Y")) @@ -245,7 +243,7 @@ def accepted_message_creator(risk_acceptance, heads_up_days=0): if risk_acceptance: return "Finding has been added to risk acceptance [({})|{}] with {} findings (expires on {})".format( escape_for_jira(risk_acceptance.name), - get_full_url(reverse("view_risk_acceptance", args=(risk_acceptance.engagement.id, risk_acceptance.id))), + get_full_url(reverse("view_risk_acceptance", args=(risk_acceptance.id,))), len(risk_acceptance.accepted_findings.all()), timezone.localtime(risk_acceptance.expiration_date).strftime("%b %d, %Y")) return "Finding has been risk accepted" @@ -253,7 +251,7 @@ def accepted_message_creator(risk_acceptance, heads_up_days=0): def unaccepted_message_creator(risk_acceptance, heads_up_days=0): if risk_acceptance: return "finding was unaccepted/deleted from risk acceptance [({})|{}]".format(escape_for_jira(risk_acceptance.name), - get_full_url(reverse("view_risk_acceptance", args=(risk_acceptance.engagement.id, risk_acceptance.id)))) + get_full_url(reverse("view_risk_acceptance", args=(risk_acceptance.id,)))) return "Finding is no longer risk accepted" @@ -280,7 +278,7 @@ def post_jira_comments(risk_acceptance, findings, message_factory, heads_up_days if not risk_acceptance: return - jira_project = jira_helper.get_jira_project(risk_acceptance.engagement) + jira_project = jira_helper.get_jira_project(risk_acceptance.product) if jira_project and jira_project.risk_acceptance_expiration_notification: jira_instance = jira_helper.get_jira_instance(risk_acceptance.engagement) @@ -311,9 +309,9 @@ def get_almost_expired_risk_acceptances_to_handle(heads_up_days): def prefetch_for_expiration(risk_acceptances): return risk_acceptances.prefetch_related("accepted_findings", "accepted_findings__jira_issue", - "engagement_set", - "engagement__jira_project", - "engagement__jira_project__jira_instance", + "product", + "product__jira_project_set", + "product__jira_project_set__jira_instance", ) diff --git a/dojo/risk_acceptance/queries.py b/dojo/risk_acceptance/queries.py index 72282af21e7..f2e30a9410c 100644 --- a/dojo/risk_acceptance/queries.py +++ b/dojo/risk_acceptance/queries.py @@ -19,19 +19,19 @@ def get_authorized_risk_acceptances(permission): roles = get_roles_for_permission(permission) authorized_product_type_roles = Product_Type_Member.objects.filter( - product_type=OuterRef("engagement__product__prod_type_id"), + product_type=OuterRef("product__prod_type_id"), user=user, role__in=roles) authorized_product_roles = Product_Member.objects.filter( - product=OuterRef("engagement__product_id"), + product=OuterRef("product_id"), user=user, role__in=roles) authorized_product_type_groups = Product_Type_Group.objects.filter( - product_type=OuterRef("engagement__product__prod_type_id"), + product_type=OuterRef("product__prod_type_id"), group__users=user, role__in=roles) authorized_product_groups = Product_Group.objects.filter( - product=OuterRef("engagement__product_id"), + product=OuterRef("product_id"), group__users=user, role__in=roles) risk_acceptances = Risk_Acceptance.objects.annotate( diff --git a/dojo/risk_acceptance/signals.py b/dojo/risk_acceptance/signals.py index 38e3cb2be6d..207857856fc 100644 --- a/dojo/risk_acceptance/signals.py +++ b/dojo/risk_acceptance/signals.py @@ -1,9 +1,10 @@ import logging -from django.db.models.signals import pre_delete +from django.core.exceptions import ObjectDoesNotExist, ValidationError +from django.db.models.signals import m2m_changed, pre_delete from django.dispatch import receiver -from dojo.models import Risk_Acceptance +from dojo.models import Finding, Risk_Acceptance from dojo.notes.helper import delete_related_notes logger = logging.getLogger(__name__) @@ -12,3 +13,30 @@ @receiver(pre_delete, sender=Risk_Acceptance) def risk_acceptance_pre_delete(sender, instance, **kwargs): delete_related_notes(instance) + + +@receiver(m2m_changed, sender=Risk_Acceptance.accepted_findings.through) # TODO: test +def validate_findings_product(sender, instance, action, reverse, model, pk_set, using, **kwargs): + if action == "pre_add": + # Using loaddata or fixtures, not all objects are already fully created. In that case we should not check all relationships + try: + if not instance.pk: + logger.debug("RA without pk") + return + if not instance.product.pk: + logger.debug("Product without pk") + return + for pk in pk_set: + if not Finding.objects.get(pk=pk).test.engagement.product.pk: + logger.debug("Finding's Product without pk") + return + except ObjectDoesNotExist: + logger.debug("Some object in chain does not exist yet") + return + + if pk_set: # Do not validate for empty set + findings_product_ids = set(Finding.objects.filter(pk__in=pk_set).values_list("test__engagement__product_id", flat=True)) + if len(findings_product_ids) > 1 or (instance.product_id not in findings_product_ids): + msg = f"All findings must belong to the same product as the risk_acc. Findings '{pk_set}' are part of '{findings_product_ids}' products, not product '{instance.product_id}' (as expected)." + logger.error(msg) + raise ValidationError(msg) diff --git a/dojo/risk_acceptance/urls.py b/dojo/risk_acceptance/urls.py new file mode 100644 index 00000000000..c2384e76d80 --- /dev/null +++ b/dojo/risk_acceptance/urls.py @@ -0,0 +1,22 @@ +from django.urls import re_path + +from dojo.risk_acceptance import views + +urlpatterns = [ + re_path(r"^product/(?P\d+)/risk_acceptance/add$", + views.add_risk_acceptance, name="add_risk_acceptance"), + re_path(r"^product/(?P\d+)/risk_acceptance/add/(?P\d+)$", + views.add_risk_acceptance, name="add_risk_acceptance"), + re_path(r"^risk_acceptance/(?P\d+)$", + views.view_risk_acceptance, name="view_risk_acceptance"), + re_path(r"^risk_acceptance/(?P\d+)/edit$", + views.edit_risk_acceptance, name="edit_risk_acceptance"), + re_path(r"^risk_acceptance/(?P\d+)/expire$", + views.expire_risk_acceptance, name="expire_risk_acceptance"), + re_path(r"^risk_acceptance/(?P\d+)/reinstate$", + views.reinstate_risk_acceptance, name="reinstate_risk_acceptance"), + re_path(r"^risk_acceptance/(?P\d+)/delete$", + views.delete_risk_acceptance, name="delete_risk_acceptance"), + re_path(r"^risk_acceptance/(?P\d+)/download$", + views.download_risk_acceptance, name="download_risk_acceptance"), +] diff --git a/dojo/risk_acceptance/views.py b/dojo/risk_acceptance/views.py new file mode 100644 index 00000000000..3c9fac4b15b --- /dev/null +++ b/dojo/risk_acceptance/views.py @@ -0,0 +1,321 @@ +import logging +import mimetypes +import pathlib + +from django.conf import settings +from django.contrib import messages +from django.core.exceptions import PermissionDenied +from django.http import HttpResponseRedirect, StreamingHttpResponse +from django.shortcuts import get_object_or_404, render +from django.urls import reverse +from django.utils import timezone + +import dojo.risk_acceptance.helper as ra_helper +from dojo.authorization.authorization_decorators import user_is_authorized +from dojo.authorization.roles_permissions import Permissions +from dojo.finding.helper import NOT_ACCEPTED_FINDINGS_QUERY +from dojo.forms import ( + AddFindingsRiskAcceptanceForm, + EditRiskAcceptanceForm, + NoteForm, + ReplaceRiskAcceptanceProofForm, + RiskAcceptanceForm, +) +from dojo.models import Finding, Notes, Product, Risk_Acceptance +from dojo.risk_acceptance.helper import prefetch_for_expiration +from dojo.utils import ( + FileIterWrapper, + Product_Tab, + get_page_items, + get_return_url, + get_system_setting, + redirect_to_return_url_or_else, +) + +logger = logging.getLogger(__name__) + + +@user_is_authorized(Product, Permissions.Risk_Acceptance, "pid") +def add_risk_acceptance(request, pid, fid=None): + product = get_object_or_404(Product, id=pid) + finding = None + if fid: + finding = get_object_or_404(Finding, id=fid) + + if not product.enable_full_risk_acceptance: + raise PermissionDenied + + if request.method == "POST": + form = RiskAcceptanceForm(request.POST, request.FILES) + if form.is_valid(): + # first capture notes param as it cannot be saved directly as m2m + notes = None + if form.cleaned_data["notes"]: + notes = Notes( + entry=form.cleaned_data["notes"], + author=request.user, + date=timezone.now()) + notes.save() + + del form.cleaned_data["notes"] + + try: + # we sometimes see a weird exception here, but are unable to reproduce. + # we add some logging in case it happens + risk_acceptance = form.save(commit=False) + risk_acceptance.product = product + risk_acceptance.save() + except Exception: + logger.debug(vars(request.POST)) + logger.error(vars(form)) + logger.exception("Creation of Risk Acc. is not possible") + raise + + # attach note to risk acceptance object now in database + if notes: + risk_acceptance.notes.add(notes) + + findings = form.cleaned_data["accepted_findings"] + + risk_acceptance = ra_helper.add_findings_to_risk_acceptance(request.user, risk_acceptance, findings) + + messages.add_message( + request, + messages.SUCCESS, + "Risk acceptance saved.", + extra_tags="alert-success") + + return redirect_to_return_url_or_else(request, reverse("view_product", args=(pid, ))) + else: + risk_acceptance_title_suggestion = f"Accept: {finding}" if finding else "Risk Acceptance" + form = RiskAcceptanceForm(initial={"owner": request.user, "name": risk_acceptance_title_suggestion}) + + finding_choices = Finding.objects.filter(duplicate=False, test__engagement__product=product).filter(NOT_ACCEPTED_FINDINGS_QUERY).order_by("title") + + form.fields["accepted_findings"].queryset = finding_choices + if fid: + form.fields["accepted_findings"].initial = {fid} + product_tab = Product_Tab(product, title="Risk Acceptance", tab="risk_acceptance") + + return render(request, "dojo/add_risk_acceptance.html", { + "product": product, + "product_tab": product_tab, + "form": form, + }) + + +@user_is_authorized(Risk_Acceptance, Permissions.Risk_Acceptance, "raid") +def view_risk_acceptance(request, raid): + return view_edit_risk_acceptance(request, raid=raid, edit_mode=False) + + +@user_is_authorized(Risk_Acceptance, Permissions.Risk_Acceptance, "raid") +def edit_risk_acceptance(request, raid): + return view_edit_risk_acceptance(request, raid=raid, edit_mode=True) + + +# will only be called by view_risk_acceptance and edit_risk_acceptance +def view_edit_risk_acceptance(request, raid, *, edit_mode=False): + risk_acceptance = get_object_or_404(Risk_Acceptance, pk=raid) + product = risk_acceptance.product + + if edit_mode and not product.enable_full_risk_acceptance: + raise PermissionDenied + + risk_acceptance_form = None + errors = False + + if request.method == "POST": + # deleting before instantiating the form otherwise django messes up and we end up with an empty path value + if len(request.FILES) > 0: + logger.debug("new proof uploaded") + risk_acceptance.path.delete() + + if "decision" in request.POST: + old_expiration_date = risk_acceptance.expiration_date + risk_acceptance_form = EditRiskAcceptanceForm(request.POST, request.FILES, instance=risk_acceptance) + errors = errors or not risk_acceptance_form.is_valid() + if not errors: + logger.debug(f"path: {risk_acceptance_form.cleaned_data['path']}") + + risk_acceptance_form.save() + + if risk_acceptance.expiration_date != old_expiration_date: + # risk acceptance was changed, check if risk acceptance needs to be reinstated and findings made accepted again + ra_helper.reinstate(risk_acceptance, old_expiration_date) + + messages.add_message( + request, + messages.SUCCESS, + "Risk Acceptance saved successfully.", + extra_tags="alert-success") + + if "entry" in request.POST: + note_form = NoteForm(request.POST) + errors = errors or not note_form.is_valid() + if not errors: + new_note = note_form.save(commit=False) + new_note.author = request.user + new_note.date = timezone.now() + new_note.save() + risk_acceptance.notes.add(new_note) + messages.add_message( + request, + messages.SUCCESS, + "Note added successfully.", + extra_tags="alert-success") + + if "delete_note" in request.POST: + note = get_object_or_404(Notes, pk=request.POST["delete_note_id"]) + if note.author.username == request.user.username: + risk_acceptance.notes.remove(note) + note.delete() + messages.add_message( + request, + messages.SUCCESS, + "Note deleted successfully.", + extra_tags="alert-success") + else: + messages.add_message( + request, + messages.ERROR, + "Since you are not the note's author, it was not deleted.", + extra_tags="alert-danger") + + if "remove_finding" in request.POST: + finding = get_object_or_404( + Finding, pk=request.POST["remove_finding_id"]) + + ra_helper.remove_finding_from_risk_acceptance(request.user, risk_acceptance, finding) + + messages.add_message( + request, + messages.SUCCESS, + "Finding removed successfully from risk acceptance.", + extra_tags="alert-success") + + if "replace_file" in request.POST: + replace_form = ReplaceRiskAcceptanceProofForm( + request.POST, request.FILES, instance=risk_acceptance) + + errors = errors or not replace_form.is_valid() + if not errors: + replace_form.save() + + messages.add_message( + request, + messages.SUCCESS, + "New Proof uploaded successfully.", + extra_tags="alert-success") + else: + logger.error(replace_form.errors) + + if "add_findings" in request.POST: + add_findings_form = AddFindingsRiskAcceptanceForm( + request.POST, request.FILES, instance=risk_acceptance) + errors = errors or not add_findings_form.is_valid() + if not errors: + findings = add_findings_form.cleaned_data["accepted_findings"] + + ra_helper.add_findings_to_risk_acceptance(request.user, risk_acceptance, findings) + + messages.add_message( + request, + messages.SUCCESS, + f"Finding{'s' if len(findings) > 1 else ''} added successfully.", + extra_tags="alert-success") + if not errors: + logger.debug("redirecting to return_url") + return redirect_to_return_url_or_else(request, reverse("view_risk_acceptance", args=(raid,))) + logger.error("errors found") + + elif edit_mode: + risk_acceptance_form = EditRiskAcceptanceForm(instance=risk_acceptance) + + note_form = NoteForm() + replace_form = ReplaceRiskAcceptanceProofForm(instance=risk_acceptance) + add_findings_form = AddFindingsRiskAcceptanceForm(instance=risk_acceptance) + + accepted_findings = risk_acceptance.accepted_findings.order_by("numerical_severity") + fpage = get_page_items(request, accepted_findings, 15) + + unaccepted_findings = Finding.objects.filter(test__engagement__product=product, risk_accepted=False) \ + .exclude(id__in=accepted_findings).order_by("title") + add_fpage = get_page_items(request, unaccepted_findings, 25, "apage") + # on this page we need to add unaccepted findings as possible findings to add as accepted + + add_findings_form.fields[ + "accepted_findings"].queryset = add_fpage.object_list + + add_findings_form.fields["accepted_findings"].widget.request = request + add_findings_form.fields["accepted_findings"].widget.findings = unaccepted_findings + add_findings_form.fields["accepted_findings"].widget.page_number = add_fpage.number + + product_tab = Product_Tab(product, title="Risk Acceptance", tab="risk_acceptance") + return render( + request, "dojo/view_risk_acceptance.html", { + "risk_acceptance": risk_acceptance, + "product": product, + "product_tab": product_tab, + "accepted_findings": fpage, + "notes": risk_acceptance.notes.all(), + "edit_mode": edit_mode, + "risk_acceptance_form": risk_acceptance_form, + "note_form": note_form, + "replace_form": replace_form, + "add_findings_form": add_findings_form, + "request": request, + "add_findings": add_fpage, + "return_url": get_return_url(request), + "enable_table_filtering": get_system_setting("enable_ui_table_based_searching"), + }) + + +@user_is_authorized(Risk_Acceptance, Permissions.Risk_Acceptance, "raid") +def expire_risk_acceptance(request, raid): + risk_acceptance = get_object_or_404(prefetch_for_expiration(Risk_Acceptance.objects.all()), pk=raid) + + ra_helper.expire_now(risk_acceptance) + + return redirect_to_return_url_or_else(request, reverse("view_risk_acceptance", args=(raid,))) + + +@user_is_authorized(Risk_Acceptance, Permissions.Risk_Acceptance, "raid") +def reinstate_risk_acceptance(request, raid): + risk_acceptance = get_object_or_404(prefetch_for_expiration(Risk_Acceptance.objects.all()), pk=raid) + product = risk_acceptance.product + + if not product.enable_full_risk_acceptance: + raise PermissionDenied + + ra_helper.reinstate(risk_acceptance, risk_acceptance.expiration_date) + + return redirect_to_return_url_or_else(request, reverse("view_risk_acceptance", args=(raid,))) + + +@user_is_authorized(Risk_Acceptance, Permissions.Risk_Acceptance, "raid") +def delete_risk_acceptance(request, raid): + risk_acceptance = get_object_or_404(Risk_Acceptance, pk=raid) + product = risk_acceptance.product + + ra_helper.delete(product, risk_acceptance) + + messages.add_message( + request, + messages.SUCCESS, + "Risk acceptance deleted successfully.", + extra_tags="alert-success") + return HttpResponseRedirect(reverse("view_product", args=(product.id, ))) + + +@user_is_authorized(Risk_Acceptance, Permissions.Risk_Acceptance, "raid") +def download_risk_acceptance(request, raid): + mimetypes.init() + risk_acceptance = get_object_or_404(Risk_Acceptance, pk=raid) + response = StreamingHttpResponse( + FileIterWrapper( + (pathlib.Path(settings.MEDIA_ROOT) / risk_acceptance.path.name).open(mode="rb"))) + response["Content-Disposition"] = f'attachment; filename="{risk_acceptance.filename()}"' + mimetype, _encoding = mimetypes.guess_type(risk_acceptance.path.name) + response["Content-Type"] = mimetype + return response diff --git a/dojo/templates/base.html b/dojo/templates/base.html index c562b598cd9..24274bca376 100644 --- a/dojo/templates/base.html +++ b/dojo/templates/base.html @@ -693,6 +693,12 @@

+
  • - + View Risk Acceptance
  • @@ -12,20 +12,20 @@ {% if engagement.product.enable_full_risk_acceptance %} {% if engagement|has_object_permission:"Risk_Acceptance" %}
  • - + Edit Risk Acceptance
  • {% if risk_acceptance.is_expired %}
  • - + Reinstate
  • {% else %}
  • - + Expire Now
  • @@ -44,7 +44,7 @@ Delete Risk Acceptance -
    + {% csrf_token %}
    diff --git a/dojo/templates/dojo/test_pdf_report.html b/dojo/templates/dojo/test_pdf_report.html index 274ca467671..18a86e2efc1 100644 --- a/dojo/templates/dojo/test_pdf_report.html +++ b/dojo/templates/dojo/test_pdf_report.html @@ -195,7 +195,7 @@

    Test Notes

    {% endwith %} {% endif %} - {% if test.engagement.risk_acceptance.count > 0 %} + {% if test.engagement.product.risk_acceptances.count > 0 %}

    Risk Accepted Findings

    @@ -212,7 +212,7 @@

    Risk Accepted Findings

    EPSS Score / Percentile - {% for risk in test.engagement.risk_acceptance.all %} + {% for risk in test.engagement.product.risk_acceptances.all %} {% for finding in risk.accepted_findings.all %} @@ -295,7 +295,7 @@
    diff --git a/dojo/templates/dojo/view_eng.html b/dojo/templates/dojo/view_eng.html index ab09dadb7c5..93e9af7d531 100644 --- a/dojo/templates/dojo/view_eng.html +++ b/dojo/templates/dojo/view_eng.html @@ -378,7 +378,7 @@

    Risk Acceptance {% if eng.product.enable_full_risk_acceptance %} {% if eng|has_object_permission:"Risk_Acceptance" %} + href="{% url 'add_risk_acceptance' eng.product.id %}?return_url={{ request.get_full_path|urlencode }}"> {% endif %} {% endif %} @@ -420,9 +420,9 @@

    Risk Acceptance -

    + - + - + {% if risk_acceptance.filename %} - diff --git a/dojo/templates/dojo/view_finding.html b/dojo/templates/dojo/view_finding.html index 2626130ed07..01d36135554 100755 --- a/dojo/templates/dojo/view_finding.html +++ b/dojo/templates/dojo/view_finding.html @@ -168,7 +168,7 @@

    {% endif %} {% if finding.test.engagement.product.enable_full_risk_acceptance %}
  • - + Add Risk Acceptance...
  • @@ -322,7 +322,7 @@

    {% if finding.risk_acceptance_set.all %}

    {% if risk_acceptance.filename %} - + {% else %} {% endif %} @@ -253,7 +253,7 @@

    Uploaded Proof

    {% if risk_acceptance.filename %}
    {{ finding.title }} {% comment %} for some reason the font-awesome icons don't work with the report template{% endcomment %} {% for ra in finding.risk_acceptance_set.all|slice:":5" %} - acceptance  {% endfor %} {{ risk_acceptance.created|date }}{{ risk_acceptance.created|date }} {{ risk_acceptance.accepted_by }}{{ risk_acceptance.name }}{{ risk_acceptance.name }} {{ risk_acceptance.get_decision_display|default_if_none:"" }} {% if risk_acceptance.decision_details %} @@ -438,9 +438,9 @@

    Risk Acceptance Never {% endif %}

    {{ risk_acceptance.accepted_findings_count }}{{ risk_acceptance.accepted_findings_count }}Yes + Yes   {% for ra in finding.risk_acceptance_set.all|slice:":5" %} -
    -
    + {% csrf_token %} {% if return_url %} @@ -107,7 +107,7 @@

    {{ risk_acceptance.expiration_date_handled|date }} {{ risk_acceptance.accepted_findings.count }}{{ risk_acceptance.filename }}{{ risk_acceptance.filename }}No
    - {{ risk_acceptance.filename }} + {{ risk_acceptance.filename }} {{ risk_acceptance.created }} @@ -346,7 +346,7 @@

    Notes

    // keyboard shortcuts document.addEventListener('keydown', function(e) { if (e.key == 'e') { - window.location.assign('{% url 'edit_risk_acceptance' eng.id risk_acceptance.id %}') + window.location.assign('{% url 'edit_risk_acceptance' risk_acceptance.id %}') } }); diff --git a/dojo/templates/dojo/view_test.html b/dojo/templates/dojo/view_test.html index 677a586b239..919b41ea600 100644 --- a/dojo/templates/dojo/view_test.html +++ b/dojo/templates/dojo/view_test.html @@ -1096,7 +1096,7 @@

    {% endif %} {% if finding.test.engagement.product.enable_full_risk_acceptance %}
  • - + {% trans "Add Risk Acceptance..." %}
  • diff --git a/dojo/templates/notifications/mail/risk_acceptance_expiration.tpl b/dojo/templates/notifications/mail/risk_acceptance_expiration.tpl index ce76a2d1b5b..27abe61cc05 100644 --- a/dojo/templates/notifications/mail/risk_acceptance_expiration.tpl +++ b/dojo/templates/notifications/mail/risk_acceptance_expiration.tpl @@ -1,9 +1,8 @@ {% load i18n %} {% load navigation_tags %} {% load display_tags %} -{% url 'view_risk_acceptance' risk_acceptance.engagement.id risk_acceptance.id as risk_acceptance_url %} -{% url 'view_product' risk_acceptance.engagement.product.id as product_url %} -{% url 'view_engagement' risk_acceptance.engagement.id as engagement_url %} +{% url 'view_risk_acceptance' risk_acceptance.id as risk_acceptance_url %} +{% url 'view_product' risk_acceptance.product.id as product_url %} {% autoescape on %} diff --git a/dojo/templatetags/display_tags.py b/dojo/templatetags/display_tags.py index 2d26b874be7..4e42a3b109d 100644 --- a/dojo/templatetags/display_tags.py +++ b/dojo/templatetags/display_tags.py @@ -736,7 +736,7 @@ def finding_display_status(finding): if "Risk Accepted" in display_status: ra = finding.risk_acceptance if ra: - url = reverse("view_risk_acceptance", args=(finding.test.engagement.id, ra.id)) + url = reverse("view_risk_acceptance", args=(ra.id,)) info = ra.name_and_expiration_info link = 'Risk Accepted' display_status = display_status.replace("Risk Accepted", link) diff --git a/dojo/urls.py b/dojo/urls.py index ac7581944de..508513826cf 100644 --- a/dojo/urls.py +++ b/dojo/urls.py @@ -98,6 +98,7 @@ from dojo.organization.urls import urlpatterns as organization_urls from dojo.regulations.urls import urlpatterns as regulations from dojo.reports.urls import urlpatterns as reports_urls +from dojo.risk_acceptance.urls import urlpatterns as risk_acceptance_urls from dojo.search.urls import urlpatterns as search_urls from dojo.sla_config.urls import urlpatterns as sla_urls from dojo.survey.urls import urlpatterns as survey_urls @@ -192,6 +193,7 @@ ur += endpoint_urls ur += eng_urls ur += finding_urls +ur += risk_acceptance_urls ur += finding_group_urls ur += home_urls ur += metrics_urls diff --git a/unittests/test_bulk_risk_acceptance_api.py b/unittests/test_bulk_risk_acceptance_api.py index 05bbe10e7a8..06354319555 100644 --- a/unittests/test_bulk_risk_acceptance_api.py +++ b/unittests/test_bulk_risk_acceptance_api.py @@ -91,7 +91,7 @@ def test_test_accept_risks(self): self.assertEqual(self.test_c.unaccepted_open_findings.count(), 33) self.assertEqual(self.test_d.unaccepted_open_findings.count(), 34) - self.assertEqual(self.engagement_2a.risk_acceptance.count(), 0) + self.assertEqual(self.engagement_2a.product.risk_acceptances.count(), 0) def test_engagement_accept_risks(self): accepted_risks = [{"vulnerability_id": f"CVE-1999-{i}", "justification": "Demonstration purposes", @@ -101,7 +101,7 @@ def test_engagement_accept_risks(self): self.assertEqual(len(result.json()), 50) self.assertEqual(self.engagement.unaccepted_open_findings.count(), 50) - self.assertEqual(self.engagement_2a.risk_acceptance.count(), 0) + self.assertEqual(self.engagement_2a.product.risk_acceptances.count(), 0) self.assertEqual(self.engagement_2a.unaccepted_open_findings.count(), 34) def test_finding_accept_risks(self): @@ -111,9 +111,9 @@ def test_finding_accept_risks(self): self.assertEqual(len(result.json()), 106) self.assertEqual(Finding.unaccepted_open_findings().count(), 62) - self.assertEqual(self.engagement_2a.risk_acceptance.count(), 0) - self.assertEqual(self.engagement_2a.unaccepted_open_findings.count(), 34) + self.assertEqual(self.engagement_2a.product.risk_acceptances.count(), 0, self.engagement_2a.product.risk_acceptances) + self.assertEqual(self.engagement_2a.unaccepted_open_findings.count(), 34, self.engagement_2a.unaccepted_open_findings) - for ra in self.engagement_2b.risk_acceptance.all(): + for ra in self.engagement_2b.product.risk_acceptances.all(): for finding in ra.accepted_findings.all(): self.assertEqual(self.engagement_2a.product, finding.test.engagement.product) diff --git a/unittests/test_dashboard.py b/unittests/test_dashboard.py index 853dbe1a07c..fbf65d10f02 100644 --- a/unittests/test_dashboard.py +++ b/unittests/test_dashboard.py @@ -49,7 +49,7 @@ def accept(when: datetime, product_id: int, title: str): with patch("django.db.models.fields.timezone.now") as mock_now: mock_now.return_value = when findings = Finding.objects.filter(test__engagement__product_id=product_id, title=title) - ra = Risk_Acceptance.objects.create(name="My Risk Acceptance", owner_id=1) + ra = Risk_Acceptance.objects.create(name="My Risk Acceptance", owner_id=1, product_id=product_id) ra.accepted_findings.add(*findings) findings.update(risk_accepted=True) diff --git a/unittests/test_jira_import_and_pushing_api.py b/unittests/test_jira_import_and_pushing_api.py index 84d173667a7..212077a6cc9 100644 --- a/unittests/test_jira_import_and_pushing_api.py +++ b/unittests/test_jira_import_and_pushing_api.py @@ -318,8 +318,8 @@ def test_import_twice_push_to_jira(self): self.assert_jira_issue_count_in_test(test_id1, 0) self.assert_jira_group_issue_count_in_test(test_id, 0) - def add_risk_acceptance(self, eid, data_risk_accceptance, fid=None): - args = (eid, fid) if fid else (eid,) + def add_risk_acceptance(self, pid, data_risk_accceptance, fid=None): + args = (pid, fid) if fid else (pid,) response = self.client.post(reverse("add_risk_acceptance", args=args), data_risk_accceptance) self.assertEqual(302, response.status_code, response.content[:1000]) return response @@ -345,6 +345,7 @@ def test_import_grouped_reopen_expired_risk_acceptance(self): "decision_details": "it has been decided!", "accepted_by": "pointy haired boss", "owner": 1, + "product": Finding.objects.get(pk=finding_id).test.engagement.product.pk, "expiration_date": "2024-12-31", "reactivate_expired": True, } @@ -354,8 +355,8 @@ def test_import_grouped_reopen_expired_risk_acceptance(self): pre_jira_status = self.get_jira_issue_status(finding_id) - response = self.add_risk_acceptance(1, data_risk_accceptance=ra_data) - self.assertEqual("/engagement/1", response.url) + response = self.add_risk_acceptance(2, data_risk_accceptance=ra_data) + self.assertEqual("/product/2", response.url) # We do this to update the JIRA for finding in ra_data["accepted_findings"]: @@ -401,6 +402,7 @@ def test_import_grouped_reopen_expired_risk_acceptance_with_finding_sync(self, m "decision_details": "it has been decided!", "accepted_by": "pointy haired boss", "owner": 1, + "product": Finding.objects.get(pk=finding_id).test.engagement.product.pk, "expiration_date": "2024-12-31", "reactivate_expired": True, } @@ -410,8 +412,8 @@ def test_import_grouped_reopen_expired_risk_acceptance_with_finding_sync(self, m pre_jira_status = self.get_jira_issue_status(finding_id) - response = self.add_risk_acceptance(1, data_risk_accceptance=ra_data) - self.assertEqual("/engagement/1", response.url) + response = self.add_risk_acceptance(2, data_risk_accceptance=ra_data) + self.assertEqual("/product/2", response.url) # we don't do any explicit push to JIRA here as it should happen automatically diff --git a/unittests/test_rest_framework.py b/unittests/test_rest_framework.py index f32350e2e86..1994b5020dc 100644 --- a/unittests/test_rest_framework.py +++ b/unittests/test_rest_framework.py @@ -1194,6 +1194,7 @@ def __init__(self, *args, **kwargs): "created": "2020-11-09T23:13:08.520000Z", "updated": "2023-09-15T17:17:39.462854Z", "owner": 1, + "product": 1, "accepted_findings": [ 226, ], @@ -1205,7 +1206,7 @@ def __init__(self, *args, **kwargs): self.permission_create = Permissions.Risk_Acceptance self.permission_update = Permissions.Risk_Acceptance self.permission_delete = Permissions.Risk_Acceptance - self.deleted_objects = 3 + self.deleted_objects = 2 BaseClass.RESTEndpointTest.__init__(self, *args, **kwargs) def test_create_object_not_authorized(self): @@ -1231,6 +1232,7 @@ def test_update_forbidden_engagement(self): "created": "2020-11-09T23:13:08.520000Z", "updated": "2023-09-15T17:17:39.462854Z", "owner": 1, + "product": 1, # TODO: this might be right, we just expecting different error code "accepted_findings": [ 4, ], diff --git a/unittests/test_risk_acceptance.py b/unittests/test_risk_acceptance.py index b31389bc432..74f50a1e9ad 100644 --- a/unittests/test_risk_acceptance.py +++ b/unittests/test_risk_acceptance.py @@ -50,9 +50,9 @@ def setUp(self): self.system_settings(enable_jira=True) self.client.force_login(self.get_test_admin()) - def add_risk_acceptance(self, eid, data_risk_accceptance, fid=None): + def add_risk_acceptance(self, pid, data_risk_accceptance, fid=None): - args = (eid, fid) if fid else (eid,) + args = (pid, fid) if fid else (pid,) response = self.client.post(reverse("add_risk_acceptance", args=args), data_risk_accceptance) self.assertEqual(302, response.status_code, response.content[:1000]) @@ -74,7 +74,7 @@ def test_add_risk_acceptance_single_findings_accepted(self): ra_data = copy.copy(self.data_risk_accceptance) ra_data["accepted_findings"] = [2] ra_data["return_url"] = reverse("view_finding", args=(2, )) - response = self.add_risk_acceptance(1, ra_data, 2) + response = self.add_risk_acceptance(2, ra_data, 2) # product_id=2 self.assertEqual("/finding/2", response.url) ra = Risk_Acceptance.objects.last() self.assert_all_active_not_risk_accepted(ra.accepted_findings.all()) @@ -82,8 +82,8 @@ def test_add_risk_acceptance_single_findings_accepted(self): def test_add_risk_acceptance_multiple_findings_accepted(self): ra_data = copy.copy(self.data_risk_accceptance) ra_data["accepted_findings"] = [2, 3] - response = self.add_risk_acceptance(1, ra_data) - self.assertEqual("/engagement/1", response.url) + response = self.add_risk_acceptance(2, ra_data) # product_id=2 + self.assertEqual("/product/2", response.url) ra = Risk_Acceptance.objects.last() self.assert_all_active_not_risk_accepted(ra.accepted_findings.all()) @@ -97,7 +97,7 @@ def test_add_findings_to_risk_acceptance_findings_accepted(self): "accepted_findings": [4, 5], } - response = self.client.post(reverse("view_risk_acceptance", args=(1, ra.id)), + response = self.client.post(reverse("view_risk_acceptance", args=(ra.id,)), urlencode(MultiValueDict(data_add_findings_to_ra), doseq=True), content_type="application/x-www-form-urlencoded") @@ -111,7 +111,7 @@ def test_remove_findings_from_risk_acceptance_findings_active(self): data = copy.copy(self.data_remove_finding_from_ra) data["remove_finding_id"] = 2 ra = Risk_Acceptance.objects.last() - response = self.client.post(reverse("view_risk_acceptance", args=(1, ra.id)), data) + response = self.client.post(reverse("view_risk_acceptance", args=(ra.id,)), data) self.assertEqual(302, response.status_code, response.content[:1000]) self.assert_all_active_not_risk_accepted(Finding.objects.filter(id=2)) self.assert_all_inactive_risk_accepted(Finding.objects.filter(id=3)) @@ -124,7 +124,7 @@ def test_remove_risk_acceptance_findings_active(self): data = {"id": ra.id} - self.client.post(reverse("delete_risk_acceptance", args=(1, ra.id)), data) + self.client.post(reverse("delete_risk_acceptance", args=(ra.id,)), data) self.assert_all_active_not_risk_accepted(findings) self.assert_all_active_not_risk_accepted(Finding.objects.filter(test__engagement=1)) @@ -139,7 +139,7 @@ def test_expire_risk_acceptance_findings_active(self): data = {"id": ra.id} - self.client.post(reverse("expire_risk_acceptance", args=(1, ra.id)), data) + self.client.post(reverse("expire_risk_acceptance", args=(ra.id,)), data) ra.refresh_from_db() self.assert_all_active_not_risk_accepted(findings) @@ -161,7 +161,7 @@ def test_expire_risk_acceptance_findings_not_active(self): data = {"id": ra.id} - self.client.post(reverse("expire_risk_acceptance", args=(1, ra.id)), data) + self.client.post(reverse("expire_risk_acceptance", args=(ra.id,)), data) ra.refresh_from_db() # no reactivation on expiry @@ -184,7 +184,7 @@ def test_expire_risk_acceptance_sla_not_reset(self): data = {"id": ra.id} - self.client.post(reverse("expire_risk_acceptance", args=(1, ra.id)), data) + self.client.post(reverse("expire_risk_acceptance", args=(ra.id,)), data) ra.refresh_from_db() @@ -200,7 +200,7 @@ def test_expire_risk_acceptance_sla_reset(self): data = {"id": ra.id} - self.client.post(reverse("expire_risk_acceptance", args=(1, ra.id)), data) + self.client.post(reverse("expire_risk_acceptance", args=(ra.id,)), data) ra.refresh_from_db() @@ -215,7 +215,7 @@ def test_reinstate_risk_acceptance_findings_accepted(self): data = {"id": ra.id} - self.client.post(reverse("reinstate_risk_acceptance", args=(1, ra.id)), data) + self.client.post(reverse("reinstate_risk_acceptance", args=(ra.id,)), data) ra.refresh_from_db() expiration_delta_days = get_system_setting("risk_acceptance_form_default_days", 90) @@ -233,19 +233,19 @@ def create_multiple_ras(self): ra_data = copy.copy(self.data_risk_accceptance) ra_data["accepted_findings"] = [2] ra_data["return_url"] = reverse("view_finding", args=(2, )) - self.add_risk_acceptance(1, ra_data, 2) + self.add_risk_acceptance(2, ra_data, 2) # product_id=2 ra1 = Risk_Acceptance.objects.last() ra_data = copy.copy(self.data_risk_accceptance) ra_data["accepted_findings"] = [7] ra_data["return_url"] = reverse("view_finding", args=(7, )) - self.add_risk_acceptance(1, ra_data, 7) + self.add_risk_acceptance(2, ra_data, 7) # product_id=2 ra2 = Risk_Acceptance.objects.last() ra_data = copy.copy(self.data_risk_accceptance) ra_data["accepted_findings"] = [22] ra_data["return_url"] = reverse("view_finding", args=(22, )) - self.add_risk_acceptance(3, ra_data, 22) + self.add_risk_acceptance(2, ra_data, 22) # product_id=2 (engagement 3 is also in product 2) ra3 = Risk_Acceptance.objects.last() return ra1, ra2, ra3 diff --git a/unittests/test_view_engineer_metrics.py b/unittests/test_view_engineer_metrics.py index fb0d9c3a3f5..c6928950490 100644 --- a/unittests/test_view_engineer_metrics.py +++ b/unittests/test_view_engineer_metrics.py @@ -235,6 +235,7 @@ def test_view_engineer_risk_acceptance_metrics(self, mock_now): decision="A", owner=self.user1, created=fixed_now, + product=finding.test.engagement.product, ) risk_acceptance.accepted_findings.add(finding)