Skip to content

Commit 719018f

Browse files
Jira keep findings in sync: Expand to import/reimport and API (#14262)
* Enhance JIRA synchronization logic in importers and serializers - Updated push_to_jira conditions to include sync behavior based on JIRA instance settings. - Refactored JIRA push logic to check for sync status in FindingSerializer and DefaultImporter. - Improved handling of JIRA instance retrieval and sync checks in DefaultReImporter and BaseImporter. - Added support for prefetched JIRA instance in is_keep_in_sync_with_jira function. * Refactor JIRA sync flag to use 'finding_jira_sync' for consistency in importers and reimporters * Refactor is_keep_in_sync_with_jira function to use a generic object parameter for improved flexibility * Refactor is_keep_in_sync_with_jira function to improve JIRA issue detection and sync logic * Add tests * bulk edit: push groups to JIRA when sync is enabled (#14265) --------- Co-authored-by: valentijnscholten <valentijnscholten@gmail.com>
1 parent f98f14c commit 719018f

16 files changed

Lines changed: 8257 additions & 56 deletions

dojo/api_v2/serializers.py

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1888,8 +1888,9 @@ def update(self, instance, validated_data):
18881888
for location_ref in locations:
18891889
location_ref.location.associate_with_finding(instance)
18901890

1891-
if push_to_jira:
1892-
jira_helper.push_to_jira(instance)
1891+
if push_to_jira or finding_helper.is_keep_in_sync_with_jira(instance):
1892+
# Push synchronously so that we can see jira errors in real time
1893+
jira_helper.push_to_jira(instance, sync=True)
18931894

18941895
return instance
18951896

dojo/finding/helper.py

Lines changed: 28 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@
2323
do_dedupe_finding_task_internal,
2424
get_finding_models_for_deduplication,
2525
)
26+
from dojo.jira_link.helper import is_keep_in_sync_with_jira
2627
from dojo.location.models import Location
2728
from dojo.location.status import FindingLocationStatus
2829
from dojo.location.utils import save_locations_to_add
@@ -32,6 +33,7 @@
3233
Engagement,
3334
Finding,
3435
Finding_Group,
36+
JIRA_Instance,
3537
Notes,
3638
System_Settings,
3739
Test,
@@ -458,14 +460,24 @@ def post_process_finding_save_internal(finding, dedupe_option=True, rules_option
458460

459461

460462
@app.task
461-
def post_process_findings_batch(finding_ids, *args, dedupe_option=True, rules_option=True, product_grading_option=True,
462-
issue_updater_option=True, push_to_jira=False, user=None, **kwargs):
463+
def post_process_findings_batch(
464+
finding_ids,
465+
*args,
466+
dedupe_option=True,
467+
rules_option=True,
468+
product_grading_option=True,
469+
issue_updater_option=True,
470+
push_to_jira=False,
471+
jira_instance_id=None,
472+
user=None,
473+
**kwargs,
474+
):
463475

464476
logger.debug(
465477
f"post_process_findings_batch called: finding_ids_count={len(finding_ids) if finding_ids else 0}, "
466478
f"args={args}, dedupe_option={dedupe_option}, rules_option={rules_option}, "
467479
f"product_grading_option={product_grading_option}, issue_updater_option={issue_updater_option}, "
468-
f"push_to_jira={push_to_jira}, user={user.id if user else None}, kwargs={kwargs}",
480+
f"push_to_jira={push_to_jira}, jira_instance_id={jira_instance_id}, user={user.id if user else None}, kwargs={kwargs}",
469481
)
470482
if not finding_ids:
471483
return
@@ -503,14 +515,21 @@ def post_process_findings_batch(finding_ids, *args, dedupe_option=True, rules_op
503515

504516
dojo_dispatch_task(calculate_grade, findings[0].test.engagement.product.id)
505517

506-
if push_to_jira:
518+
# If we received the ID of a jira instance, then we need to determine the keep in sync behavior
519+
jira_instance = None
520+
if jira_instance_id is not None:
521+
with suppress(JIRA_Instance.DoesNotExist):
522+
jira_instance = JIRA_Instance.objects.get(id=jira_instance_id)
523+
# We dont check if the finding jira sync is applicable quite yet until we can get in the loop
524+
# but this is a way to at least make it that far
525+
if push_to_jira or getattr(jira_instance, "finding_jira_sync", False):
507526
for finding in findings:
508-
if finding.has_jira_issue or not finding.finding_group:
509-
jira_helper.push_to_jira(finding)
510-
else:
511-
jira_helper.push_to_jira(finding.finding_group)
527+
object_to_push = finding if finding.has_jira_issue or not finding.finding_group else finding.finding_group
528+
# Check the push_to_jira flag again to potentially shorty circuit without checking for existing findings
529+
if push_to_jira or is_keep_in_sync_with_jira(object_to_push, prefetched_jira_instance=jira_instance):
530+
jira_helper.push_to_jira(object_to_push)
512531
else:
513-
logger.debug("push_to_jira is False, not ushing to JIRA")
532+
logger.debug("push_to_jira is False, not pushing to JIRA")
514533

515534

516535
@receiver(pre_delete, sender=Finding)

dojo/finding/views.py

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2963,7 +2963,11 @@ def _bulk_push_to_jira(finds, form, note):
29632963
)
29642964
logger.debug("finding_groups: %s", finding_groups)
29652965
for group in finding_groups:
2966-
if form.cleaned_data.get("push_to_jira"):
2966+
if (
2967+
form.cleaned_data.get("push_to_jira")
2968+
or jira_helper.is_push_all_issues(group)
2969+
or jira_helper.is_keep_in_sync_with_jira(group)
2970+
):
29672971
(
29682972
can_be_pushed_to_jira,
29692973
error_message,

dojo/importers/base_importer.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717
from dojo.importers.endpoint_manager import EndpointManager
1818
from dojo.importers.location_manager import LocationManager
1919
from dojo.importers.options import ImporterOptions
20+
from dojo.jira_link.helper import is_keep_in_sync_with_jira
2021
from dojo.location.models import AbstractLocation, Location
2122
from dojo.models import (
2223
# Import History States
@@ -967,7 +968,7 @@ def mitigate_finding(
967968
# don't try to dedupe findings that we are closing
968969
finding.save(dedupe_option=False, product_grading_option=product_grading_option)
969970
else:
970-
finding.save(dedupe_option=False, push_to_jira=self.push_to_jira, product_grading_option=product_grading_option)
971+
finding.save(dedupe_option=False, push_to_jira=(self.push_to_jira or is_keep_in_sync_with_jira(finding, prefetched_jira_instance=self.jira_instance)), product_grading_option=product_grading_option)
971972

972973
def notify_scan_added(
973974
self,

dojo/importers/default_importer.py

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
from dojo.finding import helper as finding_helper
1212
from dojo.importers.base_importer import BaseImporter, Parser
1313
from dojo.importers.options import ImporterOptions
14+
from dojo.jira_link.helper import is_keep_in_sync_with_jira
1415
from dojo.models import (
1516
Engagement,
1617
Finding,
@@ -383,9 +384,13 @@ def close_old_findings(
383384
product_grading_option=False,
384385
)
385386
# push finding groups to jira since we only only want to push whole groups
386-
if self.findings_groups_enabled and self.push_to_jira:
387+
# We dont check if the finding jira sync is applicable quite yet until we can get in the loop
388+
# but this is a way to at least make it that far
389+
if self.findings_groups_enabled and (self.push_to_jira or getattr(self.jira_instance, "finding_jira_sync", False)):
387390
for finding_group in {finding.finding_group for finding in old_findings if finding.finding_group is not None}:
388-
jira_helper.push_to_jira(finding_group)
391+
# Check the push_to_jira flag again to potentially shorty circuit without checking for existing findings
392+
if self.push_to_jira or is_keep_in_sync_with_jira(finding_group, prefetched_jira_instance=self.jira_instance):
393+
jira_helper.push_to_jira(finding_group)
389394

390395
# Calculate grade once after all findings have been closed
391396
if old_findings:

dojo/importers/default_reimporter.py

Lines changed: 21 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616
)
1717
from dojo.importers.base_importer import BaseImporter, Parser
1818
from dojo.importers.options import ImporterOptions
19+
from dojo.jira_link.helper import is_keep_in_sync_with_jira
1920
from dojo.location.status import FindingLocationStatus
2021
from dojo.models import (
2122
Development_Environment,
@@ -441,6 +442,7 @@ def process_findings(
441442
product_grading_option=True,
442443
issue_updater_option=True,
443444
push_to_jira=push_to_jira,
445+
jira_instance_id=getattr(self.jira_instance, "id", None),
444446
)
445447

446448
# No chord: tasks are dispatched immediately above per batch
@@ -499,10 +501,13 @@ def close_old_findings(
499501
)
500502
mitigated_findings.append(finding)
501503
# push finding groups to jira since we only only want to push whole groups
502-
if self.findings_groups_enabled and self.push_to_jira:
504+
# We dont check if the finding jira sync is applicable quite yet until we can get in the loop
505+
# but this is a way to at least make it that far
506+
if self.findings_groups_enabled and (self.push_to_jira or getattr(self.jira_instance, "finding_jira_sync", False)):
503507
for finding_group in {finding.finding_group for finding in findings if finding.finding_group is not None}:
504-
jira_helper.push_to_jira(finding_group)
505-
508+
# Check the push_to_jira flag again to potentially shorty circuit without checking for existing findings
509+
if self.push_to_jira or is_keep_in_sync_with_jira(finding_group, prefetched_jira_instance=self.jira_instance):
510+
jira_helper.push_to_jira(finding_group)
506511
# Calculate grade once after all findings have been closed
507512
if mitigated_findings:
508513
perform_product_grading(self.test.engagement.product)
@@ -985,19 +990,24 @@ def process_groups_for_all_findings(
985990
create_finding_groups_for_all_findings=self.create_finding_groups_for_all_findings,
986991
**kwargs,
987992
)
988-
if self.push_to_jira:
989-
if findings[0].finding_group is not None:
990-
jira_helper.push_to_jira(findings[0].finding_group)
991-
else:
992-
jira_helper.push_to_jira(findings[0])
993-
994-
if self.findings_groups_enabled and self.push_to_jira:
993+
# We dont check if the finding jira sync is applicable quite yet until we can get in the loop
994+
# but this is a way to at least make it that far
995+
if self.push_to_jira or getattr(self.jira_instance, "finding_jira_sync", False):
996+
object_to_push = findings[0].finding_group if findings[0].finding_group is not None else findings[0]
997+
# Check the push_to_jira flag again to potentially shorty circuit without checking for existing findings
998+
if self.push_to_jira or is_keep_in_sync_with_jira(object_to_push, prefetched_jira_instance=self.jira_instance):
999+
jira_helper.push_to_jira(object_to_push)
1000+
# We dont check if the finding jira sync is applicable quite yet until we can get in the loop
1001+
# but this is a way to at least make it that far
1002+
if self.findings_groups_enabled and (self.push_to_jira or getattr(self.jira_instance, "finding_jira_sync", False)):
9951003
for finding_group in {
9961004
finding.finding_group
9971005
for finding in self.reactivated_items + self.unchanged_items
9981006
if finding.finding_group is not None and not finding.is_mitigated
9991007
}:
1000-
jira_helper.push_to_jira(finding_group)
1008+
# Check the push_to_jira flag again to potentially shorty circuit without checking for existing findings
1009+
if self.push_to_jira or is_keep_in_sync_with_jira(finding_group, prefetched_jira_instance=self.jira_instance):
1010+
jira_helper.push_to_jira(finding_group)
10011011

10021012
def process_results(
10031013
self,

dojo/importers/options.py

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,12 +10,14 @@
1010
from django.utils import timezone
1111
from django.utils.functional import SimpleLazyObject
1212

13+
from dojo.jira_link.helper import get_jira_instance
1314
from dojo.models import (
1415
Development_Environment,
1516
Dojo_User,
1617
Endpoint,
1718
Engagement,
1819
Finding,
20+
JIRA_Instance,
1921
Product_API_Scan_Configuration,
2022
Test,
2123
Test_Import,
@@ -70,7 +72,6 @@ def load_base_options(
7072
self.lead: Dojo_User | None = self.validate_lead(*args, **kwargs)
7173
self.minimum_severity: str = self.validate_minimum_severity(*args, **kwargs)
7274
self.parsed_findings: list[Finding] | None = self.validate_parsed_findings(*args, **kwargs)
73-
self.push_to_jira: bool = self.validate_push_to_jira(*args, **kwargs)
7475
self.scan_date: datetime = self.validate_scan_date(*args, **kwargs)
7576
self.scan_type: str = self.validate_scan_type(*args, **kwargs)
7677
self.service: str = self.validate_service(*args, **kwargs)
@@ -80,6 +81,8 @@ def load_base_options(
8081
self.test_title: str = self.validate_test_title(*args, **kwargs)
8182
self.verified: bool = self.validate_verified(*args, **kwargs)
8283
self.version: str = self.validate_version(*args, **kwargs)
84+
# Save this for last to use engagement and test for prefetching related to Jira info
85+
self.push_to_jira: bool = self.validate_push_to_jira(*args, **kwargs)
8386

8487
def load_additional_options(
8588
self,
@@ -478,6 +481,7 @@ def validate_push_to_jira(
478481
*args: list,
479482
**kwargs: dict,
480483
) -> bool:
484+
self.jira_instance: JIRA_Instance | None = get_jira_instance(self.engagement or self.test)
481485
return self.validate(
482486
"push_to_jira",
483487
expected_types=[bool],

dojo/jira_link/helper.py

Lines changed: 31 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -145,17 +145,19 @@ def _safely_get_obj_status_for_jira(obj: Finding | Finding_Group, *, isenforced:
145145
return status or ["Inactive"]
146146

147147

148-
def is_keep_in_sync_with_jira(finding):
149-
keep_in_sync_enabled = False
150-
# Check if there is a jira issue that needs to be updated
151-
jira_issue_exists = finding.has_jira_issue or (finding.finding_group and finding.finding_group.has_jira_issue)
152-
if jira_issue_exists:
153-
# Determine if any automatic sync should occur
154-
jira_instance = get_jira_instance(finding)
155-
if jira_instance:
156-
keep_in_sync_enabled = jira_instance.finding_jira_sync
157-
158-
return keep_in_sync_enabled
148+
def is_keep_in_sync_with_jira(obj: Finding | Finding_Group, prefetched_jira_instance: JIRA_Instance = None):
149+
"""Determine if any automatic sync should occur"""
150+
jira_issue_exists = False
151+
# Check for a jira issue on each type of object
152+
if isinstance(obj, Finding):
153+
jira_issue_exists = obj.has_jira_issue or (obj.finding_group and obj.finding_group.has_jira_issue)
154+
elif isinstance(obj, Finding_Group):
155+
jira_issue_exists = obj.has_jira_issue
156+
# Now determine if we need to pull the jira instance to check if sync is enabled
157+
# but only if there is a jira issue that would need syncing
158+
if jira_issue_exists and (jira_instance := prefetched_jira_instance or get_jira_instance(obj)) is not None:
159+
return jira_instance.finding_jira_sync
160+
return False
159161

160162

161163
# checks if a finding can be pushed to JIRA
@@ -225,8 +227,8 @@ def can_be_pushed_to_jira(obj, form=None):
225227

226228

227229
# use_inheritance=True means get jira_project config from product if engagement itself has none
228-
def get_jira_project(obj, *, use_inheritance=True):
229-
if not is_jira_enabled():
230+
def get_jira_project(obj, *, use_inheritance=True, jira_enabled: bool = False):
231+
if not jira_enabled and not (jira_enabled := is_jira_enabled()):
230232
return None
231233

232234
if obj is None:
@@ -242,19 +244,19 @@ def get_jira_project(obj, *, use_inheritance=True):
242244
return obj.jira_project
243245
# some old jira_issue records don't have a jira_project, so try to go via the finding instead
244246
if (hasattr(obj, "finding") and obj.finding) or (hasattr(obj, "engagement") and obj.engagement):
245-
return get_jira_project(obj.finding, use_inheritance=use_inheritance)
247+
return get_jira_project(obj.finding, use_inheritance=use_inheritance, jira_enabled=jira_enabled)
246248
return None
247249

248250
if isinstance(obj, Finding | Stub_Finding):
249251
finding = obj
250-
return get_jira_project(finding.test)
252+
return get_jira_project(finding.test, jira_enabled=jira_enabled)
251253

252254
if isinstance(obj, Finding_Group):
253-
return get_jira_project(obj.test)
255+
return get_jira_project(obj.test, jira_enabled=jira_enabled)
254256

255257
if isinstance(obj, Test):
256258
test = obj
257-
return get_jira_project(test.engagement)
259+
return get_jira_project(test.engagement, jira_enabled=jira_enabled)
258260

259261
if isinstance(obj, Engagement):
260262
engagement = obj
@@ -269,7 +271,7 @@ def get_jira_project(obj, *, use_inheritance=True):
269271

270272
if use_inheritance:
271273
logger.debug("delegating to product %s for %s", engagement.product, engagement)
272-
return get_jira_project(engagement.product)
274+
return get_jira_project(engagement.product, jira_enabled=jira_enabled)
273275
logger.debug("not delegating to product %s for %s", engagement.product, engagement)
274276
return None
275277

@@ -286,11 +288,11 @@ def get_jira_project(obj, *, use_inheritance=True):
286288
return None
287289

288290

289-
def get_jira_instance(obj):
290-
if not is_jira_enabled():
291+
def get_jira_instance(obj, jira_enabled: bool = False): # noqa: FBT001, FBT002
292+
if not jira_enabled and not (jira_enabled := is_jira_enabled()):
291293
return None
292294

293-
jira_project = get_jira_project(obj)
295+
jira_project = get_jira_project(obj, jira_enabled=jira_enabled)
294296
if jira_project:
295297
logger.debug("found jira_instance %s for %s", jira_project.jira_instance, obj)
296298
return jira_project.jira_instance
@@ -415,17 +417,17 @@ def get_jira_finding_text(jira_instance):
415417
return None
416418

417419

418-
def has_jira_issue(obj):
420+
def has_jira_issue(obj: Finding | Engagement | Finding_Group) -> bool:
419421
return get_jira_issue(obj) is not None
420422

421423

422-
def get_jira_issue(obj):
423-
if isinstance(obj, Finding | Engagement | Finding_Group):
424-
try:
425-
return obj.jira_issue
426-
except JIRA_Issue.DoesNotExist:
427-
return None
428-
return None
424+
def get_jira_issue(obj: Finding | Engagement | Finding_Group) -> JIRA_Issue | None:
425+
"""
426+
This pattern is "cheaper" than the try/catch handling of the DoesNotExist exception
427+
that would happen if we try to access obj.jira_issue when there is none, and it also
428+
works with prefetch_related where the related object is None instead of a RelatedManager
429+
"""
430+
return getattr(obj, "jira_issue", None)
429431

430432

431433
def has_jira_configured(obj):

unittests/dojo_test_case.py

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -488,12 +488,24 @@ def assert_jira_updated_map_changed(self, test_id, updated_map):
488488
logger.debug("finding!")
489489
self.assertNotEqual(jira_helper.get_jira_updated(finding), updated_map[finding.id])
490490

491+
def assert_jira_status_changed(self, finding_id: int, payload: dict, current_status_name: str, expected_status_name: str, push_to_jira: bool = True): # noqa: FBT001, FBT002
492+
pre_jira_status = self.get_jira_issue_status(finding_id)
493+
self.assertEqual(current_status_name, pre_jira_status.name)
494+
self.patch_finding_api(finding_id, {"push_to_jira": push_to_jira, **payload})
495+
post_jira_status = self.get_jira_issue_status(finding_id)
496+
self.assertEqual(expected_status_name, post_jira_status.name)
497+
491498
# Toggle epic mapping on jira product
492499
def toggle_jira_project_epic_mapping(self, obj, value):
493500
project = jira_helper.get_jira_project(obj)
494501
project.enable_engagement_epic_mapping = value
495502
project.save()
496503

504+
def toggle_jira_finding_sync(self, obj, value):
505+
instance = jira_helper.get_jira_instance(obj)
506+
instance.finding_jira_sync = value
507+
instance.save()
508+
497509
# Return a list of jira issue in json format.
498510
def get_epic_issues(self, engagement):
499511
instance = jira_helper.get_jira_instance(engagement)
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
{
2+
"findings": [
3+
{
4+
"title": "High",
5+
"description": "test",
6+
"date": "2025-12-01",
7+
"severity": "High",
8+
"component_name": "Component A"
9+
}
10+
]
11+
}

0 commit comments

Comments
 (0)