diff --git a/dojo/api_v2/views.py b/dojo/api_v2/views.py index 3f3663ecb7f..bb773e6a245 100644 --- a/dojo/api_v2/views.py +++ b/dojo/api_v2/views.py @@ -29,8 +29,9 @@ extend_schema_view, ) from drf_spectacular.views import SpectacularAPIView -from rest_framework import mixins, status, viewsets +from rest_framework import mixins, serializers as drf_serializers, status, viewsets from rest_framework.decorators import action +from rest_framework.exceptions import ValidationError as DRFValidationError from rest_framework.generics import GenericAPIView from rest_framework.parsers import MultiPartParser from rest_framework.permissions import DjangoModelPermissions, IsAuthenticated @@ -39,8 +40,6 @@ import dojo.finding.helper as finding_helper from dojo.api_v2 import ( mixins as dojo_mixins, -) -from dojo.api_v2 import ( prefetch, serializers, ) @@ -162,6 +161,15 @@ labels = get_labels() +def get_request_boolean(request, name): + value = request.query_params.get(name) if name in request.query_params else request.data.get(name) + + if value is None: + return None + + return drf_serializers.BooleanField(required=False).run_validation(value) + + def schema_with_prefetch() -> dict: return { "list": extend_schema( @@ -835,6 +843,17 @@ def get_queryset(self): ), ], ), + destroy=extend_schema( + parameters=[ + OpenApiParameter( + "push_to_jira", + OpenApiTypes.BOOL, + OpenApiParameter.QUERY, + required=False, + description="Close or reassign linked JIRA issue while deleting this finding.", + ), + ], + ), ) class FindingViewSet( prefetch.PrefetchListMixin, @@ -866,6 +885,17 @@ def perform_update(self, serializer): serializer.save(push_to_jira=push_to_jira) + def destroy(self, request, *args, **kwargs): + try: + push_to_jira = get_request_boolean(request, "push_to_jira") + except DRFValidationError as error: + return Response({"push_to_jira": error.detail}, status=status.HTTP_400_BAD_REQUEST) + + instance = self.get_object() + finding_helper.set_push_to_jira_on_delete(instance, push_to_jira) + self.perform_destroy(instance) + return Response(status=status.HTTP_204_NO_CONTENT) + def get_queryset(self): if settings.V3_FEATURE_LOCATIONS: findings = get_authorized_findings( diff --git a/dojo/finding/helper.py b/dojo/finding/helper.py index d83d032b176..0db5d816ee9 100644 --- a/dojo/finding/helper.py +++ b/dojo/finding/helper.py @@ -532,6 +532,13 @@ def post_process_findings_batch( @receiver(pre_delete, sender=Finding) def finding_pre_delete(sender, instance, **kwargs): logger.debug("finding pre_delete: %d", instance.id) + push_to_jira = get_push_to_jira_on_delete(instance) + if ( + instance.has_jira_issue + and not getattr(instance, "_skip_jira_close_on_delete", False) + and jira_services.is_delete_sync_allowed(instance, push_to_jira=push_to_jira) + ): + jira_services.close_issue_for_deleted_finding(instance, push_to_jira=push_to_jira) # this shouldn't be necessary as Django should remove any Many-To-Many entries automatically, might be a bug in Django? # https://code.djangoproject.com/ticket/154 instance.found_by.clear() @@ -562,7 +569,8 @@ def finding_delete(instance, **kwargs): if settings.DUPLICATE_CLUSTER_CASCADE_DELETE: duplicate_cluster.order_by("-id").delete() else: - reconfigure_duplicate_cluster(instance, duplicate_cluster) + new_original = reconfigure_duplicate_cluster(instance, duplicate_cluster) + _reassign_jira_issue_to_new_original(instance, new_original) else: logger.debug("no duplicate cluster found for finding: %d, so no need to reconfigure", instance.id) @@ -579,6 +587,42 @@ def finding_post_delete(sender, instance, **kwargs): logger.debug("finding post_delete, sender: %s instance: %s", to_str_typed(sender), to_str_typed(instance)) +def _reassign_jira_issue_to_new_original(deleted_finding, new_original): + push_to_jira = get_push_to_jira_on_delete(deleted_finding) + if not new_original or not jira_services.is_delete_sync_allowed(deleted_finding, push_to_jira=push_to_jira): + return False + + if jira_services.get_issue(new_original): + return False + + jira_issue = jira_services.get_issue(deleted_finding) + if not jira_issue: + return False + + jira_instance = jira_services.get_instance(deleted_finding) + jira_services.add_simple_comment( + jira_instance, + jira_issue, + ( + f"DefectDojo finding {deleted_finding.id} was deleted. " + f"This Jira issue was reassigned to finding {new_original.id}." + ), + ) + jira_services.reassign_issue_to_finding(jira_issue, new_original) + deleted_finding._skip_jira_close_on_delete = True + return True + + +def get_push_to_jira_on_delete(finding): + push_to_jira = getattr(finding, "_push_to_jira_on_delete", None) + return push_to_jira if isinstance(push_to_jira, bool) else None + + +def set_push_to_jira_on_delete(finding, push_to_jira): + if push_to_jira is not None: + finding._push_to_jira_on_delete = push_to_jira + + # can't use model to id here due to the queryset # @dojo_async_task # @app.task @@ -586,12 +630,12 @@ def reconfigure_duplicate_cluster(original, cluster_outside): # when a finding is deleted, and is an original of a duplicate cluster, we have to chose a new original for the cluster # only look for a new original if there is one outside this test if original is None or cluster_outside is None or len(cluster_outside) == 0: - return + return None if settings.DUPLICATE_CLUSTER_CASCADE_DELETE: # Don't delete here — the caller (async_delete_crawl_task or finding_delete) # handles deletion of outside-scope duplicates efficiently via bulk_delete_findings. - return + return None logger.debug("reconfigure_duplicate_cluster: cluster_outside: %s", cluster_outside) # set new original to first finding in cluster (ordered by id) new_original = cluster_outside.order_by("id").first() @@ -610,6 +654,8 @@ def reconfigure_duplicate_cluster(original, cluster_outside): # Re-point remaining duplicates to the new original in a single query cluster_outside.exclude(id=new_original.id).update(duplicate_finding=new_original) + return new_original + return None def prepare_duplicates_for_delete(obj, *, preview_only=False): diff --git a/dojo/finding/views.py b/dojo/finding/views.py index e39a0a8fea8..ffd136326af 100644 --- a/dojo/finding/views.py +++ b/dojo/finding/views.py @@ -1085,6 +1085,10 @@ def get_finding(self, finding_id: int): def process_form(self, request: HttpRequest, finding: Finding, context: dict): if context["form"].is_valid(): product = finding.test.engagement.product + finding_helper.set_push_to_jira_on_delete( + finding, + context["form"].cleaned_data.get("push_to_jira"), + ) finding.delete() # Update the grade of the product async dojo_dispatch_task(calculate_grade, product.id) @@ -2474,7 +2478,9 @@ def _bulk_delete_findings(request, pid, form, finding_to_update, finds, total_fi skipped_find_count = total_find_count - finds.count() deleted_find_count = finds.count() + push_to_jira = form.cleaned_data.get("push_to_jira") for find in finds: + finding_helper.set_push_to_jira_on_delete(find, push_to_jira) find.delete() if skipped_find_count > 0: diff --git a/dojo/forms.py b/dojo/forms.py index cb90d0f57de..b5a39e7358a 100644 --- a/dojo/forms.py +++ b/dojo/forms.py @@ -2501,10 +2501,15 @@ class CustomReportOptionsForm(forms.Form): class DeleteFindingForm(forms.ModelForm): id = forms.IntegerField(required=True, widget=forms.widgets.HiddenInput()) + push_to_jira = forms.BooleanField( + required=False, + label="Push to JIRA", + help_text="Checking this will close or reassign the linked JIRA issue while deleting this finding.", + ) class Meta: model = Finding - fields = ["id"] + fields = ["id", "push_to_jira"] class CopyFindingForm(forms.Form): diff --git a/dojo/jira/helper.py b/dojo/jira/helper.py index 5b83a0596ab..2a7c0c5b9bd 100644 --- a/dojo/jira/helper.py +++ b/dojo/jira/helper.py @@ -159,6 +159,10 @@ def is_keep_in_sync_with_jira(obj: Finding | Finding_Group, prefetched_jira_inst return False +def is_delete_sync_allowed(finding, push_to_jira=None): + return bool(push_to_jira or is_keep_in_sync_with_jira(finding) or is_push_all_issues(finding)) + + # checks if a finding can be pushed to JIRA # optionally provides a form with the new data for the finding # any finding that already has a JIRA issue can be pushed again to JIRA @@ -1268,6 +1272,81 @@ def push_status_to_jira(obj, jira_instance, jira, issue, *, save=False): return updated +def close_jira_issue_for_deleted_finding(finding, push_to_jira=None) -> tuple[bool | None, str]: + logger.debug("closing linked Jira issue before deleting finding %d", finding.id) + + if not is_jira_enabled(): + return False, "JIRA integration is not enabled." + + if not finding.has_jira_issue: + return False, f"Finding {finding.id} has no linked JIRA issue." + + if not is_delete_sync_allowed(finding, push_to_jira=push_to_jira): + return False, f"Finding {finding.id} is not configured to sync deleted findings to JIRA." + + if not is_jira_configured_and_enabled(finding): + message = ( + f"Finding {finding.id} cannot close its linked JIRA issue " + "because JIRA is not configured or enabled." + ) + logger.debug(message) + return False, message + + jira_instance = get_jira_instance(finding) + if not jira_instance: + message = ( + f"Finding {finding.id} cannot close its linked JIRA issue " + "because the JIRA instance is not available." + ) + logger.warning(message) + return False, message + + jira_issue = get_jira_issue(finding) + if not jira_issue: + return False, f"Finding {finding.id} has no local JIRA issue record." + + try: + JIRAError.log_to_tempfile = False + jira = get_jira_connection(jira_instance) + if not jira: + message = ( + f"Finding {finding.id} cannot close its linked JIRA issue " + "because the JIRA connection could not be established." + ) + logger.warning(message) + return False, message + issue = jira.issue(jira_issue.jira_id) + except Exception as e: + message = f"The following jira instance could not be connected: {jira_instance} - {e}" + logger.exception(message) + log_jira_alert(message, finding) + return False, message + + if not issue_from_jira_is_active(issue): + logger.debug("Jira issue %s is already resolved", jira_issue.jira_key) + return False, f"Jira issue {jira_issue.jira_key} is already resolved." + + updated = jira_transition(jira, issue, jira_instance.close_status_key) + if updated: + add_simple_jira_comment( + jira_instance, + jira_issue, + f"DefectDojo finding {finding.id} was deleted. This Jira issue was closed automatically.", + ) + jira_issue.jira_change = timezone.now() + jira_issue.save(update_fields=["jira_change"]) + return True, f"Jira issue {jira_issue.jira_key} closed successfully." + + return updated, f"Jira issue {jira_issue.jira_key} was not closed." + + +def reassign_jira_issue_to_finding(jira_issue, finding): + jira_issue.finding = finding + jira_issue.finding_group = None + jira_issue.engagement = None + jira_issue.save(update_fields=["finding", "finding_group", "engagement"]) + + # gets the metadata for the provided issue type in the provided jira project def get_issuetype_fields( jira, diff --git a/dojo/jira/services.py b/dojo/jira/services.py index 54dec073be1..262783acd77 100644 --- a/dojo/jira/services.py +++ b/dojo/jira/services.py @@ -137,6 +137,24 @@ def push_status(obj, jira_instance, jira, issue, *, save=False): return _get_helper().push_status_to_jira(obj, jira_instance, jira, issue, save=save) +def close_issue_for_deleted_finding(finding, push_to_jira=None): + """ + Close the linked Jira issue before a finding is deleted. + + Wraps: jira_helper.close_jira_issue_for_deleted_finding + """ + return _get_helper().close_jira_issue_for_deleted_finding(finding, push_to_jira=push_to_jira) + + +def reassign_issue_to_finding(jira_issue, finding): + """ + Reassign a local Jira issue record to another finding. + + Wraps: jira_helper.reassign_jira_issue_to_finding + """ + return _get_helper().reassign_jira_issue_to_finding(jira_issue, finding) + + def update_issue(obj, *args, **kwargs): """ Update a Jira issue. @@ -339,6 +357,15 @@ def is_keep_in_sync(obj, prefetched_jira_instance=None): return _get_helper().is_keep_in_sync_with_jira(obj, prefetched_jira_instance=prefetched_jira_instance) +def is_delete_sync_allowed(finding, push_to_jira=None): + """ + Check if deleting a finding should update its linked Jira issue. + + Wraps: jira_helper.is_delete_sync_allowed + """ + return _get_helper().is_delete_sync_allowed(finding, push_to_jira=push_to_jira) + + def is_push(instance, push_to_jira_parameter=None): """ Check if Jira push should happen. diff --git a/dojo/templates/dojo/findings_list_snippet.html b/dojo/templates/dojo/findings_list_snippet.html index eb8d3db7edb..6695043e3d4 100644 --- a/dojo/templates/dojo/findings_list_snippet.html +++ b/dojo/templates/dojo/findings_list_snippet.html @@ -1079,7 +1079,16 @@