Skip to content

Commit f98f14c

Browse files
Add finding group support to jira_status_reconciliation command (#14267)
* Add finding group support to jira_status_reconciliation command The jira_status_reconciliation management command only processed individual findings with direct JIRA issues. Finding groups that were pushed to JIRA as groups were completely skipped because their JIRA issue is attached to the Finding_Group model, not to individual findings. This adds a second processing loop for Finding_Group objects with JIRA issues, supporting all three modes (reconcile, push_status_to_jira, import_status_from_jira). The group's aggregate status is derived from its member findings. To avoid pushing the same JIRA issue twice, we use push_status_to_jira directly on the group object (not push_finding_group_to_jira which would also push individual finding JIRA issues already handled by the existing loop). Also adds --include-findings/--no-include-findings and --include-finding-groups/--no-include-finding-groups flags so users can control which types are processed. Closes #14031 * add upgrade notes
1 parent e3407a4 commit f98f14c

2 files changed

Lines changed: 270 additions & 16 deletions

File tree

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
---
2+
title: 'Upgrading to DefectDojo Version 2.55.2'
3+
toc_hide: true
4+
weight: -20260208
5+
description: JIRA Reconciliation now also processes Finding Groups.
6+
---
7+
8+
## JIRA Reconciliation
9+
10+
The `jira_status_reconciliation` management command now also processes JIRA issues for Finding Groups.
11+
12+
New command line options:
13+
14+
- `--include-findings` / `--no-include-findings` — Process individual findings with direct JIRA issues (default: True)
15+
- `--include-finding-groups` / `--no-include-finding-groups` — Process finding groups with JIRA issues (default: True)
16+
17+
Full list of options:
18+
19+
docker compose exec uwsgi bash -c "python manage.py jira_status_reconciliation --help"
20+
21+
usage: manage.py jira_status_reconciliation [-h] [--mode MODE] [--product PRODUCT]
22+
[--engagement ENGAGEMENT] [--daysback DAYSBACK] [--dryrun]
23+
[--include-findings | --no-include-findings]
24+
[--include-finding-groups | --no-include-finding-groups]
25+
[--version] [-v {0,1,2,3}] [--settings SETTINGS]
26+
[--pythonpath PYTHONPATH] [--traceback] [--no-color]
27+
[--force-color] [--skip-checks]
28+
29+
Reconcile finding/finding group status with JIRA issue status, stdout will
30+
contain semicolon separated CSV results. Risk Accepted findings are skipped.
31+
Findings created before 1.14.0 are skipped.
32+
33+
options:
34+
-h, --help show this help message and exit
35+
--mode MODE reconcile: (default) reconcile any differences in
36+
status between Defect Dojo and JIRA.
37+
push_status_to_jira: update JIRA status for all JIRA
38+
issues connected to a finding or finding group.
39+
import_status_from_jira: update finding/finding group
40+
status from JIRA.
41+
--product PRODUCT Only process findings in this product (name)
42+
--engagement ENGAGEMENT
43+
Only process findings in this engagement (name)
44+
--daysback DAYSBACK Only process findings created in the last
45+
'daysback' days
46+
--dryrun Only print actions to be performed, but make no
47+
modifications.
48+
--include-findings, --no-include-findings
49+
Process individual findings with direct JIRA issues
50+
(default: True)
51+
--include-finding-groups, --no-include-finding-groups
52+
Process finding groups with JIRA issues
53+
(default: True)
54+
55+
Check the [Release Notes](https://github.com/DefectDojo/django-DefectDojo/releases/tag/2.55.2) for the contents of the release.

dojo/management/commands/jira_status_reconciliation.py

Lines changed: 215 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import argparse
12
import logging
23

34
import pghistory
@@ -8,7 +9,7 @@
89
from django.utils.dateparse import parse_datetime
910

1011
import dojo.jira_link.helper as jira_helper
11-
from dojo.models import Engagement, Finding, Product
12+
from dojo.models import Engagement, Finding, Finding_Group, Product
1213

1314
logger = logging.getLogger(__name__)
1415

@@ -19,6 +20,8 @@ def jira_status_reconciliation(*args, **kwargs):
1920
engagement = kwargs["engagement"]
2021
daysback = kwargs["daysback"]
2122
dryrun = kwargs["dryrun"]
23+
include_findings = kwargs.get("include_findings", True)
24+
include_finding_groups = kwargs.get("include_finding_groups", True)
2225

2326
logger.debug("mode: %s product:%s engagement: %s dryrun: %s", mode, product, engagement, dryrun)
2427

@@ -29,22 +32,50 @@ def jira_status_reconciliation(*args, **kwargs):
2932
if not mode:
3033
mode = "reconcile"
3134

32-
findings = Finding.objects.all()
35+
# Resolve product and engagement objects once for reuse in both loops
36+
product_obj = None
3337
if product:
34-
product = Product.objects.filter(name=product).first()
35-
findings = findings.filter(test__engagement__product=product)
38+
product_obj = Product.objects.filter(name=product).first()
3639

40+
engagement_obj = None
3741
if engagement:
38-
engagement = Engagement.objects.filter(name=engagement).first()
39-
findings = findings.filter(test__engagement=engagement)
42+
engagement_obj = Engagement.objects.filter(name=engagement).first()
4043

44+
timestamp = None
4145
if daysback:
4246
timestamp = timezone.now() - relativedelta(days=int(daysback))
47+
48+
messages = ["jira_key;url;resolution_or_status;jira_issue.jira_change;issue_from_jira.fields.updated;last_status_update;issue_from_jira.fields.updated;last_reviewed;issue_from_jira.fields.updated;flag1;flag2;flag3;action;change_made"]
49+
50+
# --- Process individual findings with direct JIRA issues ---
51+
if include_findings:
52+
_reconcile_findings(mode, product_obj, engagement_obj, timestamp, dryrun, messages)
53+
54+
# --- Process finding groups with JIRA issues ---
55+
if include_finding_groups:
56+
_reconcile_finding_groups(mode, product_obj, engagement_obj, timestamp, dryrun, messages)
57+
58+
logger.info("results (semicolon seperated)")
59+
for message in messages:
60+
logger.info(message)
61+
return None
62+
63+
64+
def _reconcile_findings(mode, product_obj, engagement_obj, timestamp, dryrun, messages):
65+
"""Reconcile individual findings that have their own direct JIRA issues."""
66+
findings = Finding.objects.all()
67+
if product_obj:
68+
findings = findings.filter(test__engagement__product=product_obj)
69+
70+
if engagement_obj:
71+
findings = findings.filter(test__engagement=engagement_obj)
72+
73+
if timestamp:
4374
findings = findings.filter(created__gte=timestamp)
4475

4576
findings = findings.exclude(jira_issue__isnull=True)
4677

47-
# order by product, engagement to increase the cance of being able to reuse jira_instance + jira connection
78+
# order by product, engagement to increase the chance of being able to reuse jira_instance + jira connection
4879
findings = findings.order_by("test__engagement__product__id", "test__engagement__id")
4980

5081
findings = findings.prefetch_related("jira_issue__jira_project__jira_instance")
@@ -53,7 +84,6 @@ def jira_status_reconciliation(*args, **kwargs):
5384

5485
logger.debug(findings.query)
5586

56-
messages = ["jira_key;finding_url;resolution_or_status;find.jira_issue.jira_change;issue_from_jira.fields.updated;find.last_status_update;issue_from_jira.fields.updated;find.last_reviewed;issue_from_jira.fields.updated;flag1;flag2;flag3;action;change_made"]
5787
for find in findings:
5888
logger.debug("jira status reconciliation for: %i:%s", find.id, find)
5989

@@ -182,10 +212,171 @@ def jira_status_reconciliation(*args, **kwargs):
182212

183213
logger.info(message)
184214

185-
logger.info("results (semicolon seperated)")
186-
for message in messages:
187-
logger.info(message)
188-
return None
215+
216+
def _reconcile_finding_groups(mode, product_obj, engagement_obj, timestamp, dryrun, messages):
217+
"""
218+
Reconcile finding groups that have their own JIRA issues.
219+
220+
This handles JIRA issues attached to Finding_Group objects separately from
221+
individual finding JIRA issues to avoid pushing the same JIRA issue twice.
222+
We use push_status_to_jira directly on the group (not push_finding_group_to_jira
223+
which would also push individual finding JIRA issues already handled by
224+
_reconcile_findings).
225+
"""
226+
finding_groups = Finding_Group.objects.all()
227+
if product_obj:
228+
finding_groups = finding_groups.filter(test__engagement__product=product_obj)
229+
230+
if engagement_obj:
231+
finding_groups = finding_groups.filter(test__engagement=engagement_obj)
232+
233+
if timestamp:
234+
finding_groups = finding_groups.filter(created__gte=timestamp)
235+
236+
finding_groups = finding_groups.exclude(jira_issue__isnull=True)
237+
238+
# order by product, engagement to increase the chance of being able to reuse jira_instance + jira connection
239+
finding_groups = finding_groups.order_by("test__engagement__product__id", "test__engagement__id")
240+
241+
finding_groups = finding_groups.prefetch_related("jira_issue__jira_project__jira_instance")
242+
finding_groups = finding_groups.prefetch_related("test__engagement__jira_project__jira_instance")
243+
finding_groups = finding_groups.prefetch_related("test__engagement__product__jira_project_set__jira_instance")
244+
finding_groups = finding_groups.prefetch_related("findings")
245+
246+
logger.debug(finding_groups.query)
247+
248+
for finding_group in finding_groups:
249+
logger.debug("jira status reconciliation for finding group: %i:%s", finding_group.id, finding_group)
250+
251+
group_findings = finding_group.findings.all()
252+
group_url = f"{settings.SITE_URL}/test/{finding_group.test.id}"
253+
254+
issue_from_jira = jira_helper.get_jira_issue_from_jira(finding_group)
255+
256+
if not issue_from_jira:
257+
message = f"{finding_group.jira_issue.jira_key};{group_url};{finding_group.status()};unable to retrieve JIRA Issue;error"
258+
messages.append(message)
259+
logger.info(message)
260+
continue
261+
262+
assignee = issue_from_jira.fields.assignee if hasattr(issue_from_jira.fields, "assignee") else None
263+
assignee_name = assignee.displayName if assignee else None
264+
resolution = issue_from_jira.fields.resolution if issue_from_jira.fields.resolution and issue_from_jira.fields.resolution != "None" else None
265+
resolution_id = resolution.id if resolution else None
266+
resolution_name = resolution.name if resolution else None
267+
268+
# convert from str to datetime
269+
issue_from_jira.fields.updated = parse_datetime(issue_from_jira.fields.updated)
270+
271+
# Derive timestamps from the findings in the group
272+
group_last_status_update = _max_or_none(f.last_status_update for f in group_findings)
273+
group_last_reviewed = _max_or_none(f.last_reviewed for f in group_findings)
274+
group_is_active = any(f.active for f in group_findings)
275+
group_all_mitigated = all(f.is_mitigated for f in group_findings) if group_findings else False
276+
277+
flag1, flag2, flag3 = None, None, None
278+
279+
if mode == "reconcile" and not group_last_status_update:
280+
message = f"{finding_group.jira_issue.jira_key}; {group_url};finding_group:{finding_group.id};{finding_group.status()};skipping finding group with no last_status_update;skipped"
281+
messages.append(message)
282+
logger.info(message)
283+
continue
284+
285+
jira_is_active = jira_helper.issue_from_jira_is_active(issue_from_jira)
286+
287+
if jira_is_active and group_is_active:
288+
message = f"{finding_group.jira_issue.jira_key}; {group_url};finding_group:{finding_group.id};{finding_group.status()};{resolution_name};no action both sides are active/open;equal"
289+
messages.append(message)
290+
logger.info(message)
291+
elif not jira_is_active and not group_is_active:
292+
message = f"{finding_group.jira_issue.jira_key}; {group_url};finding_group:{finding_group.id};{finding_group.status()};{resolution_name};no action both sides are inactive/closed;equal"
293+
messages.append(message)
294+
logger.info(message)
295+
296+
else:
297+
# statuses are different
298+
action = None
299+
if mode in {"push_status_to_jira", "import_status_from_jira"}:
300+
action = mode
301+
else:
302+
# reconcile - determine which side is newer using derived timestamps
303+
# Status in JIRA is newer if all DefectDojo timestamps are older than JIRA updated
304+
flag1 = (not finding_group.jira_issue.jira_change or (finding_group.jira_issue.jira_change < issue_from_jira.fields.updated))
305+
flag2 = not group_last_status_update or (group_last_status_update < issue_from_jira.fields.updated)
306+
flag3 = (not group_last_reviewed or (group_last_reviewed < issue_from_jira.fields.updated))
307+
308+
logger.debug("finding_group reconcile: %s,%s,%s,%s", resolution_name, flag1, flag2, flag3)
309+
310+
if flag1 and flag2 and flag3:
311+
action = "import_status_from_jira"
312+
else:
313+
# Status in DefectDojo is newer
314+
flag1 = not finding_group.jira_issue.jira_change or (finding_group.jira_issue.jira_change > issue_from_jira.fields.updated)
315+
flag2 = group_last_status_update and (group_last_status_update > issue_from_jira.fields.updated)
316+
flag3 = group_all_mitigated and finding_group.jira_issue.jira_change and any(
317+
f.is_mitigated and f.mitigated and f.mitigated > finding_group.jira_issue.jira_change
318+
for f in group_findings
319+
)
320+
321+
logger.debug("finding_group reconcile dojo newer: %s,%s,%s,%s", resolution_name, flag1, flag2, flag3)
322+
323+
if flag1 or flag2 or flag3:
324+
action = "push_status_to_jira"
325+
326+
prev_jira_instance, jira = None, None
327+
328+
if action == "import_status_from_jira":
329+
# Import status from JIRA to all findings in the group
330+
# Same pattern as the JIRA webhook handler in dojo/jira_link/views.py
331+
any_status_changed = False
332+
for find in group_findings:
333+
if not dryrun:
334+
status_changed = jira_helper.process_resolution_from_jira(
335+
find, resolution_id, resolution_name, assignee_name,
336+
issue_from_jira.fields.updated, finding_group.jira_issue,
337+
finding_group=finding_group,
338+
)
339+
else:
340+
status_changed = "dryrun"
341+
if status_changed:
342+
any_status_changed = True
343+
344+
message_action = "deactivating" if group_is_active else "reactivating"
345+
message = f"{finding_group.jira_issue.jira_key}; {group_url};finding_group:{finding_group.id};{finding_group.status()};{resolution_name};{flag1};{flag2};{flag3};{message_action} findings in finding group;{any_status_changed}"
346+
messages.append(message)
347+
logger.info(message)
348+
349+
elif action == "push_status_to_jira":
350+
# Push the finding group's aggregate status to its JIRA issue directly.
351+
# We do NOT use push_finding_group_to_jira here because that would also push
352+
# individual finding JIRA issues which are already handled by _reconcile_findings.
353+
jira_instance = jira_helper.get_jira_instance(finding_group)
354+
if not prev_jira_instance or (jira_instance.id != prev_jira_instance.id):
355+
jira = jira_helper.get_jira_connection(jira_instance)
356+
357+
message_action = "reopening" if group_is_active else "closing"
358+
359+
status_changed = jira_helper.push_status_to_jira(finding_group, jira_instance, jira, issue_from_jira, save=True) if not dryrun else "dryrun"
360+
361+
if status_changed:
362+
message = f"{finding_group.jira_issue.jira_key}; {group_url};finding_group:{finding_group.id};{finding_group.status()};{resolution_name};{flag1};{flag2};{flag3};{message_action} jira issue for finding group;{status_changed}"
363+
else:
364+
if status_changed is None:
365+
status_changed = "Error"
366+
message = f"{finding_group.jira_issue.jira_key}; {group_url};finding_group:{finding_group.id};{finding_group.status()};{resolution_name};{flag1};{flag2};{flag3};no changes made while pushing status to jira;{status_changed}"
367+
368+
messages.append(message)
369+
logger.info(message)
370+
else:
371+
message = f"{finding_group.jira_issue.jira_key}; {group_url};finding_group:{finding_group.id};{finding_group.status()};{resolution_name};{flag1};{flag2};{flag3};unable to determine source of truth;unknown"
372+
messages.append(message)
373+
logger.info(message)
374+
375+
376+
def _max_or_none(iterable):
377+
"""Return the max of non-None values in iterable, or None if all are None."""
378+
values = [v for v in iterable if v is not None]
379+
return max(values) if values else None
189380

190381

191382
class Command(BaseCommand):
@@ -200,21 +391,29 @@ class Command(BaseCommand):
200391
- sync_from_jira: overwrite status in Defect Dojo with status from JIRA
201392
"""
202393

203-
help = "Reconcile finding status with JIRA issue status, stdout will contain semicolon seperated CSV results. \
394+
help = "Reconcile finding/finding group status with JIRA issue status, stdout will contain semicolon seperated CSV results. \
204395
Risk Accepted findings are skipped. Findings created before 1.14.0 are skipped."
205396

206397
mode_help = (
207398
"- reconcile: (default)reconcile any differences in status between Defect Dojo and JIRA, will look at the latest status change timestamp in both systems to determine which one is the correct status"
208-
"- push_status_to_jira: update JIRA status for all JIRA issues connected to a Defect Dojo finding (will not push summary/description, only status)"
209-
"- import_status_from_jira: update Defect Dojo finding status from JIRA"
399+
"- push_status_to_jira: update JIRA status for all JIRA issues connected to a Defect Dojo finding or finding group (will not push summary/description, only status)"
400+
"- import_status_from_jira: update Defect Dojo finding/finding group status from JIRA"
210401
)
211402

212403
def add_arguments(self, parser):
213404
parser.add_argument("--mode", help=self.mode_help)
214405
parser.add_argument("--product", help="Only process findings in this product (name)")
215-
parser.add_argument("--engagement", help="Only process findings in this product (name)")
406+
parser.add_argument("--engagement", help="Only process findings in this engagement (name)")
216407
parser.add_argument("--daysback", type=int, help="Only process findings created in the last 'daysback' days")
217408
parser.add_argument("--dryrun", action="store_true", help="Only print actions to be performed, but make no modifications.")
409+
parser.add_argument(
410+
"--include-findings", action=argparse.BooleanOptionalAction, default=True,
411+
help="Process individual findings with direct JIRA issues (default: True)",
412+
)
413+
parser.add_argument(
414+
"--include-finding-groups", action=argparse.BooleanOptionalAction, default=True,
415+
help="Process finding groups with JIRA issues (default: True)",
416+
)
218417

219418
def handle(self, *args, **options):
220419
# Wrap with pghistory context for audit trail

0 commit comments

Comments
 (0)