Skip to content

Commit 568f0ce

Browse files
committed
fix jira bug
1 parent 43b2238 commit 568f0ce

3 files changed

Lines changed: 196 additions & 10 deletions

File tree

dojo/jira_link/helper.py

Lines changed: 54 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1133,7 +1133,23 @@ def failure_to_update_message(message: str, exception: Exception, obj: Any) -> t
11331133
message = f"Failed to fetch fields for {jira_instance.default_issue_type} under project {jira_project.project_key} - {e}"
11341134
return failure_to_update_message(message, e, obj)
11351135

1136-
# Update the issue in jira
1136+
# Update the status in jira FIRST, before applying the other field
1137+
# updates (priority, description, labels, etc.). This ordering matters
1138+
# because each call to the Jira REST API fires its own
1139+
# jira:issue_updated webhook. If we updated fields first, the
1140+
# pre-transition webhook would see the old (stale) resolution /
1141+
# statusCategory - for a reopen, the webhook would still report the
1142+
# issue as resolved and our handler would ricochet the linked finding
1143+
# back to mitigated before the transition webhook even arrived. By
1144+
# transitioning first, every webhook that fires during this sync sees
1145+
# the intended post-transition state.
1146+
try:
1147+
push_status_to_jira(obj, jira_instance, jira, issue)
1148+
except Exception as e:
1149+
message = f"Failed to update the jira issue status - {e}"
1150+
return failure_to_update_message(message, e, obj)
1151+
# Update the rest of the issue fields in jira (summary, description,
1152+
# priority, labels, due date, etc.) AFTER the transition above.
11371153
try:
11381154
logger.debug("Updating JIRA issue with fields: %s", json.dumps(fields, indent=4))
11391155
issue.update(
@@ -1145,12 +1161,6 @@ def failure_to_update_message(message: str, exception: Exception, obj: Any) -> t
11451161
except Exception as e:
11461162
message = f"Failed to update the jira issue with the following payload: {fields} - {e}"
11471163
return failure_to_update_message(message, e, obj)
1148-
# Update the status in jira
1149-
try:
1150-
push_status_to_jira(obj, jira_instance, jira, issue)
1151-
except Exception as e:
1152-
message = f"Failed to update the jira issue status - {e}"
1153-
return failure_to_update_message(message, e, obj)
11541164
# Upload dojo finding screenshots to Jira
11551165
try:
11561166
findings = [obj]
@@ -1887,11 +1897,46 @@ def escape_for_jira(text):
18871897
return text.replace("|", "%7D")
18881898

18891899

1890-
def process_resolution_from_jira(finding, resolution_id, resolution_name, assignee_name, jira_now, jira_issue, finding_group: Finding_Group = None) -> bool:
1900+
def process_resolution_from_jira(
1901+
finding,
1902+
resolution_id,
1903+
resolution_name,
1904+
assignee_name,
1905+
jira_now,
1906+
jira_issue,
1907+
finding_group: Finding_Group = None,
1908+
*,
1909+
status_category_key: str | None = None,
1910+
) -> bool:
18911911
"""Processes the resolution field in the JIRA issue and updated the finding in Defect Dojo accordingly"""
18921912
import dojo.risk_acceptance.helper as ra_helper # noqa: PLC0415 import error
18931913
status_changed = False
1894-
resolved = resolution_id is not None
1914+
# A Jira issue is treated as "resolved" (i.e. the linked finding should be
1915+
# mitigated / risk-accepted / false-positive'd) only when BOTH a non-null
1916+
# resolution is present AND the issue's statusCategory is "done". Jira's
1917+
# statusCategory.key is the canonical closure signal (always one of
1918+
# "new", "indeterminate", "done"), and checking it alongside the
1919+
# resolution field prevents several real-world bugs:
1920+
# 1. Workflows where reopen transitions do not clear the resolution -
1921+
# the issue ends up in an "open but resolved" state and every
1922+
# webhook event for the issue would otherwise mis-mitigate the
1923+
# finding.
1924+
# 2. Workflows that set a default resolution on brand-new issues
1925+
# (for example "Unresolved") - a webhook would otherwise mis-
1926+
# mitigate the finding as soon as the issue was created.
1927+
# 3. Ricochets during DefectDojo's own push to Jira: the issue.update()
1928+
# call and the subsequent status transition each fire a webhook,
1929+
# and the first webhook sees the pre-transition (still-resolved)
1930+
# state even though the intended final state is reopened.
1931+
#
1932+
# status_category_key is keyword-only and optional to preserve backward
1933+
# compatibility with any caller that has not yet been updated to extract
1934+
# it from the webhook payload; when it is None we fall back to the
1935+
# historical resolution-only behavior.
1936+
if status_category_key is None:
1937+
resolved = resolution_id is not None
1938+
else:
1939+
resolved = resolution_id is not None and status_category_key == "done"
18951940
jira_instance = get_jira_instance(finding)
18961941

18971942
if resolved:

dojo/jira_link/views.py

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -149,11 +149,31 @@ def webhook(request, secret=None):
149149
resolution = resolution if resolution and resolution != "None" else None
150150
resolution_id = resolution["id"] if resolution else None
151151
resolution_name = resolution["name"] if resolution else None
152+
153+
# Extract the statusCategory.key from the issue status. Jira
154+
# returns one of "new" (To Do), "indeterminate" (In Progress)
155+
# or "done" (Done). We pass this alongside the resolution into
156+
# process_resolution_from_jira so mitigation is only triggered
157+
# when the issue is genuinely closed, not just when a
158+
# resolution value happens to be present.
159+
status = parsed["issue"]["fields"].get("status") or {}
160+
status_category = status.get("statusCategory") or {}
161+
status_category_key = status_category.get("key")
162+
152163
jira_now = parse_datetime(parsed["issue"]["fields"]["updated"])
153164

154165
if findings:
155166
for finding in findings:
156-
jira_helper.process_resolution_from_jira(finding, resolution_id, resolution_name, assignee_name, jira_now, jissue, finding_group=jissue.finding_group)
167+
jira_helper.process_resolution_from_jira(
168+
finding,
169+
resolution_id,
170+
resolution_name,
171+
assignee_name,
172+
jira_now,
173+
jissue,
174+
finding_group=jissue.finding_group,
175+
status_category_key=status_category_key,
176+
)
157177
# Check for any comment that could have come along with the resolution
158178
if (error_response := check_for_and_create_comment(parsed)) is not None:
159179
return error_response

unittests/test_jira_webhook.py

Lines changed: 121 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -667,3 +667,124 @@ def test_webhook_issue_updated_extracts_comment(self):
667667
last_note = finding.notes.order_by("-id").first()
668668
self.assertIsNotNone(last_note)
669669
self.assertEqual(last_note.entry, "(Valentijn Scholten (valentijn)): test2")
670+
671+
# ---- statusCategory guard on the mitigation path ----
672+
# The webhook payload's issue.fields.resolution used to be the sole
673+
# signal for whether the Jira issue was closed, which caused several
674+
# real-world false-mitigations:
675+
# (a) Jira workflows where the Reopen transition does not clear
676+
# resolution - the issue ends up in status=To-Do with a stale
677+
# resolution value, and every webhook mis-mitigated the finding.
678+
# (b) Ricochets during DefectDojo's own push to Jira: when a finding
679+
# is reopened, the issue.update() and the subsequent transition
680+
# fire separate webhooks, and the first one sees the
681+
# pre-transition (still-resolved) state.
682+
# The fix: a webhook only mitigates when BOTH resolution is non-null
683+
# AND statusCategory.key == "done". These tests lock that in.
684+
685+
def _update_body_with_status(self, *, status_name, status_category_key):
686+
body = json.loads(self.jira_issue_update_template_string)
687+
# Target the JIRA_Issue linked to finding 5 (jira_id=2 in the fixture)
688+
body["issue"]["id"] = 2
689+
body["issue"]["fields"]["status"]["name"] = status_name
690+
body["issue"]["fields"]["status"]["statusCategory"]["key"] = status_category_key
691+
# The template already carries a non-null resolution ("Cancelled").
692+
# That is the interesting state: resolution is set but status may or
693+
# may not actually be "done".
694+
return body
695+
696+
def _reset_finding_to_active(self, finding):
697+
finding.active = True
698+
finding.is_mitigated = False
699+
finding.mitigated = None
700+
finding.mitigated_by = None
701+
finding.false_p = False
702+
finding.risk_accepted = False
703+
finding.save()
704+
705+
def test_webhook_update_does_not_mitigate_when_status_category_is_new(self):
706+
"""
707+
Regression: resolution set + statusCategory 'new' must NOT mitigate.
708+
709+
This is the real-world symptom seen when a Jira Reopen transition
710+
forgets to clear the resolution field, or when DefectDojo's own
711+
multi-step push to Jira fires a webhook against the pre-transition
712+
state during a finding reopen.
713+
"""
714+
self.system_settings(
715+
enable_jira=True, enable_jira_web_hook=True,
716+
disable_jira_webhook_secret=False,
717+
jira_webhook_secret=self.correct_secret,
718+
)
719+
jira_issue = JIRA_Issue.objects.get(jira_id=2)
720+
finding = jira_issue.finding
721+
self._reset_finding_to_active(finding)
722+
723+
body = self._update_body_with_status(
724+
status_name="To Do", status_category_key="new",
725+
)
726+
response = self.client.post(
727+
reverse("jira_web_hook_secret", args=(self.correct_secret, )),
728+
body,
729+
content_type="application/json",
730+
)
731+
self.assertEqual(200, response.status_code, response.content[:1000])
732+
733+
finding.refresh_from_db()
734+
self.assertTrue(finding.active)
735+
self.assertFalse(finding.is_mitigated)
736+
self.assertIsNone(finding.mitigated)
737+
self.assertIsNone(finding.mitigated_by)
738+
739+
def test_webhook_update_does_not_mitigate_when_status_category_is_indeterminate(self):
740+
"""Same guard for issues in the 'In Progress' category."""
741+
self.system_settings(
742+
enable_jira=True, enable_jira_web_hook=True,
743+
disable_jira_webhook_secret=False,
744+
jira_webhook_secret=self.correct_secret,
745+
)
746+
jira_issue = JIRA_Issue.objects.get(jira_id=2)
747+
finding = jira_issue.finding
748+
self._reset_finding_to_active(finding)
749+
750+
body = self._update_body_with_status(
751+
status_name="In Progress", status_category_key="indeterminate",
752+
)
753+
response = self.client.post(
754+
reverse("jira_web_hook_secret", args=(self.correct_secret, )),
755+
body,
756+
content_type="application/json",
757+
)
758+
self.assertEqual(200, response.status_code, response.content[:1000])
759+
760+
finding.refresh_from_db()
761+
self.assertTrue(finding.active)
762+
self.assertFalse(finding.is_mitigated)
763+
764+
def test_webhook_update_mitigates_when_status_category_is_done(self):
765+
"""Happy path: a genuinely-closed Jira issue still mitigates its finding."""
766+
self.system_settings(
767+
enable_jira=True, enable_jira_web_hook=True,
768+
disable_jira_webhook_secret=False,
769+
jira_webhook_secret=self.correct_secret,
770+
)
771+
jira_issue = JIRA_Issue.objects.get(jira_id=2)
772+
finding = jira_issue.finding
773+
self._reset_finding_to_active(finding)
774+
775+
body = self._update_body_with_status(
776+
status_name="Done", status_category_key="done",
777+
)
778+
response = self.client.post(
779+
reverse("jira_web_hook_secret", args=(self.correct_secret, )),
780+
body,
781+
content_type="application/json",
782+
)
783+
self.assertEqual(200, response.status_code, response.content[:1000])
784+
785+
finding.refresh_from_db()
786+
self.assertFalse(finding.active)
787+
self.assertTrue(finding.is_mitigated)
788+
self.assertIsNotNone(finding.mitigated)
789+
self.assertIsNotNone(finding.mitigated_by)
790+
self.assertEqual(finding.mitigated_by.username, "JIRA")

0 commit comments

Comments
 (0)