From b246fd4232865ec1eba6fded04a971a728d5f725 Mon Sep 17 00:00:00 2001 From: Filipe Pina Date: Fri, 13 Feb 2026 17:32:33 +0000 Subject: [PATCH 1/5] feature: quick verify finding --- dojo/finding/urls.py | 2 + dojo/finding/views.py | 66 +++++++++++++++++++++++++ dojo/templates/dojo/verify_finding.html | 18 +++++++ dojo/templates/dojo/view_finding.html | 7 +++ 4 files changed, 93 insertions(+) create mode 100644 dojo/templates/dojo/verify_finding.html diff --git a/dojo/finding/urls.py b/dojo/finding/urls.py index fa442df384c..75ab68303a4 100644 --- a/dojo/finding/urls.py +++ b/dojo/finding/urls.py @@ -142,6 +142,8 @@ name="choose_finding_template_options"), re_path(r"^finding/(?P\d+)/(?P\d+)/apply_template_to_finding$", views.apply_template_to_finding, name="apply_template_to_finding"), + re_path(r"^finding/(?P\d+)/verify$", views.verify_finding, + name="verify_finding"), re_path(r"^finding/(?P\d+)/close$", views.close_finding, name="close_finding"), re_path(r"^finding/(?P\d+)/defect_review$", diff --git a/dojo/finding/views.py b/dojo/finding/views.py index b5b10faefc6..2db7f3e67ce 100644 --- a/dojo/finding/views.py +++ b/dojo/finding/views.py @@ -1226,6 +1226,72 @@ def close_finding(request, fid): ) +@user_is_authorized(Finding, Permissions.Finding_Edit, "fid") +def verify_finding(request, fid): + finding = get_object_or_404(Finding, id=fid) + + if finding.verified: + messages.add_message( + request, + messages.INFO, + "Finding already verified.", + extra_tags="alert-info", + ) + return redirect_to_return_url_or_else( + request, + reverse("view_finding", args=(finding.id,)), + ) + + form = NoteForm(data=request.POST or None) + form.fields["entry"].required = False + form.fields["entry"].label = _("Comment (optional)") + + if request.method == "POST" and form.is_valid(): + entry = form.cleaned_data.get("entry", "").strip() + if entry: + note = form.save(commit=False) + note.author = request.user + note.save() + finding.notes.add(note) + + now_time = timezone.now() + finding.verified = True + finding.last_reviewed = now_time + finding.last_reviewed_by = request.user + finding.last_status_update = now_time + finding.save(push_to_jira=False) + + messages.add_message( + request, + messages.SUCCESS, + "Finding verified.", + extra_tags="alert-success", + ) + + return redirect_to_return_url_or_else( + request, + reverse("view_finding", args=(finding.id,)), + ) + + product_tab = Product_Tab( + finding.test.engagement.product, + title="Verify Finding", + tab="findings", + ) + + return render( + request, + "dojo/verify_finding.html", + { + "finding": finding, + "product_tab": product_tab, + "user": request.user, + "form": form, + "active_tab": "findings", + }, + ) + + @user_is_authorized(Finding, Permissions.Finding_Edit, "fid") def defect_finding_review(request, fid): finding = get_object_or_404(Finding, id=fid) diff --git a/dojo/templates/dojo/verify_finding.html b/dojo/templates/dojo/verify_finding.html new file mode 100644 index 00000000000..f07cca04143 --- /dev/null +++ b/dojo/templates/dojo/verify_finding.html @@ -0,0 +1,18 @@ +{% extends "base.html" %} +{% load i18n %} + +{% block content %} + {{ block.super }} +

{% trans "Verify Finding" %}

+

{{ finding.title }}

+

{% trans "Use this form to mark the finding as verified. Adding a comment is optional." %}

+
+ {% csrf_token %} + {% include "dojo/form_fields.html" with form=form %} +
+
+ +
+
+
+{% endblock %} diff --git a/dojo/templates/dojo/view_finding.html b/dojo/templates/dojo/view_finding.html index bae3abbe8b8..ae15a8ae17b 100755 --- a/dojo/templates/dojo/view_finding.html +++ b/dojo/templates/dojo/view_finding.html @@ -126,6 +126,13 @@

{% else %} + {% if not finding.verified %} +
  • + + Verify Finding + +
  • + {% endif %}
  • Close Finding From 541e956a440cc8d01ce4bcfbf5d646fababd2dda Mon Sep 17 00:00:00 2001 From: Filipe Pina Date: Fri, 13 Feb 2026 17:38:29 +0000 Subject: [PATCH 2/5] keyboard shortcuts to verify/close finding --- dojo/templates/dojo/view_finding.html | 33 ++++++++++++++++++++++++++- 1 file changed, 32 insertions(+), 1 deletion(-) diff --git a/dojo/templates/dojo/view_finding.html b/dojo/templates/dojo/view_finding.html index ae15a8ae17b..1cf2b7e0691 100755 --- a/dojo/templates/dojo/view_finding.html +++ b/dojo/templates/dojo/view_finding.html @@ -1169,7 +1169,7 @@

    Credential
    - ProTip! Type e to edit any finding, p and n to navigate to the previous or next finding. + ProTip! Type e to edit any finding, p and n to navigate to the previous or next finding, v to verify, and c to close the finding.
    {% endblock %} @@ -1182,6 +1182,9 @@

    Credential var firstID = {% if findings_list.0 %}{{findings_list.0}}{% else %}null{% endif %}; var currentID = {% if finding.id %}{{finding.id}}{% else %}null{% endif %}; var lastID = {% if findings_list_lastElement %}{{findings_list_lastElement}}{% else %}null{% endif %}; + var canEditFinding = {% if finding|has_object_permission:"Finding_Edit" %}true{% else %}false{% endif %}; + var findingIsMitigated = {% if finding.mitigated %}true{% else %}false{% endif %}; + var findingIsVerified = {% if finding.verified %}true{% else %}false{% endif %}; if(currentID != firstID) { $('.PrevAndNext_Buttons').append('Previous Finding '); @@ -1261,6 +1264,34 @@

    Credential window.location.assign('{% url 'view_finding' next_finding_id %}'); }); + $(document).on('keypress', null, 'v', function () { + if (!canEditFinding) { + alert('You do not have permission to verify this finding.'); + return; + } + if (findingIsMitigated) { + alert('Finding is already closed and cannot be verified.'); + return; + } + if (findingIsVerified) { + alert('Finding has already been verified.'); + return; + } + window.location.assign('{% url 'verify_finding' finding.id %}'); + }); + + $(document).on('keypress', null, 'c', function () { + if (!canEditFinding) { + alert('You do not have permission to close this finding.'); + return; + } + if (findingIsMitigated) { + alert('Finding has already been closed.'); + return; + } + window.location.assign('{% url 'close_finding' finding.id %}'); + }); + $('a.delete-finding').on('click', function (e) { if (confirm('Are you sure you want to delete this finding?')) { $("form#delete-finding-form").submit(); From 1a0e5e217356721862e6b9c7d0cd669b298e6806 Mon Sep 17 00:00:00 2001 From: Filipe Pina <63779195+fopinappb@users.noreply.github.com> Date: Thu, 26 Feb 2026 11:26:14 +0000 Subject: [PATCH 3/5] address feedback --- dojo/api_v2/serializers.py | 5 +++ dojo/api_v2/views.py | 26 +++++++++++++ dojo/finding/helper.py | 65 +++++++++++++++++++++++++++----- dojo/finding/views.py | 19 +++------- unittests/test_rest_framework.py | 33 +++++++++++++++- 5 files changed, 125 insertions(+), 23 deletions(-) diff --git a/dojo/api_v2/serializers.py b/dojo/api_v2/serializers.py index 1eeb021d165..f504a3d3b21 100644 --- a/dojo/api_v2/serializers.py +++ b/dojo/api_v2/serializers.py @@ -2976,6 +2976,11 @@ def validate(self, data): return data +class FindingVerifySerializer(serializers.Serializer): + note = serializers.CharField(required=False, allow_blank=True) + note_type = serializers.PrimaryKeyRelatedField(required=False, allow_null=True, queryset=Note_Type.objects.all()) + + class ReportGenerateOptionSerializer(serializers.Serializer): include_finding_notes = serializers.BooleanField(default=False) include_finding_images = serializers.BooleanField(default=False) diff --git a/dojo/api_v2/views.py b/dojo/api_v2/views.py index 3461e54b25a..6906a3d48eb 100644 --- a/dojo/api_v2/views.py +++ b/dojo/api_v2/views.py @@ -1014,6 +1014,32 @@ def close(self, request, pk=None): serialized_finding = serializers.FindingCloseSerializer(finding, context={"request": request}) return Response(serialized_finding.data) + @extend_schema( + methods=["POST"], + request=serializers.FindingVerifySerializer, + responses={status.HTTP_200_OK: serializers.FindingSerializer}, + ) + @action(detail=True, methods=["post"], permission_classes=(IsAuthenticated, permissions.UserHasFindingRelatedObjectPermission)) + def verify(self, request, pk=None): + finding = self.get_object() + + serializer = serializers.FindingVerifySerializer(data=request.data) + if not serializer.is_valid(): + return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) + + # Remove prefetched tags to keep queryset state in sync + finding.tags._remove_prefetched_objects() + + finding_helper.verify_finding( + finding=finding, + user=request.user, + note_entry=serializer.validated_data.get("note"), + note_type=serializer.validated_data.get("note_type"), + ) + + serialized_finding = serializers.FindingSerializer(finding, context={"request": request}) + return Response(serialized_finding.data) + @extend_schema( methods=["GET"], responses={status.HTTP_200_OK: serializers.TagSerializer}, diff --git a/dojo/finding/helper.py b/dojo/finding/helper.py index a43986bd7ea..a20c84b570f 100644 --- a/dojo/finding/helper.py +++ b/dojo/finding/helper.py @@ -1012,6 +1012,31 @@ def normalize_datetime(value): return value +def _create_note_if_provided( + finding, + note_entry, + *, + user=None, + note_type=None, + note_date=None, +): + """ + Create a note for the finding when content is provided. Returns the note or None. + Note author defaults to finding.last_reviewed_by + """ + if not note_entry: + return None + + new_note = Notes.objects.create( + entry=note_entry, + author=user or finding.last_reviewed_by, + note_type=note_type, + date=note_date, + ) + finding.notes.add(new_note) + return new_note + + def close_finding( *, finding, @@ -1046,15 +1071,12 @@ def close_finding( finding.last_reviewed_by = user # Create note if provided - new_note = None - if note_entry: - new_note = Notes.objects.create( - entry=note_entry, - author=user, - note_type=note_type, - date=mitigated_date, - ) - finding.notes.add(new_note) + new_note = _create_note_if_provided( + finding, + note_entry, + note_type=note_type, + note_date=mitigated_date, + ) if settings.V3_FEATURE_LOCATIONS: # Related locations @@ -1105,3 +1127,28 @@ def close_finding( description=f'The finding "{finding.title}" was closed by {user}', url=reverse("view_finding", args=(finding.id,)), ) + + +def verify_finding( + *, + finding, + user, + note_entry=None, + note_type=None, +) -> None: + """Shared verify logic used by UI and API.""" + verification_time = now() + + finding.verified = True + finding.last_reviewed = verification_time + finding.last_reviewed_by = user + finding.last_status_update = verification_time + + _create_note_if_provided( + finding, + note_entry, + note_type=note_type, + note_date=verification_time, + ) + + finding.save(push_to_jira=False) diff --git a/dojo/finding/views.py b/dojo/finding/views.py index 2db7f3e67ce..debda2b3853 100644 --- a/dojo/finding/views.py +++ b/dojo/finding/views.py @@ -1247,19 +1247,12 @@ def verify_finding(request, fid): form.fields["entry"].label = _("Comment (optional)") if request.method == "POST" and form.is_valid(): - entry = form.cleaned_data.get("entry", "").strip() - if entry: - note = form.save(commit=False) - note.author = request.user - note.save() - finding.notes.add(note) - - now_time = timezone.now() - finding.verified = True - finding.last_reviewed = now_time - finding.last_reviewed_by = request.user - finding.last_status_update = now_time - finding.save(push_to_jira=False) + entry = form.cleaned_data.get("entry", "") + finding_helper.verify_finding( + finding=finding, + user=request.user, + note_entry=entry, + ) messages.add_message( request, diff --git a/unittests/test_rest_framework.py b/unittests/test_rest_framework.py index 5666f4de1d8..750a5a26353 100644 --- a/unittests/test_rest_framework.py +++ b/unittests/test_rest_framework.py @@ -1011,7 +1011,38 @@ def test_close_finding_pushes_note_to_jira_when_configured(self): } response = self.client.post(self._close_url(finding.id), payload, format="json") self.assertEqual(200, response.status_code, response.content[:1000]) - self.assertTrue(add_comment_mock.called) + self.assertTrue(add_comment_mock.called) + + +@versioned_fixtures +class FindingVerifyAPITest(DojoAPITestCase): + fixtures = ["dojo_testdata.json"] + + def setUp(self): + testuser = User.objects.get(username="admin") + token = Token.objects.get(user=testuser) + self.client = APIClient() + self.client.credentials(HTTP_AUTHORIZATION=f"Token {token.key}") + self.admin = testuser + + def _verify_url(self, finding_id: int) -> str: + return f"/api/v2/findings/{finding_id}/verify/" + + def test_verify_finding_basic(self): + finding = Finding.objects.get(id=7) + response = self.client.post(self._verify_url(finding.id), {"note": "Marked verified"}, format="json") + self.assertEqual(200, response.status_code, response.content[:1000]) + + finding.refresh_from_db() + self.assertTrue(finding.verified) + self.assertEqual(finding.last_reviewed_by, self.admin) + self.assertTrue(finding.notes.filter(entry__icontains="Marked verified").exists()) + + def test_verify_finding_invalid_payload(self): + finding = Finding.objects.get(id=7) + # note_type specified but invalid id + response = self.client.post(self._verify_url(finding.id), {"note_type": 9999}, format="json") + self.assertEqual(400, response.status_code, response.content[:1000]) @versioned_fixtures From 4b8704203fffe4e9ba282b2ba26b2b4573d28c00 Mon Sep 17 00:00:00 2001 From: Filipe Pina Date: Fri, 27 Feb 2026 00:11:32 +0000 Subject: [PATCH 4/5] sync to JIRA in verify_finding --- dojo/finding/helper.py | 50 +++++++++++++++++++++++------------------- 1 file changed, 28 insertions(+), 22 deletions(-) diff --git a/dojo/finding/helper.py b/dojo/finding/helper.py index a20c84b570f..503a861cda5 100644 --- a/dojo/finding/helper.py +++ b/dojo/finding/helper.py @@ -1037,6 +1037,31 @@ def _create_note_if_provided( return new_note +def _save_finding_with_jira_sync(finding, *, new_note=None): + """ + Persist finding and apply JIRA sync behavior used by finding status actions. + """ + push_to_jira = False + finding_in_group = finding.has_finding_group + jira_issue_exists = finding.has_jira_issue or ( + finding.finding_group and finding.finding_group.has_jira_issue + ) + jira_instance = jira_helper.get_jira_instance(finding) + jira_project = jira_helper.get_jira_project(finding) + + if jira_issue_exists: + push_to_jira = ( + jira_helper.is_push_all_issues(finding) + or (jira_instance and jira_instance.finding_jira_sync) + ) + if new_note and (getattr(jira_project, "push_notes", False) or push_to_jira) and not finding_in_group: + jira_helper.add_comment(finding, new_note, force_push=True) + + finding.save(push_to_jira=(push_to_jira and not finding_in_group)) + if push_to_jira and finding_in_group: + jira_helper.push_to_jira(finding.finding_group) + + def close_finding( *, finding, @@ -1098,26 +1123,7 @@ def close_finding( # External issues (best effort) close_external_issue(finding.id, "Closed by defectdojo", "github") - # JIRA sync - push_to_jira = False - finding_in_group = finding.has_finding_group - jira_issue_exists = finding.has_jira_issue or ( - finding.finding_group and finding.finding_group.has_jira_issue - ) - jira_instance = jira_helper.get_jira_instance(finding) - jira_project = jira_helper.get_jira_project(finding) - if jira_issue_exists: - push_to_jira = ( - jira_helper.is_push_all_issues(finding) - or (jira_instance and jira_instance.finding_jira_sync) - ) - if new_note and (getattr(jira_project, "push_notes", False) or push_to_jira) and not finding_in_group: - jira_helper.add_comment(finding, new_note, force_push=True) - - # Persist and push JIRA if applicable - finding.save(push_to_jira=(push_to_jira and not finding_in_group)) - if push_to_jira and finding_in_group: - jira_helper.push_to_jira(finding.finding_group) + _save_finding_with_jira_sync(finding, new_note=new_note) # Notification create_notification( @@ -1144,11 +1150,11 @@ def verify_finding( finding.last_reviewed_by = user finding.last_status_update = verification_time - _create_note_if_provided( + new_note = _create_note_if_provided( finding, note_entry, note_type=note_type, note_date=verification_time, ) - finding.save(push_to_jira=False) + _save_finding_with_jira_sync(finding, new_note=new_note) From a1dd5e8b24ecadb2ef140c43602d37fdc7cf363b Mon Sep 17 00:00:00 2001 From: Filipe Pina Date: Fri, 27 Feb 2026 00:24:54 +0000 Subject: [PATCH 5/5] lint --- dojo/finding/helper.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/dojo/finding/helper.py b/dojo/finding/helper.py index 503a861cda5..1382f9b7947 100644 --- a/dojo/finding/helper.py +++ b/dojo/finding/helper.py @@ -1038,9 +1038,7 @@ def _create_note_if_provided( def _save_finding_with_jira_sync(finding, *, new_note=None): - """ - Persist finding and apply JIRA sync behavior used by finding status actions. - """ + """Persist finding and apply JIRA sync behavior used by finding status actions.""" push_to_jira = False finding_in_group = finding.has_finding_group jira_issue_exists = finding.has_jira_issue or (