Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 6 additions & 2 deletions docs/content/en/open_source/upgrading/3.1.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,10 @@
title: 'Upgrading to DefectDojo Version 3.1.x'
toc_hide: true
weight: -20260615
description: No special instructions.
description: JIRA sync behavior changed for deleted findings.
---
There are no special instructions for upgrading to 3.1.x. Check the [Release Notes](https://github.com/DefectDojo/django-DefectDojo/releases/tag/3.1.0) for the contents of the release.
## JIRA sync when deleting findings

Direct finding deletes now honor the explicit `push_to_jira` choice before falling back to keep-in-sync or push-all settings. The UI checkbox is two-state, so users who want deleted findings to close or reassign linked JIRA issues must tick the option. In the API, omitting `push_to_jira` still uses the automatic keep-in-sync or push-all fallback, while passing `push_to_jira=false` skips the JIRA close/reassign action.

Cascade deletes, such as deleting a Test or Engagement that contains findings, do not close linked JIRA issues automatically. Delete findings directly first when you want DefectDojo to close or reassign their linked JIRA issues.
10 changes: 10 additions & 0 deletions dojo/api_v2/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@
)
from drf_spectacular.views import SpectacularAPIView
from rest_framework import mixins, status, viewsets
from rest_framework import serializers as drf_serializers
from rest_framework.decorators import action
from rest_framework.parsers import MultiPartParser
from rest_framework.permissions import DjangoModelPermissions, IsAuthenticated
Expand Down Expand Up @@ -88,6 +89,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(
Expand Down
23 changes: 22 additions & 1 deletion dojo/finding/api/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@
)
from rest_framework import mixins, status, viewsets
from rest_framework.decorators import action
from rest_framework.exceptions import ValidationError as DRFValidationError
from rest_framework.parsers import MultiPartParser
from rest_framework.permissions import IsAuthenticated
from rest_framework.response import Response
Expand All @@ -32,7 +33,7 @@
from dojo.api_v2 import (
serializers as api_v2_serializers,
)
from dojo.api_v2.views import DojoModelViewSet, report_generate
from dojo.api_v2.views import DojoModelViewSet, get_request_boolean, report_generate
from dojo.authorization import api_permissions as permissions
from dojo.finding.api.filters import ApiFindingFilter, ApiTemplateFindingFilter
from dojo.finding.api.serializer import (
Expand Down Expand Up @@ -128,6 +129,17 @@ def get_queryset(self):
),
],
),
destroy=extend_schema(
parameters=[
OpenApiParameter(
"push_to_jira",
OpenApiTypes.BOOL,
OpenApiParameter.QUERY,
required=False,
description="Close or reassign the linked JIRA issue when deleting this finding.",
),
],
),
)
class FindingViewSet(
prefetch.PrefetchListMixin,
Expand Down Expand Up @@ -159,6 +171,15 @@ def perform_update(self, serializer):

serializer.save(push_to_jira=push_to_jira)

def destroy(self, request, *args, **kwargs):
instance = self.get_object()
try:
push_to_jira = get_request_boolean(request, "push_to_jira")
except DRFValidationError as error:
raise DRFValidationError({"push_to_jira": error.detail}) from error
instance.delete(push_to_jira=push_to_jira)
return Response(status=status.HTTP_204_NO_CONTENT)

def get_queryset(self):
if settings.V3_FEATURE_LOCATIONS:
findings = get_authorized_findings(
Expand Down
58 changes: 54 additions & 4 deletions dojo/finding/helper.py
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,7 @@
WAS_ACCEPTED_FINDINGS_QUERY = Q(risk_acceptance__isnull=False) & Q(risk_acceptance__expiration_date_handled__isnull=False)
CLOSED_FINDINGS_QUERY = Q(is_mitigated=True)
UNDER_REVIEW_QUERY = Q(under_review=True)
DELETE_JIRA_SYNC_UNSET = object()


# this signal is triggered just before a finding is getting saved
Expand Down Expand Up @@ -539,7 +540,7 @@ def finding_pre_delete(sender, instance, **kwargs):
delete_related_files(instance)


def finding_delete(instance, **kwargs):
def finding_delete(instance, *, push_to_jira=DELETE_JIRA_SYNC_UNSET, **kwargs):
logger.debug("finding delete, instance: %s", instance.id)

# the idea is that the engagement/test pre delete already prepared all the duplicates inside
Expand All @@ -557,15 +558,31 @@ def finding_delete(instance, **kwargs):
# but django still calls delete() in this case
return

jira_sync_requested = push_to_jira is None or isinstance(push_to_jira, bool)
jira_issue_reassigned = False
duplicate_cluster = instance.original_finding.all()
if duplicate_cluster:
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)
if jira_sync_requested:
jira_issue_reassigned = _reassign_jira_issue_to_new_original(
instance,
new_original,
push_to_jira=push_to_jira,
)
else:
logger.debug("no duplicate cluster found for finding: %d, so no need to reconfigure", instance.id)

if (
jira_sync_requested
and not jira_issue_reassigned
and instance.has_jira_issue
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
logger.debug("finding delete: clearing found by")
Expand All @@ -579,19 +596,50 @@ 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=None):
if (
not new_original
or new_original.has_jira_issue
or not jira_services.is_delete_sync_allowed(deleted_finding, push_to_jira=push_to_jira)
):
return False

jira_issue = jira_services.get_issue(deleted_finding)
if not jira_issue:
return False

jira_instance = jira_services.get_instance(deleted_finding)
if not jira_instance:
return False

jira_id = jira_issue.jira_id
jira_instance_id = jira_instance.id
comment = (
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)
jira_services.add_simple_comment_async(
jira_id,
jira_instance_id,
comment,
)
return True


# can't use model to id here due to the queryset
# @dojo_async_task
# @app.task
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()
Expand All @@ -610,6 +658,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):
Expand Down
5 changes: 3 additions & 2 deletions dojo/finding/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@

logger = logging.getLogger(__name__)
deduplicationLogger = logging.getLogger("dojo.specific-loggers.deduplication")
DELETE_JIRA_SYNC_UNSET = object()


class Finding(BaseModel):
Expand Down Expand Up @@ -683,10 +684,10 @@ def copy(self, test=None):

return copy

def delete(self, *args, product_grading_option=True, **kwargs):
def delete(self, *args, product_grading_option=True, push_to_jira=DELETE_JIRA_SYNC_UNSET, **kwargs):
logger.debug("%d finding delete", self.id)
from dojo.finding import helper as finding_helper # noqa: PLC0415 -- lazy import, avoids circular dependency
finding_helper.finding_delete(self)
finding_helper.finding_delete(self, push_to_jira=push_to_jira)
super().delete(*args, **kwargs)
if product_grading_option:
from dojo.models import ( # noqa: PLC0415 -- lazy import, avoids circular dependency
Expand Down
7 changes: 6 additions & 1 deletion dojo/finding/ui/forms.py
Original file line number Diff line number Diff line change
Expand Up @@ -1056,10 +1056,15 @@ class Meta:
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 when this finding is deleted.",
)

class Meta:
model = Finding
fields = ["id"]
fields = ["id", "push_to_jira"]


class CopyFindingForm(forms.Form):
Expand Down
5 changes: 3 additions & 2 deletions dojo/finding/ui/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -1086,7 +1086,7 @@ 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.delete()
finding.delete(push_to_jira=context["form"].cleaned_data.get("push_to_jira"))
# Update the grade of the product async
dojo_dispatch_task(calculate_grade, product.id)
# Add a message to the request that the finding was successfully deleted
Expand Down Expand Up @@ -2475,8 +2475,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:
find.delete()
find.delete(push_to_jira=push_to_jira)

if skipped_find_count > 0:
add_error_message_to_response(
Expand Down
106 changes: 106 additions & 0 deletions dojo/jira/helper.py
Original file line number Diff line number Diff line change
Expand Up @@ -159,6 +159,12 @@ 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):
if push_to_jira is not None:
return is_push_to_jira(finding, push_to_jira_parameter=push_to_jira)
return bool(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
Expand Down Expand Up @@ -1273,6 +1279,86 @@ 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("queueing linked Jira issue close for deleted 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_issue = get_jira_issue(finding)
if not jira_issue:
return False, f"Finding {finding.id} has no local JIRA issue record."

jira_project = get_jira_project(jira_issue)
if not jira_project or not jira_project.jira_instance:
return False, f"Finding {finding.id} has no JIRA instance for its linked issue."

dojo_dispatch_task(
close_deleted_finding_jira_issue,
jira_issue.jira_id,
jira_project.jira_instance.id,
finding.id,
)
return True, f"Jira issue {jira_issue.jira_key} close queued."


@app.task
def close_deleted_finding_jira_issue(jira_id, jira_instance_id, finding_id, **_kwargs) -> tuple[bool | None, str]:
jira_instance = get_object_or_none(JIRA_Instance, id=jira_instance_id)
if not jira_instance:
message = f"JIRA instance {jira_instance_id} is not available for issue {jira_id}."
logger.warning(message)
return False, message

try:
JIRAError.log_to_tempfile = False
jira = get_jira_connection(jira_instance)
if not jira:
message = f"JIRA connection could not be established for issue {jira_id}."
logger.warning(message)
return False, message
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)
return False, message

if not issue_from_jira_is_active(issue):
logger.debug("Jira issue %s is already resolved", jira_id)
return False, f"Jira issue {jira_id} is already resolved."

updated = jira_transition(jira, issue, jira_instance.close_status_key)
if updated:
jira.add_comment(
jira_id,
f"DefectDojo finding {finding_id} was deleted. This Jira issue was closed automatically.",
)
return True, f"Jira issue {jira_id} closed successfully."

return updated, f"Jira issue {jira_id} 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,
Expand Down Expand Up @@ -1666,6 +1752,26 @@ def add_simple_jira_comment(jira_instance, jira_issue, comment):
return True


def add_simple_jira_comment_async(jira_id, jira_instance_id, comment):
return dojo_dispatch_task(add_simple_jira_comment_by_id, jira_id, jira_instance_id, comment)


@app.task
def add_simple_jira_comment_by_id(jira_id, jira_instance_id, comment, **_kwargs):
jira_instance = get_object_or_none(JIRA_Instance, id=jira_instance_id)
if not jira_instance:
logger.warning("JIRA instance %s is not available for issue %s", jira_instance_id, jira_id)
return False

try:
jira = get_jira_connection(jira_instance)
jira.add_comment(jira_id, comment)
except Exception as e:
log_jira_generic_alert("Jira Add Comment Error", str(e))
return False
return True


def jira_already_linked(finding, jira_issue_key, jira_id) -> Finding | None:
jira_issues = JIRA_Issue.objects.filter(jira_id=jira_id, jira_key=jira_issue_key).exclude(engagement__isnull=False)
jira_issues = jira_issues.exclude(finding=finding)
Expand Down
Loading
Loading