Skip to content

Commit 2c25fa4

Browse files
Finding Groups: Respect minimum severity and active/verified rules when pushing to JIRA (#12475)
* jira helper: clarify some code * wip * restore status list * wip * create jira status logic for finding groups * wip * wip * fixes * temp * rerecord * finetune * upgrade notes * typos * also sync group severity * add duedate syncing * fix upgrade notes * update limited template * move comma * adjust wording * rerecord
1 parent d461e5d commit 2c25fa4

File tree

56 files changed

+34466
-13919
lines changed

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

56 files changed

+34466
-13919
lines changed

docs/content/en/open_source/upgrading/2.47.md

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -8,8 +8,9 @@ description: Drop support for PostgreSQL-HA in HELM
88

99
This release removes support for the PostgreSQL-HA (High Availability) Helm chart as a dependency in the DefectDojo Helm chart. Users relying on the PostgreSQL-HA Helm chart will need to transition to using the standard PostgreSQL configuration or an external PostgreSQL database.
1010

11-
There are no special instructions for upgrading to 2.47.x. Check the [Release Notes](https://github.com/DefectDojo/django-DefectDojo/releases/tag/2.47.0) for the contents of the release.
12-
1311
## Removal of Asynchronous Import
1412

15-
Please note that asynchronous import has been removed as it was announced in 2.46. If you haven't migrated from this feature yet, we recommend doing before upgrading to 2.47.0
13+
Please note that asynchronous import has been removed as it was announced in 2.46. If you haven't migrated from this feature yet, we recommend doing before upgrading to 2.47.0
14+
15+
16+
There are no special instructions for upgrading to 2.47.x. Check the [Release Notes](https://github.com/DefectDojo/django-DefectDojo/releases/tag/2.47.0) for the contents of the release.

docs/content/en/open_source/upgrading/2.48.md

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,10 @@
22
title: 'Upgrading to DefectDojo Version 2.48.x'
33
toc_hide: true
44
weight: -20250602
5-
description: No special instructions.
5+
description: Better pushing to JIRA for Finding Groups
66
---
7-
There are no special instructions for upgrading to 2.48.x. Check the [Release Notes](https://github.com/DefectDojo/django-DefectDojo/releases/tag/2.48.0) for the contents of the release.
7+
8+
## Finding Group JIRA Issue template changes
9+
As part of [PR 12475](https://github.com/DefectDojo/django-DefectDojo/pull/12475) the [jira-finding-group-description.tpl](https://github.com/DefectDojo/django-DefectDojo/blob/master/dojo/templates/issue-trackers/jira_full/jira-finding-group-description.tpl) was updated. If you're using a custom set of JIRA template files, please review the PR for any changes you need to take into account.
10+
11+
There are no special instructions for upgrading to 2.48.x. Check the [Release Notes](https://github.com/DefectDojo/django-DefectDojo/releases/tag/2.48.0) for the contents of the release.

dojo/fixtures/dojo_testdata.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2144,7 +2144,7 @@
21442144
"epic_name_id": 10011,
21452145
"open_status_key": 11,
21462146
"close_status_key": 41,
2147-
"info_mapping_severity": "Trivial",
2147+
"info_mapping_severity": "Lowest",
21482148
"low_mapping_severity": "Low",
21492149
"medium_mapping_severity": "Medium",
21502150
"high_mapping_severity": "High",

dojo/jira_link/helper.py

Lines changed: 162 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -109,12 +109,36 @@ def is_push_all_issues(instance):
109109
return None
110110

111111

112-
def _safely_get_finding_group_status(finding_group: Finding_Group) -> str:
113-
# Accommodating a strange behavior where a finding group sometimes prefers `obj.status` rather than `obj.status()`
114-
try:
115-
return finding_group.status()
116-
except TypeError: # TypeError: 'str' object is not callable
117-
return finding_group.status
112+
def _safely_get_obj_status_for_jira(obj: Finding | Finding_Group, *, isenforced: bool = False) -> str:
113+
# Accommodating a strange behavior where a obj sometimes prefers `obj.status` rather than `obj.status()`
114+
status = []
115+
if isinstance(obj, Finding):
116+
try:
117+
return obj.status()
118+
except TypeError: # TypeError: 'str' object is not callable
119+
return obj.status
120+
121+
if isinstance(obj, Finding_Group):
122+
# only consider findings that are above the minimum threshold, but includ inactive and non-verified findings
123+
findings = get_finding_group_findings_above_threshold(obj)
124+
if not findings:
125+
return ["Empty", "Inactive"]
126+
127+
for find in findings:
128+
logger.debug(f"Finding {find.id} status {find.active} {find.verified} {find.is_mitigated}")
129+
130+
# This iterates 3 times over the list of findings, but any code doing 1 iteration would looke it's from 1990
131+
if any(find.active for find in findings):
132+
status += ["Active"]
133+
134+
if any((find.active and find.verified) for find in findings):
135+
status += ["Verified"]
136+
137+
if all(find.is_mitigated for find in findings):
138+
status += ["Mitigated", "Inactive"]
139+
140+
# if no active findings are found, we must assume the status is inactive
141+
return status or ["Inactive"]
118142

119143

120144
# checks if a finding can be pushed to JIRA
@@ -141,6 +165,12 @@ def can_be_pushed_to_jira(obj, form=None):
141165
# findings or groups already having an existing jira issue can always be pushed
142166
return True, None, None
143167

168+
jira_minimum_threshold = None
169+
if System_Settings.objects.get().jira_minimum_severity:
170+
jira_minimum_threshold = Finding.get_number_severity(System_Settings.objects.get().jira_minimum_severity)
171+
172+
isenforced = get_system_setting("enforce_verified_status", True) or get_system_setting("enforce_verified_status_jira", True)
173+
144174
if isinstance(obj, Finding):
145175
if form:
146176
active = form["active"].value()
@@ -153,25 +183,24 @@ def can_be_pushed_to_jira(obj, form=None):
153183

154184
logger.debug("can_be_pushed_to_jira: %s, %s, %s", active, verified, severity)
155185

156-
isenforced = get_system_setting("enforce_verified_status", True) or get_system_setting("enforce_verified_status_jira", True)
157-
158186
if not active or (not verified and isenforced):
159187
logger.debug("Findings must be active and verified, if enforced by system settings, to be pushed to JIRA")
160-
return False, "Findings must be active and verified, if enforced by system settings, to be pushed to JIRA", "not_active_or_verified"
188+
return False, "Findings must be active and verified, if enforced by system settings, to be pushed to JIRA", "error_not_active_or_verified"
161189

162-
jira_minimum_threshold = None
163-
if System_Settings.objects.get().jira_minimum_severity:
164-
jira_minimum_threshold = Finding.get_number_severity(System_Settings.objects.get().jira_minimum_severity)
165-
166-
if jira_minimum_threshold and jira_minimum_threshold > Finding.get_number_severity(severity):
167-
logger.debug(f"Finding below the minimum JIRA severity threshold ({System_Settings.objects.get().jira_minimum_severity}).")
168-
return False, f"Finding below the minimum JIRA severity threshold ({System_Settings.objects.get().jira_minimum_severity}).", "below_minimum_threshold"
190+
if jira_minimum_threshold and jira_minimum_threshold > Finding.get_number_severity(severity):
191+
logger.debug(f"Finding below the minimum JIRA severity threshold ({System_Settings.objects.get().jira_minimum_severity}).")
192+
return False, f"Finding below the minimum JIRA severity threshold ({System_Settings.objects.get().jira_minimum_severity}).", "error_below_minimum_threshold"
169193
elif isinstance(obj, Finding_Group):
170-
if not obj.findings.all():
171-
return False, f"{to_str_typed(obj)} cannot be pushed to jira as it is empty.", "error_empty"
172-
# Determine if the finding group is not active
173-
if "Active" not in _safely_get_finding_group_status(obj):
174-
return False, f"{to_str_typed(obj)} cannot be pushed to jira as it is not active.", "error_inactive"
194+
finding_group_status = _safely_get_obj_status_for_jira(obj)
195+
logger.error(f"Finding group status: {finding_group_status}")
196+
if "Empty" in finding_group_status:
197+
return False, f"{to_str_typed(obj)} cannot be pushed to jira as it contains no findings above minimum treshold.", "error_empty"
198+
199+
if isenforced and "Verified" not in finding_group_status:
200+
return False, f"{to_str_typed(obj)} cannot be pushed to jira as it contains no active and verified findings above minimum treshold.", "error_not_active_or_verified"
201+
202+
if "Active" not in _safely_get_obj_status_for_jira(obj):
203+
return False, f"{to_str_typed(obj)} cannot be pushed to jira as it contains no active findings above minimum treshold.", "error_inactive"
175204

176205
else:
177206
return False, f"{to_str_typed(obj)} cannot be pushed to jira as it is of unsupported type.", "error_unsupported"
@@ -511,6 +540,20 @@ def get_jira_status(finding):
511540
return None
512541

513542

543+
# Used for unit testing so geting all the connections is manadatory
544+
def get_jira_priortiy(finding):
545+
if finding.has_jira_issue:
546+
j_issue = finding.jira_issue.jira_id
547+
elif finding.finding_group and finding.finding_group.has_jira_issue:
548+
j_issue = finding.finding_group.jira_issue.jira_id
549+
550+
if j_issue:
551+
project = get_jira_project(finding)
552+
issue = jira_get_issue(project, j_issue)
553+
return issue.fields.priority
554+
return None
555+
556+
514557
# Used for unit testing so geting all the connections is manadatory
515558
def get_jira_comments(finding):
516559
if finding.has_jira_issue:
@@ -651,7 +694,22 @@ def jira_description(obj, **kwargs):
651694

652695

653696
def jira_priority(obj):
654-
return get_jira_instance(obj).get_priority(obj.severity)
697+
if isinstance(obj, Finding):
698+
return get_jira_instance(obj).get_priority(obj.severity)
699+
700+
if isinstance(obj, Finding_Group):
701+
# priority based on qualified findings, so if alls criticals get closed, the priority will gets lowered etc
702+
active_findings = get_qualified_findings(obj)
703+
704+
if not active_findings:
705+
# using a string literal "Info" as we don't really have a "enum" for this anywhere
706+
max_number_severity = Finding.get_number_severity("Info")
707+
else:
708+
max_number_severity = max(Finding.get_number_severity(find.severity) for find in active_findings)
709+
return get_jira_instance(obj).get_priority(Finding.get_severity(max_number_severity))
710+
711+
msg = f"Unsupported object type for jira_priority: {obj.__class__.__name__}"
712+
raise ValueError(msg)
655713

656714

657715
def jira_environment(obj):
@@ -798,7 +856,7 @@ def prepare_jira_issue_fields(
798856
def add_jira_issue(obj, *args, **kwargs):
799857
def failure_to_add_message(message: str, exception: Exception, _: Any) -> bool:
800858
if exception:
801-
logger.error(exception)
859+
logger.error("Exception occurred", exc_info=exception)
802860
logger.error(message)
803861
log_jira_alert(message, obj)
804862
return False
@@ -842,7 +900,7 @@ def failure_to_add_message(message: str, exception: Exception, _: Any) -> bool:
842900
duedate = None
843901

844902
if System_Settings.objects.get().enable_finding_sla:
845-
duedate = obj.sla_deadline()
903+
duedate = get_sla_deadline(obj)
846904
# Set the fields that will compose the jira issue
847905
try:
848906
issuetype_fields = get_issuetype_fields(jira, jira_project.project_key, jira_instance.default_issue_type)
@@ -868,7 +926,7 @@ def failure_to_add_message(message: str, exception: Exception, _: Any) -> bool:
868926
return failure_to_add_message(message, e, obj)
869927
# Create a new issue in Jira with the fields set in the last step
870928
try:
871-
logger.debug("sending fields to JIRA: %s", fields)
929+
logger.debug("Creating new JIRA issue with fields: %s", json.dumps(fields, indent=4))
872930
new_issue = jira.create_issue(fields)
873931
logger.debug("saving JIRA_Issue for %s finding %s", new_issue.key, obj.id)
874932
j_issue = JIRA_Issue(jira_id=new_issue.id, jira_key=new_issue.key, jira_project=jira_project)
@@ -971,6 +1029,19 @@ def failure_to_update_message(message: str, exception: Exception, obj: Any) -> b
9711029
labels = get_labels(obj) + get_tags(obj)
9721030
if labels:
9731031
labels = list(dict.fromkeys(labels)) # de-dup
1032+
1033+
# Only Finding Groups will have their priority synced on updates.
1034+
# For Findings we resepect any priority change made in JIRA
1035+
# https://github.com/DefectDojo/django-DefectDojo/pull/9571 and https://github.com/DefectDojo/django-DefectDojo/pull/12475
1036+
jira_priority_name = None
1037+
if isinstance(obj, Finding_Group):
1038+
jira_priority_name = jira_priority(obj)
1039+
1040+
# Determine what due date to set on the jira issue
1041+
duedate = None
1042+
if System_Settings.objects.get().enable_finding_sla:
1043+
duedate = get_sla_deadline(obj)
1044+
9741045
# Set the fields that will compose the jira issue
9751046
try:
9761047
issuetype_fields = get_issuetype_fields(jira, jira_project.project_key, jira_instance.default_issue_type)
@@ -982,20 +1053,19 @@ def failure_to_update_message(message: str, exception: Exception, obj: Any) -> b
9821053
component_name=jira_project.component if not issue.fields.components else None,
9831054
labels=labels + issue.fields.labels,
9841055
environment=jira_environment(obj),
985-
# Do not update the priority in jira after creation as this could have changed in jira, but should not change in dojo
986-
# priority_name=jira_priority(obj),
1056+
priority_name=jira_priority_name,
1057+
duedate=duedate,
9871058
issuetype_fields=issuetype_fields)
9881059
except Exception as e:
9891060
message = f"Failed to fetch fields for {jira_instance.default_issue_type} under project {jira_project.project_key} - {e}"
9901061
return failure_to_update_message(message, e, obj)
1062+
9911063
# Update the issue in jira
9921064
try:
993-
logger.debug("sending fields to JIRA: %s", fields)
1065+
logger.debug("Updating JIRA issue with fields: %s", json.dumps(fields, indent=4))
9941066
issue.update(
9951067
summary=fields["summary"],
9961068
description=fields["description"],
997-
# Do not update the priority in jira after creation as this could have changed in jira, but should not change in dojo
998-
# priority=fields['priority'],
9991069
fields=fields)
10001070
j_issue.jira_change = timezone.now()
10011071
j_issue.save()
@@ -1100,10 +1170,12 @@ def issue_from_jira_is_active(issue_from_jira):
11001170

11011171

11021172
def push_status_to_jira(obj, jira_instance, jira, issue, *, save=False):
1103-
status_list = _safely_get_finding_group_status(obj)
1173+
status_list = _safely_get_obj_status_for_jira(obj)
11041174
issue_closed = False
1175+
updated = False
1176+
logger.debug("pushing status to JIRA for %d:%s status:%s", obj.id, to_str_typed(obj), status_list)
11051177
# check RESOLVED_STATUS first to avoid corner cases with findings that are Inactive, but verified
1106-
if any(item in status_list for item in RESOLVED_STATUS):
1178+
if not updated and any(item in status_list for item in RESOLVED_STATUS):
11071179
if issue_from_jira_is_active(issue):
11081180
logger.debug("Transitioning Jira issue to Resolved")
11091181
updated = jira_transition(jira, issue, jira_instance.close_status_key)
@@ -1148,12 +1220,14 @@ def get_issuetype_fields(
11481220
try:
11491221
project = meta["projects"][0]
11501222
except Exception:
1223+
logger.debug("JIRA meta: %s", json.dumps(meta, indent=4)) # this is None safe
11511224
msg = "Project misconfigured or no permissions in Jira ?"
11521225
raise JIRAError(msg)
11531226

11541227
try:
11551228
issuetype_fields = project["issuetypes"][0]["fields"].keys()
11561229
except Exception:
1230+
logger.debug("JIRA meta: %s", json.dumps(meta, indent=4)) # this is None safe
11571231
msg = "Misconfigured default issue type ?"
11581232
raise JIRAError(msg)
11591233

@@ -1760,3 +1834,59 @@ def save_and_push_to_jira(finding):
17601834
# the updated data of the finding is pushed as part of the group
17611835
if push_to_jira_decision and finding_in_group:
17621836
push_to_jira(finding.finding_group)
1837+
1838+
1839+
def get_finding_group_findings_above_threshold(finding_group):
1840+
"""Get the findings that are above the minimum threshold"""
1841+
jira_minimum_threshold = 0
1842+
if System_Settings.objects.get().jira_minimum_severity:
1843+
jira_minimum_threshold = Finding.get_numerical_severity(System_Settings.objects.get().jira_minimum_severity)
1844+
1845+
return [finding for finding in finding_group.findings.all() if finding.numerical_severity <= jira_minimum_threshold]
1846+
1847+
1848+
def is_qualified(finding):
1849+
"""Check if the finding is qualified to be pushed to JIRA, i.e. active, verified (unless not enforced) and severity is above the threshold"""
1850+
jira_minimum_threshold = None
1851+
if System_Settings.objects.get().jira_minimum_severity:
1852+
jira_minimum_threshold = Finding.get_numerical_severity(System_Settings.objects.get().jira_minimum_severity)
1853+
1854+
isenforced = get_system_setting("enforce_verified_status", True) or get_system_setting("enforce_verified_status_jira", True)
1855+
1856+
return finding.active and (finding.verified or not isenforced) and (finding.numerical_severity <= jira_minimum_threshold)
1857+
1858+
1859+
def get_qualified_findings(finding_group):
1860+
"""Filters findings to return only findings qualified to be pushed to JIRA, i.e. active, verified (unless not enforced) and severity is above the threshold"""
1861+
if not finding_group.findings.all():
1862+
return None
1863+
1864+
return [find for find in finding_group.findings.all() if is_qualified(find)]
1865+
1866+
1867+
def get_non_qualified_findings(finding_group):
1868+
"""Filters findings to return only findings not qualified to be pushed to JIRA, i.e. inactive, not-verified (unless not enforced) and severity is below the threshold"""
1869+
if not finding_group.findings.all():
1870+
return None
1871+
1872+
return [find for find in finding_group.findings.all() if not is_qualified(find)]
1873+
1874+
1875+
def get_sla_deadline(obj):
1876+
"""Get the earliest SLA deadline from a finding or a list of findings, this typically includes all qualified findings in the group"""
1877+
if not obj:
1878+
return None
1879+
1880+
if isinstance(obj, Finding):
1881+
return obj.sla_deadline()
1882+
1883+
if isinstance(obj, Finding_Group):
1884+
return min([find.sla_deadline() for find in get_qualified_findings(obj) if find.sla_deadline()], default=None)
1885+
1886+
msg = f"get_sla_deadline: obj passed that is not a Finding or Finding_Group: {type(obj)}"
1887+
raise ValueError(msg)
1888+
1889+
1890+
def get_severity(findings):
1891+
max_number_severity = max(Finding.get_number_severity(find.severity) for find in findings)
1892+
return Finding.get_severity(max_number_severity)

0 commit comments

Comments
 (0)