Skip to content

Commit d2b8210

Browse files
fopinafopinappb
andauthored
Quick verify in menu and keyboard shortcuts to verify/close findings (#14318)
* feature: quick verify finding * keyboard shortcuts to verify/close finding * address feedback * sync to JIRA in verify_finding * lint --------- Co-authored-by: Filipe Pina <63779195+fopinappb@users.noreply.github.com>
1 parent 665914d commit d2b8210

8 files changed

Lines changed: 261 additions & 31 deletions

File tree

dojo/api_v2/serializers.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2994,6 +2994,11 @@ def validate(self, data):
29942994
return data
29952995

29962996

2997+
class FindingVerifySerializer(serializers.Serializer):
2998+
note = serializers.CharField(required=False, allow_blank=True)
2999+
note_type = serializers.PrimaryKeyRelatedField(required=False, allow_null=True, queryset=Note_Type.objects.all())
3000+
3001+
29973002
class ReportGenerateOptionSerializer(serializers.Serializer):
29983003
include_finding_notes = serializers.BooleanField(default=False)
29993004
include_finding_images = serializers.BooleanField(default=False)

dojo/api_v2/views.py

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1024,6 +1024,32 @@ def close(self, request, pk=None):
10241024
serialized_finding = serializers.FindingCloseSerializer(finding, context={"request": request})
10251025
return Response(serialized_finding.data)
10261026

1027+
@extend_schema(
1028+
methods=["POST"],
1029+
request=serializers.FindingVerifySerializer,
1030+
responses={status.HTTP_200_OK: serializers.FindingSerializer},
1031+
)
1032+
@action(detail=True, methods=["post"], permission_classes=(IsAuthenticated, permissions.UserHasFindingRelatedObjectPermission))
1033+
def verify(self, request, pk=None):
1034+
finding = self.get_object()
1035+
1036+
serializer = serializers.FindingVerifySerializer(data=request.data)
1037+
if not serializer.is_valid():
1038+
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
1039+
1040+
# Remove prefetched tags to keep queryset state in sync
1041+
finding.tags._remove_prefetched_objects()
1042+
1043+
finding_helper.verify_finding(
1044+
finding=finding,
1045+
user=request.user,
1046+
note_entry=serializer.validated_data.get("note"),
1047+
note_type=serializer.validated_data.get("note_type"),
1048+
)
1049+
1050+
serialized_finding = serializers.FindingSerializer(finding, context={"request": request})
1051+
return Response(serialized_finding.data)
1052+
10271053
@extend_schema(
10281054
methods=["GET"],
10291055
responses={status.HTTP_200_OK: serializers.TagSerializer},

dojo/finding/helper.py

Lines changed: 80 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -1012,6 +1012,54 @@ def normalize_datetime(value):
10121012
return value
10131013

10141014

1015+
def _create_note_if_provided(
1016+
finding,
1017+
note_entry,
1018+
*,
1019+
user=None,
1020+
note_type=None,
1021+
note_date=None,
1022+
):
1023+
"""
1024+
Create a note for the finding when content is provided. Returns the note or None.
1025+
Note author defaults to finding.last_reviewed_by
1026+
"""
1027+
if not note_entry:
1028+
return None
1029+
1030+
new_note = Notes.objects.create(
1031+
entry=note_entry,
1032+
author=user or finding.last_reviewed_by,
1033+
note_type=note_type,
1034+
date=note_date,
1035+
)
1036+
finding.notes.add(new_note)
1037+
return new_note
1038+
1039+
1040+
def _save_finding_with_jira_sync(finding, *, new_note=None):
1041+
"""Persist finding and apply JIRA sync behavior used by finding status actions."""
1042+
push_to_jira = False
1043+
finding_in_group = finding.has_finding_group
1044+
jira_issue_exists = finding.has_jira_issue or (
1045+
finding.finding_group and finding.finding_group.has_jira_issue
1046+
)
1047+
jira_instance = jira_helper.get_jira_instance(finding)
1048+
jira_project = jira_helper.get_jira_project(finding)
1049+
1050+
if jira_issue_exists:
1051+
push_to_jira = (
1052+
jira_helper.is_push_all_issues(finding)
1053+
or (jira_instance and jira_instance.finding_jira_sync)
1054+
)
1055+
if new_note and (getattr(jira_project, "push_notes", False) or push_to_jira) and not finding_in_group:
1056+
jira_helper.add_comment(finding, new_note, force_push=True)
1057+
1058+
finding.save(push_to_jira=(push_to_jira and not finding_in_group))
1059+
if push_to_jira and finding_in_group:
1060+
jira_helper.push_to_jira(finding.finding_group)
1061+
1062+
10151063
def close_finding(
10161064
*,
10171065
finding,
@@ -1046,15 +1094,12 @@ def close_finding(
10461094
finding.last_reviewed_by = user
10471095

10481096
# Create note if provided
1049-
new_note = None
1050-
if note_entry:
1051-
new_note = Notes.objects.create(
1052-
entry=note_entry,
1053-
author=user,
1054-
note_type=note_type,
1055-
date=mitigated_date,
1056-
)
1057-
finding.notes.add(new_note)
1097+
new_note = _create_note_if_provided(
1098+
finding,
1099+
note_entry,
1100+
note_type=note_type,
1101+
note_date=mitigated_date,
1102+
)
10581103

10591104
if settings.V3_FEATURE_LOCATIONS:
10601105
# Related locations
@@ -1076,26 +1121,7 @@ def close_finding(
10761121
# External issues (best effort)
10771122
close_external_issue(finding.id, "Closed by defectdojo", "github")
10781123

1079-
# JIRA sync
1080-
push_to_jira = False
1081-
finding_in_group = finding.has_finding_group
1082-
jira_issue_exists = finding.has_jira_issue or (
1083-
finding.finding_group and finding.finding_group.has_jira_issue
1084-
)
1085-
jira_instance = jira_helper.get_jira_instance(finding)
1086-
jira_project = jira_helper.get_jira_project(finding)
1087-
if jira_issue_exists:
1088-
push_to_jira = (
1089-
jira_helper.is_push_all_issues(finding)
1090-
or (jira_instance and jira_instance.finding_jira_sync)
1091-
)
1092-
if new_note and (getattr(jira_project, "push_notes", False) or push_to_jira) and not finding_in_group:
1093-
jira_helper.add_comment(finding, new_note, force_push=True)
1094-
1095-
# Persist and push JIRA if applicable
1096-
finding.save(push_to_jira=(push_to_jira and not finding_in_group))
1097-
if push_to_jira and finding_in_group:
1098-
jira_helper.push_to_jira(finding.finding_group)
1124+
_save_finding_with_jira_sync(finding, new_note=new_note)
10991125

11001126
# Notification
11011127
create_notification(
@@ -1105,3 +1131,28 @@ def close_finding(
11051131
description=f'The finding "{finding.title}" was closed by {user}',
11061132
url=reverse("view_finding", args=(finding.id,)),
11071133
)
1134+
1135+
1136+
def verify_finding(
1137+
*,
1138+
finding,
1139+
user,
1140+
note_entry=None,
1141+
note_type=None,
1142+
) -> None:
1143+
"""Shared verify logic used by UI and API."""
1144+
verification_time = now()
1145+
1146+
finding.verified = True
1147+
finding.last_reviewed = verification_time
1148+
finding.last_reviewed_by = user
1149+
finding.last_status_update = verification_time
1150+
1151+
new_note = _create_note_if_provided(
1152+
finding,
1153+
note_entry,
1154+
note_type=note_type,
1155+
note_date=verification_time,
1156+
)
1157+
1158+
_save_finding_with_jira_sync(finding, new_note=new_note)

dojo/finding/urls.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -142,6 +142,8 @@
142142
name="choose_finding_template_options"),
143143
re_path(r"^finding/(?P<fid>\d+)/(?P<tid>\d+)/apply_template_to_finding$",
144144
views.apply_template_to_finding, name="apply_template_to_finding"),
145+
re_path(r"^finding/(?P<fid>\d+)/verify$", views.verify_finding,
146+
name="verify_finding"),
145147
re_path(r"^finding/(?P<fid>\d+)/close$", views.close_finding,
146148
name="close_finding"),
147149
re_path(r"^finding/(?P<fid>\d+)/defect_review$",

dojo/finding/views.py

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1226,6 +1226,65 @@ def close_finding(request, fid):
12261226
)
12271227

12281228

1229+
@user_is_authorized(Finding, Permissions.Finding_Edit, "fid")
1230+
def verify_finding(request, fid):
1231+
finding = get_object_or_404(Finding, id=fid)
1232+
1233+
if finding.verified:
1234+
messages.add_message(
1235+
request,
1236+
messages.INFO,
1237+
"Finding already verified.",
1238+
extra_tags="alert-info",
1239+
)
1240+
return redirect_to_return_url_or_else(
1241+
request,
1242+
reverse("view_finding", args=(finding.id,)),
1243+
)
1244+
1245+
form = NoteForm(data=request.POST or None)
1246+
form.fields["entry"].required = False
1247+
form.fields["entry"].label = _("Comment (optional)")
1248+
1249+
if request.method == "POST" and form.is_valid():
1250+
entry = form.cleaned_data.get("entry", "")
1251+
finding_helper.verify_finding(
1252+
finding=finding,
1253+
user=request.user,
1254+
note_entry=entry,
1255+
)
1256+
1257+
messages.add_message(
1258+
request,
1259+
messages.SUCCESS,
1260+
"Finding verified.",
1261+
extra_tags="alert-success",
1262+
)
1263+
1264+
return redirect_to_return_url_or_else(
1265+
request,
1266+
reverse("view_finding", args=(finding.id,)),
1267+
)
1268+
1269+
product_tab = Product_Tab(
1270+
finding.test.engagement.product,
1271+
title="Verify Finding",
1272+
tab="findings",
1273+
)
1274+
1275+
return render(
1276+
request,
1277+
"dojo/verify_finding.html",
1278+
{
1279+
"finding": finding,
1280+
"product_tab": product_tab,
1281+
"user": request.user,
1282+
"form": form,
1283+
"active_tab": "findings",
1284+
},
1285+
)
1286+
1287+
12291288
@user_is_authorized(Finding, Permissions.Finding_Edit, "fid")
12301289
def defect_finding_review(request, fid):
12311290
finding = get_object_or_404(Finding, id=fid)
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
{% extends "base.html" %}
2+
{% load i18n %}
3+
4+
{% block content %}
5+
{{ block.super }}
6+
<h3>{% trans "Verify Finding" %}</h3>
7+
<h4>{{ finding.title }}</h4>
8+
<p>{% trans "Use this form to mark the finding as verified. Adding a comment is optional." %}</p>
9+
<form class="form-horizontal" action="{% url 'verify_finding' finding.id %}" method="post">
10+
{% csrf_token %}
11+
{% include "dojo/form_fields.html" with form=form %}
12+
<div class="form-group">
13+
<div class="col-sm-offset-2 col-sm-10">
14+
<input class="btn btn-primary" type="submit" value="{% trans "Verify Finding" %}" aria-label="{% trans "Verify Finding" %}"/>
15+
</div>
16+
</div>
17+
</form>
18+
{% endblock %}

dojo/templates/dojo/view_finding.html

Lines changed: 39 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -126,6 +126,13 @@ <h3 class="pull-left finding-title">
126126
</a>
127127
</li>
128128
{% else %}
129+
{% if not finding.verified %}
130+
<li role="presentation">
131+
<a href="{% url 'verify_finding' finding.id %}">
132+
<i class="fa-solid fa-circle-check"></i> Verify Finding
133+
</a>
134+
</li>
135+
{% endif %}
129136
<li role="presentation">
130137
<a href="{% url 'close_finding' finding.id %}">
131138
<i class="fa-solid fa-fire-extinguisher"></i> Close Finding
@@ -1186,7 +1193,7 @@ <h4>Credential
11861193
</div>
11871194

11881195
<div class="protip">
1189-
<i class="fa-solid fa-lightbulb"></i> <strong>ProTip!</strong> Type <kbd>e</kbd> to edit any finding, <kbd>p</kbd> and <kbd>n</kbd> to navigate to the previous or next finding.
1196+
<i class="fa-solid fa-lightbulb"></i> <strong>ProTip!</strong> Type <kbd>e</kbd> to edit any finding, <kbd>p</kbd> and <kbd>n</kbd> to navigate to the previous or next finding, <kbd>v</kbd> to verify, and <kbd>c</kbd> to close the finding.
11901197
</div>
11911198
</div>
11921199
{% endblock %}
@@ -1199,6 +1206,9 @@ <h4>Credential
11991206
var firstID = {% if findings_list.0 %}{{findings_list.0}}{% else %}null{% endif %};
12001207
var currentID = {% if finding.id %}{{finding.id}}{% else %}null{% endif %};
12011208
var lastID = {% if findings_list_lastElement %}{{findings_list_lastElement}}{% else %}null{% endif %};
1209+
var canEditFinding = {% if finding|has_object_permission:"Finding_Edit" %}true{% else %}false{% endif %};
1210+
var findingIsMitigated = {% if finding.mitigated %}true{% else %}false{% endif %};
1211+
var findingIsVerified = {% if finding.verified %}true{% else %}false{% endif %};
12021212
if(currentID != firstID)
12031213
{
12041214
$('.PrevAndNext_Buttons').append('<a href="{% url 'view_finding' prev_finding_id %}" class="btn btn-primary">Previous Finding</a> ');
@@ -1278,6 +1288,34 @@ <h4>Credential
12781288
window.location.assign('{% url 'view_finding' next_finding_id %}');
12791289
});
12801290

1291+
$(document).on('keypress', null, 'v', function () {
1292+
if (!canEditFinding) {
1293+
alert('You do not have permission to verify this finding.');
1294+
return;
1295+
}
1296+
if (findingIsMitigated) {
1297+
alert('Finding is already closed and cannot be verified.');
1298+
return;
1299+
}
1300+
if (findingIsVerified) {
1301+
alert('Finding has already been verified.');
1302+
return;
1303+
}
1304+
window.location.assign('{% url 'verify_finding' finding.id %}');
1305+
});
1306+
1307+
$(document).on('keypress', null, 'c', function () {
1308+
if (!canEditFinding) {
1309+
alert('You do not have permission to close this finding.');
1310+
return;
1311+
}
1312+
if (findingIsMitigated) {
1313+
alert('Finding has already been closed.');
1314+
return;
1315+
}
1316+
window.location.assign('{% url 'close_finding' finding.id %}');
1317+
});
1318+
12811319
$('a.delete-finding').on('click', function (e) {
12821320
if (confirm('Are you sure you want to delete this finding?')) {
12831321
$("form#delete-finding-form").submit();

unittests/test_rest_framework.py

Lines changed: 32 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1011,7 +1011,38 @@ def test_close_finding_pushes_note_to_jira_when_configured(self):
10111011
}
10121012
response = self.client.post(self._close_url(finding.id), payload, format="json")
10131013
self.assertEqual(200, response.status_code, response.content[:1000])
1014-
self.assertTrue(add_comment_mock.called)
1014+
self.assertTrue(add_comment_mock.called)
1015+
1016+
1017+
@versioned_fixtures
1018+
class FindingVerifyAPITest(DojoAPITestCase):
1019+
fixtures = ["dojo_testdata.json"]
1020+
1021+
def setUp(self):
1022+
testuser = User.objects.get(username="admin")
1023+
token = Token.objects.get(user=testuser)
1024+
self.client = APIClient()
1025+
self.client.credentials(HTTP_AUTHORIZATION=f"Token {token.key}")
1026+
self.admin = testuser
1027+
1028+
def _verify_url(self, finding_id: int) -> str:
1029+
return f"/api/v2/findings/{finding_id}/verify/"
1030+
1031+
def test_verify_finding_basic(self):
1032+
finding = Finding.objects.get(id=7)
1033+
response = self.client.post(self._verify_url(finding.id), {"note": "Marked verified"}, format="json")
1034+
self.assertEqual(200, response.status_code, response.content[:1000])
1035+
1036+
finding.refresh_from_db()
1037+
self.assertTrue(finding.verified)
1038+
self.assertEqual(finding.last_reviewed_by, self.admin)
1039+
self.assertTrue(finding.notes.filter(entry__icontains="Marked verified").exists())
1040+
1041+
def test_verify_finding_invalid_payload(self):
1042+
finding = Finding.objects.get(id=7)
1043+
# note_type specified but invalid id
1044+
response = self.client.post(self._verify_url(finding.id), {"note_type": 9999}, format="json")
1045+
self.assertEqual(400, response.status_code, response.content[:1000])
10151046

10161047

10171048
@versioned_fixtures

0 commit comments

Comments
 (0)