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 @@
{% trans "Components" %}
+
+
+
+ {% trans "Risk Acceptance" %}
+
+
diff --git a/dojo/templates/dojo/custom_html_report_endpoint_list.html b/dojo/templates/dojo/custom_html_report_endpoint_list.html
index aca9cd3bef9..cd7bb826877 100644
--- a/dojo/templates/dojo/custom_html_report_endpoint_list.html
+++ b/dojo/templates/dojo/custom_html_report_endpoint_list.html
@@ -86,7 +86,7 @@
|
{% 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 %}
|
diff --git a/dojo/templates/dojo/custom_html_report_finding_list.html b/dojo/templates/dojo/custom_html_report_finding_list.html
index 13f33d03dca..921f840c706 100644
--- a/dojo/templates/dojo/custom_html_report_finding_list.html
+++ b/dojo/templates/dojo/custom_html_report_finding_list.html
@@ -75,7 +75,7 @@
|
{% 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 %}
|
diff --git a/dojo/templates/dojo/endpoint_pdf_report.html b/dojo/templates/dojo/endpoint_pdf_report.html
index 637527d39ca..240f5b22daf 100644
--- a/dojo/templates/dojo/endpoint_pdf_report.html
+++ b/dojo/templates/dojo/endpoint_pdf_report.html
@@ -148,7 +148,7 @@
|
{% 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 %}
|
diff --git a/dojo/templates/dojo/engagement_pdf_report.html b/dojo/templates/dojo/engagement_pdf_report.html
index e1333b8b48a..e1afd169c09 100644
--- a/dojo/templates/dojo/engagement_pdf_report.html
+++ b/dojo/templates/dojo/engagement_pdf_report.html
@@ -183,7 +183,7 @@ Testing Notes
{% endif %}
{% endif %}
- {% if engagement.risk_acceptance.count > 0 %}
+ {% if engagement.product.risk_acceptances.count > 0 %}
Risk Accepted Findings
@@ -200,7 +200,7 @@ Risk Accepted Findings
EPSS Score / Percentile
- {% for risk in engagement.risk_acceptance.all %}
+ {% for risk in engagement.product.risk_acceptances.all %}
{% for finding in risk.accepted_findings.all %}
| {{ finding.title }} |
@@ -283,7 +283,7 @@
|
{% 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 %}
|
diff --git a/dojo/templates/dojo/finding_pdf_report.html b/dojo/templates/dojo/finding_pdf_report.html
index 79235b1e07f..51472074192 100644
--- a/dojo/templates/dojo/finding_pdf_report.html
+++ b/dojo/templates/dojo/finding_pdf_report.html
@@ -127,7 +127,7 @@
|
{% 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 %}
|
diff --git a/dojo/templates/dojo/findings_list_snippet.html b/dojo/templates/dojo/findings_list_snippet.html
index 31dd287e54b..fd26ba03171 100644
--- a/dojo/templates/dojo/findings_list_snippet.html
+++ b/dojo/templates/dojo/findings_list_snippet.html
@@ -506,7 +506,7 @@
{% endif %}
{% if finding.test.engagement.product.enable_full_risk_acceptance %}
-
+
{% trans "Add Risk Acceptance..." %}
diff --git a/dojo/templates/dojo/product_endpoint_pdf_report.html b/dojo/templates/dojo/product_endpoint_pdf_report.html
index bb26f835628..3e58b1391a3 100644
--- a/dojo/templates/dojo/product_endpoint_pdf_report.html
+++ b/dojo/templates/dojo/product_endpoint_pdf_report.html
@@ -196,7 +196,7 @@
|
{% 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 %}
|
diff --git a/dojo/templates/dojo/product_pdf_report.html b/dojo/templates/dojo/product_pdf_report.html
index 4fec57dee5a..84df6d024fc 100644
--- a/dojo/templates/dojo/product_pdf_report.html
+++ b/dojo/templates/dojo/product_pdf_report.html
@@ -161,7 +161,7 @@ Test Notes
{% endfor %}
{% endif %}
- {% if engagement.risk_acceptance.count > 0 %}
+ {% if engagement.product.risk_acceptances.count > 0 %}
Risk Accepted Findings
@@ -175,7 +175,7 @@ Risk Accepted Findings
Severity
- {% for risk in engagement.risk_acceptance.all %}
+ {% for risk in engagement.product.risk_acceptances.all %}
{% for finding in risk.accepted_findings.all %}
| {{ finding.title }} |
@@ -253,7 +253,7 @@
|
{% 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 %}
|
diff --git a/dojo/templates/dojo/product_type_pdf_report.html b/dojo/templates/dojo/product_type_pdf_report.html
index fba8bf63e28..15db58c930c 100644
--- a/dojo/templates/dojo/product_type_pdf_report.html
+++ b/dojo/templates/dojo/product_type_pdf_report.html
@@ -182,7 +182,7 @@
|
{% 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 %}
|
diff --git a/dojo/templates/dojo/risk_acceptances.html b/dojo/templates/dojo/risk_acceptances.html
new file mode 100644
index 00000000000..fbf71bcdef9
--- /dev/null
+++ b/dojo/templates/dojo/risk_acceptances.html
@@ -0,0 +1,129 @@
+{% extends "base.html" %}
+{% load display_tags %}
+{% load humanize %}
+{% load authorization_tags %}
+{% block content %}
+ {{ block.super }}
+
+
+
+
+
+
+ Risk Acceptances
+ {% if prod|has_object_permission:"Risk_Acceptance" %}
+ {% if prod.enable_full_risk_acceptance %}
+
+
+
+ {% endif %}
+ {% endif %}
+
+
+
+ {% if risk_acceptances %}
+
+
+
+
+ | Name |
+ Owner |
+ Accepted Findings |
+ Expiration Date |
+ Status |
+ Created |
+ Actions |
+
+
+
+ {% for ra in risk_acceptances %}
+
+ |
+
+ {{ ra.name }}
+
+ |
+ {{ ra.owner }} |
+
+ {{ ra.accepted_findings_count }}
+ |
+
+ {% if ra.expiration_date %}
+ {{ ra.expiration_date|date:"M d, Y" }}
+ {% else %}
+ Never
+ {% endif %}
+ |
+
+ {% if ra.is_expired %}
+ Expired
+ {% elif ra.expiration_date %}
+ Active
+ {% else %}
+ No Expiration
+ {% endif %}
+ |
+ {{ ra.created|date:"M d, Y" }} |
+
+
+
+
+
+ {% if prod|has_object_permission:"Risk_Acceptance" %}
+
+
+
+ {% if not ra.is_expired %}
+
+
+
+ {% else %}
+
+
+
+ {% endif %}
+
+
+
+ {% endif %}
+
+ |
+
+ {% endfor %}
+
+
+
+ {% else %}
+
+
+
No risk acceptances found for this product.
+ {% if prod|has_object_permission:"Risk_Acceptance" and prod.enable_full_risk_acceptance %}
+
+ Add Risk Acceptance
+
+ {% endif %}
+
+
+ {% endif %}
+
+
+
+{% if enable_table_filtering %}
+
+{% endif %}
+
+{% endblock %}
\ No newline at end of file
diff --git a/dojo/templates/dojo/snippets/risk_acceptance_actions_snippet.html b/dojo/templates/dojo/snippets/risk_acceptance_actions_snippet.html
index a756818d4bf..440ed930f4a 100644
--- a/dojo/templates/dojo/snippets/risk_acceptance_actions_snippet.html
+++ b/dojo/templates/dojo/snippets/risk_acceptance_actions_snippet.html
@@ -3,7 +3,7 @@
{% if include_view %}
-
+
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
-
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 %}
| {{ finding.title }} |
@@ -295,7 +295,7 @@
|
{% 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 %}
|
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
- | {{ 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 }} |
{% if risk_acceptance.filename %}
- Yes
+ | Yes
|
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 @@