Skip to content

Commit ebe082a

Browse files
fopinafopinappb
authored andcommitted
Quick verify in menu and keyboard shortcuts to verify/close findings (DefectDojo#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 5c0f84b commit ebe082a

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
@@ -3022,6 +3022,11 @@ def validate(self, data):
30223022
return data
30233023

30243024

3025+
class FindingVerifySerializer(serializers.Serializer):
3026+
note = serializers.CharField(required=False, allow_blank=True)
3027+
note_type = serializers.PrimaryKeyRelatedField(required=False, allow_null=True, queryset=Note_Type.objects.all())
3028+
3029+
30253030
class ReportGenerateOptionSerializer(serializers.Serializer):
30263031
include_finding_notes = serializers.BooleanField(default=False)
30273032
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
@@ -1088,6 +1088,32 @@ def close(self, request, pk=None):
10881088
serialized_finding = serializers.FindingCloseSerializer(finding, context={"request": request})
10891089
return Response(serialized_finding.data)
10901090

1091+
@extend_schema(
1092+
methods=["POST"],
1093+
request=serializers.FindingVerifySerializer,
1094+
responses={status.HTTP_200_OK: serializers.FindingSerializer},
1095+
)
1096+
@action(detail=True, methods=["post"], permission_classes=(IsAuthenticated, permissions.UserHasFindingRelatedObjectPermission))
1097+
def verify(self, request, pk=None):
1098+
finding = self.get_object()
1099+
1100+
serializer = serializers.FindingVerifySerializer(data=request.data)
1101+
if not serializer.is_valid():
1102+
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
1103+
1104+
# Remove prefetched tags to keep queryset state in sync
1105+
finding.tags._remove_prefetched_objects()
1106+
1107+
finding_helper.verify_finding(
1108+
finding=finding,
1109+
user=request.user,
1110+
note_entry=serializer.validated_data.get("note"),
1111+
note_type=serializer.validated_data.get("note_type"),
1112+
)
1113+
1114+
serialized_finding = serializers.FindingSerializer(finding, context={"request": request})
1115+
return Response(serialized_finding.data)
1116+
10911117
@extend_schema(
10921118
methods=["GET"],
10931119
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
@@ -1122,6 +1122,54 @@ def normalize_datetime(value):
11221122
return value
11231123

11241124

1125+
def _create_note_if_provided(
1126+
finding,
1127+
note_entry,
1128+
*,
1129+
user=None,
1130+
note_type=None,
1131+
note_date=None,
1132+
):
1133+
"""
1134+
Create a note for the finding when content is provided. Returns the note or None.
1135+
Note author defaults to finding.last_reviewed_by
1136+
"""
1137+
if not note_entry:
1138+
return None
1139+
1140+
new_note = Notes.objects.create(
1141+
entry=note_entry,
1142+
author=user or finding.last_reviewed_by,
1143+
note_type=note_type,
1144+
date=note_date,
1145+
)
1146+
finding.notes.add(new_note)
1147+
return new_note
1148+
1149+
1150+
def _save_finding_with_jira_sync(finding, *, new_note=None):
1151+
"""Persist finding and apply JIRA sync behavior used by finding status actions."""
1152+
push_to_jira = False
1153+
finding_in_group = finding.has_finding_group
1154+
jira_issue_exists = finding.has_jira_issue or (
1155+
finding.finding_group and finding.finding_group.has_jira_issue
1156+
)
1157+
jira_instance = jira_helper.get_jira_instance(finding)
1158+
jira_project = jira_helper.get_jira_project(finding)
1159+
1160+
if jira_issue_exists:
1161+
push_to_jira = (
1162+
jira_helper.is_push_all_issues(finding)
1163+
or (jira_instance and jira_instance.finding_jira_sync)
1164+
)
1165+
if new_note and (getattr(jira_project, "push_notes", False) or push_to_jira) and not finding_in_group:
1166+
jira_helper.add_comment(finding, new_note, force_push=True)
1167+
1168+
finding.save(push_to_jira=(push_to_jira and not finding_in_group))
1169+
if push_to_jira and finding_in_group:
1170+
jira_helper.push_to_jira(finding.finding_group)
1171+
1172+
11251173
def close_finding(
11261174
*,
11271175
finding,
@@ -1156,15 +1204,12 @@ def close_finding(
11561204
finding.last_reviewed_by = user
11571205

11581206
# Create note if provided
1159-
new_note = None
1160-
if note_entry:
1161-
new_note = Notes.objects.create(
1162-
entry=note_entry,
1163-
author=user,
1164-
note_type=note_type,
1165-
date=mitigated_date,
1166-
)
1167-
finding.notes.add(new_note)
1207+
new_note = _create_note_if_provided(
1208+
finding,
1209+
note_entry,
1210+
note_type=note_type,
1211+
note_date=mitigated_date,
1212+
)
11681213

11691214
if settings.V3_FEATURE_LOCATIONS:
11701215
# Related locations
@@ -1186,26 +1231,7 @@ def close_finding(
11861231
# External issues (best effort)
11871232
close_external_issue(finding.id, "Closed by defectdojo", "github")
11881233

1189-
# JIRA sync
1190-
push_to_jira = False
1191-
finding_in_group = finding.has_finding_group
1192-
jira_issue_exists = finding.has_jira_issue or (
1193-
finding.finding_group and finding.finding_group.has_jira_issue
1194-
)
1195-
jira_instance = jira_helper.get_jira_instance(finding)
1196-
jira_project = jira_helper.get_jira_project(finding)
1197-
if jira_issue_exists:
1198-
push_to_jira = (
1199-
jira_helper.is_push_all_issues(finding)
1200-
or (jira_instance and jira_instance.finding_jira_sync)
1201-
)
1202-
if new_note and (getattr(jira_project, "push_notes", False) or push_to_jira) and not finding_in_group:
1203-
jira_helper.add_comment(finding, new_note, force_push=True)
1204-
1205-
# Persist and push JIRA if applicable
1206-
finding.save(push_to_jira=(push_to_jira and not finding_in_group))
1207-
if push_to_jira and finding_in_group:
1208-
jira_helper.push_to_jira(finding.finding_group)
1234+
_save_finding_with_jira_sync(finding, new_note=new_note)
12091235

12101236
# Notification
12111237
create_notification(
@@ -1215,3 +1241,28 @@ def close_finding(
12151241
description=f'The finding "{finding.title}" was closed by {user}',
12161242
url=reverse("view_finding", args=(finding.id,)),
12171243
)
1244+
1245+
1246+
def verify_finding(
1247+
*,
1248+
finding,
1249+
user,
1250+
note_entry=None,
1251+
note_type=None,
1252+
) -> None:
1253+
"""Shared verify logic used by UI and API."""
1254+
verification_time = now()
1255+
1256+
finding.verified = True
1257+
finding.last_reviewed = verification_time
1258+
finding.last_reviewed_by = user
1259+
finding.last_status_update = verification_time
1260+
1261+
new_note = _create_note_if_provided(
1262+
finding,
1263+
note_entry,
1264+
note_type=note_type,
1265+
note_date=verification_time,
1266+
)
1267+
1268+
_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
@@ -1191,7 +1198,7 @@ <h4>Credential
11911198
</div>
11921199

11931200
<div class="protip">
1194-
<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.
1201+
<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.
11951202
</div>
11961203
</div>
11971204
{% endblock %}
@@ -1204,6 +1211,9 @@ <h4>Credential
12041211
var firstID = {% if findings_list.0 %}{{findings_list.0}}{% else %}null{% endif %};
12051212
var currentID = {% if finding.id %}{{finding.id}}{% else %}null{% endif %};
12061213
var lastID = {% if findings_list_lastElement %}{{findings_list_lastElement}}{% else %}null{% endif %};
1214+
var canEditFinding = {% if finding|has_object_permission:"Finding_Edit" %}true{% else %}false{% endif %};
1215+
var findingIsMitigated = {% if finding.mitigated %}true{% else %}false{% endif %};
1216+
var findingIsVerified = {% if finding.verified %}true{% else %}false{% endif %};
12071217
if(currentID != firstID)
12081218
{
12091219
$('.PrevAndNext_Buttons').append('<a href="{% url 'view_finding' prev_finding_id %}" class="btn btn-primary">Previous Finding</a> ');
@@ -1283,6 +1293,34 @@ <h4>Credential
12831293
window.location.assign('{% url 'view_finding' next_finding_id %}');
12841294
});
12851295

1296+
$(document).on('keypress', null, 'v', function () {
1297+
if (!canEditFinding) {
1298+
alert('You do not have permission to verify this finding.');
1299+
return;
1300+
}
1301+
if (findingIsMitigated) {
1302+
alert('Finding is already closed and cannot be verified.');
1303+
return;
1304+
}
1305+
if (findingIsVerified) {
1306+
alert('Finding has already been verified.');
1307+
return;
1308+
}
1309+
window.location.assign('{% url 'verify_finding' finding.id %}');
1310+
});
1311+
1312+
$(document).on('keypress', null, 'c', function () {
1313+
if (!canEditFinding) {
1314+
alert('You do not have permission to close this finding.');
1315+
return;
1316+
}
1317+
if (findingIsMitigated) {
1318+
alert('Finding has already been closed.');
1319+
return;
1320+
}
1321+
window.location.assign('{% url 'close_finding' finding.id %}');
1322+
});
1323+
12861324
$('a.delete-finding').on('click', function (e) {
12871325
if (confirm('Are you sure you want to delete this finding?')) {
12881326
$("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)