Skip to content

Commit 2fc71eb

Browse files
close finding: sync api and ui behaviour (#13230)
* close finding: sync api and ui behaviour * deduplicate code into common method * use Finding_Edit permission * restore schema * fix test
1 parent dc761aa commit 2fc71eb

6 files changed

Lines changed: 224 additions & 110 deletions

File tree

dojo/api_v2/serializers.py

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@
2222
from rest_framework.exceptions import ValidationError as RestFrameworkValidationError
2323
from rest_framework.fields import DictField, MultipleChoiceField
2424

25+
import dojo.finding.helper as finding_helper
2526
import dojo.jira_link.helper as jira_helper
2627
import dojo.risk_acceptance.helper as ra_helper
2728
from dojo.authorization.authorization import user_has_permission
@@ -122,6 +123,7 @@
122123
requires_file,
123124
requires_tool_type,
124125
)
126+
from dojo.user.queries import get_authorized_users
125127
from dojo.user.utils import get_configuration_permissions_codenames
126128
from dojo.utils import is_scan_file_too_large
127129
from dojo.validators import ImporterFileExtensionValidator, tag_validator
@@ -2697,6 +2699,9 @@ class FindingCloseSerializer(serializers.ModelSerializer):
26972699
false_p = serializers.BooleanField(required=False)
26982700
out_of_scope = serializers.BooleanField(required=False)
26992701
duplicate = serializers.BooleanField(required=False)
2702+
mitigated_by = serializers.PrimaryKeyRelatedField(required=False, allow_null=True, queryset=Dojo_User.objects.all())
2703+
note = serializers.CharField(required=False, allow_blank=True)
2704+
note_type = serializers.PrimaryKeyRelatedField(required=False, allow_null=True, queryset=Note_Type.objects.all())
27002705

27012706
class Meta:
27022707
model = Finding
@@ -2706,8 +2711,34 @@ class Meta:
27062711
"false_p",
27072712
"out_of_scope",
27082713
"duplicate",
2714+
"mitigated_by",
2715+
"note",
2716+
"note_type",
27092717
)
27102718

2719+
def validate(self, data):
2720+
request = self.context.get("request")
2721+
request_user = getattr(request, "user", None)
2722+
2723+
mitigated_by_user = data.get("mitigated_by")
2724+
if mitigated_by_user is not None:
2725+
# Require permission to edit mitigated metadata
2726+
if not (request_user and finding_helper.can_edit_mitigated_data(request_user)):
2727+
raise serializers.ValidationError({
2728+
"mitigated_by": ["Not allowed to set mitigated_by."],
2729+
})
2730+
2731+
# Ensure selected user is authorized (Finding_Edit)
2732+
authorized_users = get_authorized_users(Permissions.Finding_Edit, user=request_user)
2733+
if not authorized_users.filter(id=mitigated_by_user.id).exists():
2734+
raise serializers.ValidationError({
2735+
"mitigated_by": [
2736+
"Selected user is not authorized to be set as mitigated_by.",
2737+
],
2738+
})
2739+
2740+
return data
2741+
27112742

27122743
class ReportGenerateOptionSerializer(serializers.Serializer):
27132744
include_finding_notes = serializers.BooleanField(default=False)

dojo/api_v2/views.py

Lines changed: 15 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@
3131
from rest_framework.permissions import DjangoModelPermissions, IsAuthenticated
3232
from rest_framework.response import Response
3333

34+
import dojo.finding.helper as finding_helper
3435
import dojo.jira_link.helper as jira_helper
3536
from dojo.api_v2 import (
3637
mixins as dojo_mixins,
@@ -924,49 +925,27 @@ def close(self, request, pk=None):
924925
if request.method == "POST":
925926
finding_close = serializers.FindingCloseSerializer(
926927
data=request.data,
928+
context={"request": request},
927929
)
928930
if finding_close.is_valid():
929-
finding.is_mitigated = finding_close.validated_data[
930-
"is_mitigated"
931-
]
932-
if settings.EDITABLE_MITIGATED_DATA:
933-
finding.mitigated = (
934-
finding_close.validated_data["mitigated"]
935-
or timezone.now()
936-
)
937-
else:
938-
finding.mitigated = timezone.now()
939-
finding.mitigated_by = request.user
940-
finding.active = False
941-
finding.false_p = finding_close.validated_data.get(
942-
"false_p", False,
943-
)
944-
finding.duplicate = finding_close.validated_data.get(
945-
"duplicate", False,
946-
)
947-
finding.out_of_scope = finding_close.validated_data.get(
948-
"out_of_scope", False,
931+
# Use shared helper to perform close operations
932+
finding_helper.close_finding(
933+
finding=finding,
934+
user=request.user,
935+
is_mitigated=finding_close.validated_data["is_mitigated"],
936+
mitigated=(finding_close.validated_data.get("mitigated") if settings.EDITABLE_MITIGATED_DATA else timezone.now()),
937+
mitigated_by=finding_close.validated_data.get("mitigated_by") or (request.user if not finding_helper.can_edit_mitigated_data(request.user) else None),
938+
false_p=finding_close.validated_data.get("false_p", False),
939+
out_of_scope=finding_close.validated_data.get("out_of_scope", False),
940+
duplicate=finding_close.validated_data.get("duplicate", False),
941+
note_entry=finding_close.validated_data.get("note"),
942+
note_type=finding_close.validated_data.get("note_type"),
949943
)
950-
951-
endpoints_status = finding.status_finding.all()
952-
for e_status in endpoints_status:
953-
e_status.mitigated_by = request.user
954-
if settings.EDITABLE_MITIGATED_DATA:
955-
e_status.mitigated_time = (
956-
finding_close.validated_data["mitigated"]
957-
or timezone.now()
958-
)
959-
else:
960-
e_status.mitigated_time = timezone.now()
961-
e_status.mitigated = True
962-
e_status.last_modified = timezone.now()
963-
e_status.save()
964-
finding.save()
965944
else:
966945
return Response(
967946
finding_close.errors, status=status.HTTP_400_BAD_REQUEST,
968947
)
969-
serialized_finding = serializers.FindingCloseSerializer(finding)
948+
serialized_finding = serializers.FindingCloseSerializer(finding, context={"request": request})
970949
return Response(serialized_finding.data)
971950

972951
@extend_schema(

dojo/finding/helper.py

Lines changed: 93 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,10 +7,12 @@
77
from django.db.models.signals import post_delete, pre_delete
88
from django.db.utils import IntegrityError
99
from django.dispatch.dispatcher import receiver
10+
from django.urls import reverse
1011
from django.utils import timezone
1112
from fieldsignals import pre_save_changed
1213

1314
import dojo.jira_link.helper as jira_helper
15+
import dojo.risk_acceptance.helper as ra_helper
1416
from dojo.celery import app
1517
from dojo.decorators import dojo_async_task, dojo_model_from_id, dojo_model_to_id
1618
from dojo.endpoint.utils import save_endpoints_to_add
@@ -21,15 +23,18 @@
2123
Engagement,
2224
Finding,
2325
Finding_Group,
26+
Notes,
2427
System_Settings,
2528
Test,
2629
Vulnerability_Id,
2730
Vulnerability_Id_Template,
2831
)
2932
from dojo.notes.helper import delete_related_notes
33+
from dojo.notifications.helper import create_notification
3034
from dojo.tools import tool_issue_updater
3135
from dojo.utils import (
3236
calculate_grade,
37+
close_external_issue,
3338
do_dedupe_finding,
3439
do_false_positive_history,
3540
get_current_user,
@@ -271,7 +276,6 @@ def get_group_by_group_name(finding, finding_group_by_option):
271276
else:
272277
msg = f"Invalid group_by option {finding_group_by_option}"
273278
raise ValueError(msg)
274-
275279
if group_name:
276280
return f"Findings in: {group_name}"
277281

@@ -689,3 +693,91 @@ def save_vulnerability_ids_template(finding_template, vulnerability_ids):
689693
finding_template.cve = vulnerability_ids[0]
690694
else:
691695
finding_template.cve = None
696+
697+
698+
def close_finding(
699+
*,
700+
finding,
701+
user,
702+
is_mitigated,
703+
mitigated,
704+
mitigated_by,
705+
false_p,
706+
out_of_scope,
707+
duplicate,
708+
note_entry=None,
709+
note_type=None,
710+
) -> None:
711+
"""
712+
Shared close logic used by UI and API.
713+
714+
Handles status updates, endpoint statuses, risk acceptance, external issues,
715+
JIRA sync, and notification.
716+
"""
717+
# Core status updates
718+
finding.is_mitigated = is_mitigated
719+
now = timezone.now()
720+
finding.mitigated = mitigated or now
721+
finding.mitigated_by = mitigated_by or user
722+
finding.active = False
723+
finding.false_p = bool(false_p)
724+
finding.out_of_scope = bool(out_of_scope)
725+
finding.duplicate = bool(duplicate)
726+
finding.under_review = False
727+
finding.last_reviewed = finding.mitigated
728+
finding.last_reviewed_by = user
729+
730+
# Create note if provided
731+
new_note = None
732+
if note_entry:
733+
new_note = Notes.objects.create(
734+
entry=note_entry,
735+
author=user,
736+
note_type=note_type,
737+
date=finding.mitigated,
738+
)
739+
finding.notes.add(new_note)
740+
741+
# Endpoint statuses
742+
for status in finding.status_finding.all():
743+
status.mitigated_by = finding.mitigated_by
744+
status.mitigated_time = finding.mitigated
745+
status.mitigated = True
746+
status.last_modified = timezone.now()
747+
status.save()
748+
749+
# Risk acceptance
750+
ra_helper.risk_unaccept(user, finding, perform_save=False)
751+
752+
# External issues (best effort)
753+
close_external_issue(finding, "Closed by defectdojo", "github")
754+
755+
# JIRA sync
756+
push_to_jira = False
757+
finding_in_group = finding.has_finding_group
758+
jira_issue_exists = finding.has_jira_issue or (
759+
finding.finding_group and finding.finding_group.has_jira_issue
760+
)
761+
jira_instance = jira_helper.get_jira_instance(finding)
762+
jira_project = jira_helper.get_jira_project(finding)
763+
if jira_issue_exists:
764+
push_to_jira = (
765+
jira_helper.is_push_all_issues(finding)
766+
or (jira_instance and jira_instance.finding_jira_sync)
767+
)
768+
if new_note and (getattr(jira_project, "push_notes", False) or push_to_jira) and not finding_in_group:
769+
jira_helper.add_comment(finding, new_note, force_push=True)
770+
771+
# Persist and push JIRA if applicable
772+
finding.save(push_to_jira=(push_to_jira and not finding_in_group))
773+
if push_to_jira and finding_in_group:
774+
jira_helper.push_to_jira(finding.finding_group)
775+
776+
# Notification
777+
create_notification(
778+
event="finding_closed",
779+
title=f"Closing of {finding.title}",
780+
finding=finding,
781+
description=f'The finding "{finding.title}" was closed by {user}',
782+
url=reverse("view_finding", args=(finding.id,)),
783+
)

dojo/finding/views.py

Lines changed: 13 additions & 70 deletions
Original file line numberDiff line numberDiff line change
@@ -112,7 +112,6 @@
112112
add_success_message_to_response,
113113
apply_cwe_to_template,
114114
calculate_grade,
115-
close_external_issue,
116115
do_false_positive_history,
117116
get_page_items,
118117
get_page_items_and_count,
@@ -1140,68 +1139,22 @@ def close_finding(request, fid):
11401139
if request.method == "POST":
11411140
form = CloseFindingForm(request.POST, missing_note_types=missing_note_types)
11421141

1143-
close_external_issue(finding, "Closed by defectdojo", "github")
1144-
11451142
if form.is_valid():
1146-
now = timezone.now()
1147-
new_note = form.save(commit=False)
1148-
new_note.author = request.user
1149-
new_note.date = form.cleaned_data.get("mitigated") or now
1150-
new_note.save()
1151-
finding.notes.add(new_note)
1152-
1153-
messages.add_message(
1154-
request, messages.SUCCESS, "Note Saved.", extra_tags="alert-success",
1155-
)
1143+
messages.add_message(request, messages.SUCCESS, "Note Saved.", extra_tags="alert-success")
11561144

11571145
if len(missing_note_types) <= 1:
1158-
finding.active = False
1159-
now = timezone.now()
1160-
finding.mitigated = form.cleaned_data.get("mitigated") or now
1161-
finding.mitigated_by = (
1162-
form.cleaned_data.get("mitigated_by") or request.user
1146+
finding_helper.close_finding(
1147+
finding=finding,
1148+
user=request.user,
1149+
is_mitigated=True,
1150+
mitigated=form.cleaned_data.get("mitigated"),
1151+
mitigated_by=form.cleaned_data.get("mitigated_by") or request.user,
1152+
false_p=form.cleaned_data.get("false_p", False),
1153+
out_of_scope=form.cleaned_data.get("out_of_scope", False),
1154+
duplicate=form.cleaned_data.get("duplicate", False),
1155+
note_entry=form.cleaned_data.get("entry"),
1156+
note_type=form.cleaned_data.get("note_type"),
11631157
)
1164-
finding.is_mitigated = True
1165-
finding.under_review = False
1166-
finding.last_reviewed = finding.mitigated
1167-
finding.last_reviewed_by = request.user
1168-
finding.false_p = form.cleaned_data.get("false_p", False)
1169-
finding.out_of_scope = form.cleaned_data.get("out_of_scope", False)
1170-
finding.duplicate = form.cleaned_data.get("duplicate", False)
1171-
endpoint_status = finding.status_finding.all()
1172-
for status in endpoint_status:
1173-
status.mitigated_by = (
1174-
form.cleaned_data.get("mitigated_by") or request.user
1175-
)
1176-
status.mitigated_time = form.cleaned_data.get("mitigated") or now
1177-
status.mitigated = True
1178-
status.last_modified = timezone.now()
1179-
status.save()
1180-
# Clear the risk acceptance, if present
1181-
ra_helper.risk_unaccept(request.user, finding)
1182-
1183-
# Manage the jira status changes
1184-
push_to_jira = False
1185-
# Determine if the finding is in a group. if so, not push to jira
1186-
finding_in_group = finding.has_finding_group
1187-
# Check if there is a jira issue that needs to be updated
1188-
jira_issue_exists = finding.has_jira_issue or (finding.finding_group and finding.finding_group.has_jira_issue)
1189-
# fetch the project
1190-
jira_instance = jira_helper.get_jira_instance(finding)
1191-
jira_project = jira_helper.get_jira_project(finding)
1192-
# Only push if the finding is not in a group
1193-
if jira_issue_exists:
1194-
# Determine if any automatic sync should occur
1195-
push_to_jira = jira_helper.is_push_all_issues(finding) or jira_instance.finding_jira_sync
1196-
# Add the closing note
1197-
if (jira_project.push_notes or push_to_jira) and not finding_in_group:
1198-
jira_helper.add_comment(finding, new_note, force_push=True)
1199-
# Save the finding
1200-
finding.save(push_to_jira=(push_to_jira and not finding_in_group))
1201-
# we only push the group after saving the finding to make sure
1202-
# the updated data of the finding is pushed as part of the group
1203-
if push_to_jira and finding_in_group:
1204-
jira_helper.push_to_jira(finding.finding_group)
12051158

12061159
messages.add_message(
12071160
request,
@@ -1210,17 +1163,7 @@ def close_finding(request, fid):
12101163
extra_tags="alert-success",
12111164
)
12121165

1213-
# Note: this notification has not be moved to "@receiver(pre_save, sender=Finding)" method as many other notifications
1214-
# Because it could generate too much noise, we keep it here only for findings created by hand in WebUI
1215-
# TODO: but same should be implemented for API endpoint
1216-
1217-
create_notification(
1218-
event="finding_closed",
1219-
title=_("Closing of %s") % finding.title,
1220-
finding=finding,
1221-
description=f'The finding "{finding.title}" was closed by {request.user}',
1222-
url=reverse("view_finding", args=(finding.id,)),
1223-
)
1166+
# Notification sent by helper
12241167
return HttpResponseRedirect(
12251168
reverse("view_test", args=(finding.test.id,)),
12261169
)

dojo/forms.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1470,7 +1470,7 @@ def __init__(self, *args, **kwargs):
14701470
super().__init__(*args, **kwargs)
14711471

14721472
self.fields["endpoints"].queryset = Endpoint.objects.filter(product=self.instance.test.engagement.product)
1473-
self.fields["mitigated_by"].queryset = get_authorized_users(Permissions.Test_Edit)
1473+
self.fields["mitigated_by"].queryset = get_authorized_users(Permissions.Finding_Edit)
14741474

14751475
# do not show checkbox if finding is not accepted and simple risk acceptance is disabled
14761476
# if checked, always show to allow unaccept also with full risk acceptance enabled
@@ -1908,7 +1908,7 @@ def __init__(self, *args, **kwargs):
19081908
else False
19091909

19101910
if self.can_edit_mitigated_data:
1911-
self.fields["mitigated_by"].queryset = get_authorized_users(Permissions.Test_Edit)
1911+
self.fields["mitigated_by"].queryset = get_authorized_users(Permissions.Finding_Edit)
19121912
self.fields["mitigated"].initial = self.instance.mitigated
19131913
self.fields["mitigated_by"].initial = self.instance.mitigated_by
19141914
if disclaimer := get_system_setting("disclaimer_notes"):

0 commit comments

Comments
 (0)