diff --git a/docs/content/issue_tracking/jira/PRO__jira_guide.md b/docs/content/issue_tracking/jira/PRO__jira_guide.md index 0b47bc45c45..df51eb2299d 100644 --- a/docs/content/issue_tracking/jira/PRO__jira_guide.md +++ b/docs/content/issue_tracking/jira/PRO__jira_guide.md @@ -138,6 +138,38 @@ Note that DefectDojo cannot send any Issue\-specific metadata as Custom Fields, Follow **[this guide](#custom-fields-in-jira)** to get started working with Custom Fields. +#### Close / Reopen Transition fields + +Some Jira workflows **require** certain fields to be set as part of a transition — for example, a workflow that refuses to close an Issue unless a Resolution and a Justification field are provided on the close screen. The Custom fields setting above only applies when an Issue is *created*, so it cannot satisfy these workflows. + +Without these settings, DefectDojo sends close / reopen transitions with no fields. A workflow that requires fields will reject that transition, and the Finding and the Jira Issue fall out of sync: the Finding shows as Mitigated in DefectDojo while the Issue remains open in Jira. + +The **Close Transition fields** and **Reopen Transition fields** settings accept a JSON object that is sent as the `fields` payload of the close / reopen transition call. For example, to close Issues with a Resolution of *Won't Fix* plus a justification value: + +```json +{ + "resolution": {"name": "Won't Fix"}, + "customfield_10200": "Risk accepted by security team #report-false-positive" +} +``` + +Leave these settings as 'null' if your Jira workflow does not require fields on transitions. + +**Which fields do you need?** + +* Ask your Jira admin which fields are on the close / reopen **transition screens**, and which of them are enforced by a validator. The configured JSON must satisfy **every** required field: if any required field is missing from the payload, Jira rejects the whole transition and sets nothing — supplying only some of the required fields does not help. +* Conversely, fields must be present **on the transition screen** to be sent at all: Jira rejects transitions that attempt to set fields that are not on the screen for that transition. +* On workflows built with Jira Cloud's current workflow editor, Jira automatically fills in the site's default Resolution when an Issue moves to a done-category status. So, a required Resolution alone will not block a bare transition there, and the practical use of `"resolution"` in this payload is choosing a *meaningful* value (for example *False Positive*) instead of the site default. Workflows built with the classic editor, or with marketplace validator apps, can still hard-require Resolution. +* Reopen transitions typically clear the Resolution via the workflow itself, so **Reopen Transition fields** usually only needs the custom fields your workflow requires. + +**Notes:** + +* The same JSON is sent for *every* close (or reopen) transition for the Product or Engagement — the values are static and do not vary per Finding. If you need different fields per disposition (for example, a different Resolution for False Positive findings than for remediated findings), use the DefectDojo Pro Jira Integrator, which supports per-status transition field mappings. +* Values use the same format as Jira's REST API: strings for text fields, `{"name": ...}` for resolutions, `[{"name": ...}]` for multi-select fields, and so on. +* If transitions were rejected while these settings were missing or incomplete, correcting the settings repairs the drift: the next status push for the Finding retries the transition with the configured fields. +* Both settings are also available on the `/api/v2/jira_projects/` REST endpoint (`close_transition_fields` / `reopen_transition_fields`), so they can be managed via the API. +* These fields are also applied when DefectDojo closes an Issue because its Finding was **deleted** — the values are captured at the moment the close is queued. + #### Jira labels Select the relevant labels that you want the Issue to be created with in Jira, e.g. **DefectDojo**, **YourProductName..** diff --git a/dojo/db_migrations/0278_jira_project_transition_fields.py b/dojo/db_migrations/0278_jira_project_transition_fields.py new file mode 100644 index 00000000000..8f9b3b6bfb4 --- /dev/null +++ b/dojo/db_migrations/0278_jira_project_transition_fields.py @@ -0,0 +1,21 @@ +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('dojo', '0277_seed_deduplication_execution_mode'), + ] + + operations = [ + migrations.AddField( + model_name='jira_project', + name='close_transition_fields', + field=models.JSONField(blank=True, help_text='JIRA fields to send as part of the Close transition, e.g. {"resolution": {"name": "Won\'t Fix"}, "customfield_10200": "justification"}. Use this when the JIRA workflow requires fields on the close transition screen. Fields not on the transition screen are rejected by JIRA.', null=True, verbose_name='Close transition fields'), + ), + migrations.AddField( + model_name='jira_project', + name='reopen_transition_fields', + field=models.JSONField(blank=True, help_text='JIRA fields to send as part of the Reopen transition, e.g. {"customfield_10201": "reopened by DefectDojo"}. Fields not on the transition screen are rejected by JIRA.', null=True, verbose_name='Reopen transition fields'), + ), + ] diff --git a/dojo/jira/api/serializers.py b/dojo/jira/api/serializers.py index a4c6ae412d2..a1270ed885c 100644 --- a/dojo/jira/api/serializers.py +++ b/dojo/jira/api/serializers.py @@ -77,11 +77,12 @@ def validate(self, data): msg = "Either engagement or product has to be set." raise serializers.ValidationError(msg) - if "custom_fields" in data and isinstance(data["custom_fields"], str): - try: - data["custom_fields"] = json.loads(data["custom_fields"]) - except json.JSONDecodeError as e: - raise serializers.ValidationError({"custom_fields": f"Invalid JSON: {e}"}) from e + for json_field in ("custom_fields", "close_transition_fields", "reopen_transition_fields"): + if json_field in data and isinstance(data[json_field], str): + try: + data[json_field] = json.loads(data[json_field]) + except json.JSONDecodeError as e: + raise serializers.ValidationError({json_field: f"Invalid JSON: {e}"}) from e return data diff --git a/dojo/jira/forms.py b/dojo/jira/forms.py index c014c92778a..0b89d2818f0 100644 --- a/dojo/jira/forms.py +++ b/dojo/jira/forms.py @@ -126,7 +126,7 @@ class JIRAProjectForm(forms.ModelForm): class Meta: model = JIRA_Project exclude = ["product", "engagement"] - fields = ["inherit_from_product", "jira_instance", "project_key", "issue_template_dir", "epic_issue_type_name", "component", "custom_fields", "jira_labels", "default_assignee", "enabled", "add_vulnerability_id_to_jira_label", "push_all_issues", "enable_engagement_epic_mapping", "push_notes", "product_jira_sla_notification", "risk_acceptance_expiration_notification"] + fields = ["inherit_from_product", "jira_instance", "project_key", "issue_template_dir", "epic_issue_type_name", "component", "custom_fields", "close_transition_fields", "reopen_transition_fields", "jira_labels", "default_assignee", "enabled", "add_vulnerability_id_to_jira_label", "push_all_issues", "enable_engagement_epic_mapping", "push_notes", "product_jira_sla_notification", "risk_acceptance_expiration_notification"] def __init__(self, *args, **kwargs): # if the form is shown for an engagement, we set a placeholder text around inherited settings from product @@ -166,6 +166,8 @@ def __init__(self, *args, **kwargs): self.fields["epic_issue_type_name"].disabled = False self.fields["component"].disabled = False self.fields["custom_fields"].disabled = False + self.fields["close_transition_fields"].disabled = False + self.fields["reopen_transition_fields"].disabled = False self.fields["default_assignee"].disabled = False self.fields["jira_labels"].disabled = False self.fields["enabled"].disabled = False @@ -191,6 +193,8 @@ def __init__(self, *args, **kwargs): self.initial["epic_issue_type_name"] = jira_project_product.epic_issue_type_name self.initial["component"] = jira_project_product.component self.initial["custom_fields"] = jira_project_product.custom_fields + self.initial["close_transition_fields"] = jira_project_product.close_transition_fields + self.initial["reopen_transition_fields"] = jira_project_product.reopen_transition_fields self.initial["default_assignee"] = jira_project_product.default_assignee self.initial["jira_labels"] = jira_project_product.jira_labels self.initial["enabled"] = jira_project_product.enabled @@ -207,6 +211,8 @@ def __init__(self, *args, **kwargs): self.fields["epic_issue_type_name"].disabled = True self.fields["component"].disabled = True self.fields["custom_fields"].disabled = True + self.fields["close_transition_fields"].disabled = True + self.fields["reopen_transition_fields"].disabled = True self.fields["default_assignee"].disabled = True self.fields["jira_labels"].disabled = True self.fields["enabled"].disabled = True diff --git a/dojo/jira/helper.py b/dojo/jira/helper.py index 7884e2f8d6b..e666242a28c 100644 --- a/dojo/jira/helper.py +++ b/dojo/jira/helper.py @@ -512,10 +512,13 @@ def jira_get_resolution_id(jira, issue, status): return resolution_id -def jira_transition(jira, issue, transition_id): +def jira_transition(jira, issue, transition_id, fields=None): try: if issue and transition_id: - jira.transition_issue(issue, transition_id) + if fields: + jira.transition_issue(issue, transition_id, fields=fields) + else: + jira.transition_issue(issue, transition_id) return True except JIRAError as jira_error: logger.debug("error transitioning jira issue " + issue.key + " " + str(jira_error)) @@ -1149,7 +1152,7 @@ def failure_to_update_message(message: str, exception: Exception, obj: Any) -> t # transitioning first, every webhook that fires during this sync sees # the intended post-transition state. try: - push_status_to_jira(obj, jira_instance, jira, issue) + push_status_to_jira(obj, jira_instance, jira, issue, jira_project=jira_project) except Exception as e: message = f"Failed to update the jira issue status - {e}" return failure_to_update_message(message, e, obj) @@ -1245,11 +1248,14 @@ def issue_from_jira_is_active(issue_from_jira): return not issue_status_category_is_done(statusCategoryKey) -def push_status_to_jira(obj, jira_instance, jira, issue, *, save=False): +def push_status_to_jira(obj, jira_instance, jira, issue, *, save=False, jira_project=None): if not jira_instance: logger.warning("Cannot push status to JIRA for %d:%s - jira_instance is None", obj.id, to_str_typed(obj)) return False + if jira_project is None: + jira_project = get_jira_project(obj) + status_list = _safely_get_obj_status_for_jira(obj) issue_closed = False updated = False @@ -1258,7 +1264,8 @@ def push_status_to_jira(obj, jira_instance, jira, issue, *, save=False): if not updated and any(item in status_list for item in RESOLVED_STATUS): if issue_from_jira_is_active(issue): logger.debug("Transitioning Jira issue to Resolved") - updated = jira_transition(jira, issue, jira_instance.close_status_key) + updated = jira_transition(jira, issue, jira_instance.close_status_key, + fields=jira_project.close_transition_fields if jira_project else None) else: logger.debug("Jira issue already Resolved") updated = False @@ -1267,7 +1274,8 @@ def push_status_to_jira(obj, jira_instance, jira, issue, *, save=False): if not issue_closed and any(item in status_list for item in OPEN_STATUS): if not issue_from_jira_is_active(issue): logger.debug("Transitioning Jira issue to Active (Reopen)") - updated = jira_transition(jira, issue, jira_instance.open_status_key) + updated = jira_transition(jira, issue, jira_instance.open_status_key, + fields=jira_project.reopen_transition_fields if jira_project else None) else: logger.debug("Jira issue already Active") updated = False @@ -1430,6 +1438,8 @@ def close_epic(engagement_id, push_to_jira, **kwargs): req_url = jira_instance.url + "/rest/api/latest/issue/" + \ jissue.jira_id + "/transitions" json_data = {"transition": {"id": jira_instance.close_status_key}} + if jira_project.close_transition_fields: + json_data["fields"] = jira_project.close_transition_fields r = requests.post( url=req_url, auth=HTTPBasicAuth(jira_instance.username, jira_instance.password), diff --git a/dojo/jira/models.py b/dojo/jira/models.py index fe70c6ab3d0..17b742f98b5 100644 --- a/dojo/jira/models.py +++ b/dojo/jira/models.py @@ -112,6 +112,12 @@ class JIRA_Project(models.Model): component = models.CharField(max_length=200, blank=True) custom_fields = models.JSONField(max_length=200, blank=True, null=True, help_text=_('JIRA custom field JSON mapping of Id to value, e.g. {"customfield_10122": [{"name": "8.0.1"}]}')) + close_transition_fields = models.JSONField(blank=True, null=True, + verbose_name=_("Close transition fields"), + help_text=_('JIRA fields to send as part of the Close transition, e.g. {"resolution": {"name": "Won\'t Fix"}, "customfield_10200": "justification"}. Use this when the JIRA workflow requires fields on the close transition screen. Fields not on the transition screen are rejected by JIRA.')) + reopen_transition_fields = models.JSONField(blank=True, null=True, + verbose_name=_("Reopen transition fields"), + help_text=_('JIRA fields to send as part of the Reopen transition, e.g. {"customfield_10201": "reopened by DefectDojo"}. Fields not on the transition screen are rejected by JIRA.')) default_assignee = models.CharField(max_length=200, blank=True, null=True, help_text=_("JIRA default assignee (name). If left blank then it defaults to whatever is configured in JIRA.")) jira_labels = models.CharField(max_length=200, blank=True, null=True, diff --git a/unittests/test_jira_helper.py b/unittests/test_jira_helper.py index ba2019f0765..4c8546bf38d 100644 --- a/unittests/test_jira_helper.py +++ b/unittests/test_jira_helper.py @@ -1,6 +1,6 @@ import logging from unittest import TestCase -from unittest.mock import Mock +from unittest.mock import Mock, patch import dojo.jira.helper as jira_helper @@ -75,3 +75,161 @@ def test_no_component_omits_field(self): def test_only_separators_omits_field(self): fields = self._fields(" , , ") self.assertNotIn("components", fields) + + +class JIRATransitionFieldsTest(TestCase): + + """ + SC-13320: some JIRA workflows require fields (e.g. a resolution or a + justification custom field) to be present on the close/reopen transition + screen, otherwise JIRA rejects the transition. The JIRA_Project + close_transition_fields / reopen_transition_fields JSON is sent as the + `fields` payload of the transition call. + """ + + CLOSE_FIELDS = {"resolution": {"name": "Won't Fix"}, "customfield_10200": "no repro #report-false-positive"} + REOPEN_FIELDS = {"customfield_10201": "reopened by DefectDojo"} + + def _make_issue(self, status_category_key): + issue = Mock() + issue.fields.status.statusCategory.key = status_category_key + return issue + + def test_jira_transition_without_fields_uses_legacy_call(self): + jira = Mock() + issue = Mock() + + self.assertTrue(jira_helper.jira_transition(jira, issue, 41)) + + jira.transition_issue.assert_called_once_with(issue, 41) + + def test_jira_transition_with_empty_fields_uses_legacy_call(self): + jira = Mock() + issue = Mock() + + self.assertTrue(jira_helper.jira_transition(jira, issue, 41, fields={})) + + jira.transition_issue.assert_called_once_with(issue, 41) + + def test_jira_transition_with_fields_passes_fields(self): + jira = Mock() + issue = Mock() + + self.assertTrue(jira_helper.jira_transition(jira, issue, 41, fields=self.CLOSE_FIELDS)) + + jira.transition_issue.assert_called_once_with(issue, 41, fields=self.CLOSE_FIELDS) + + @patch("dojo.jira.helper.jira_transition", return_value=True) + @patch("dojo.jira.helper._safely_get_obj_status_for_jira", return_value=["Mitigated"]) + def test_push_status_to_jira_close_sends_close_transition_fields(self, status_mock, jira_transition): + jira_instance = Mock(close_status_key=41, open_status_key=42) + jira = Mock() + issue = self._make_issue("new") + obj = Mock(id=1) + jira_project = Mock(close_transition_fields=self.CLOSE_FIELDS, reopen_transition_fields=None) + + updated = jira_helper.push_status_to_jira(obj, jira_instance, jira, issue, jira_project=jira_project) + + self.assertTrue(updated) + jira_transition.assert_called_once_with(jira, issue, 41, fields=self.CLOSE_FIELDS) + + @patch("dojo.jira.helper.jira_transition", return_value=True) + @patch("dojo.jira.helper._safely_get_obj_status_for_jira", return_value=["Active"]) + def test_push_status_to_jira_reopen_sends_reopen_transition_fields(self, status_mock, jira_transition): + jira_instance = Mock(close_status_key=41, open_status_key=42) + jira = Mock() + issue = self._make_issue("done") + obj = Mock(id=1) + jira_project = Mock(close_transition_fields=None, reopen_transition_fields=self.REOPEN_FIELDS) + + updated = jira_helper.push_status_to_jira(obj, jira_instance, jira, issue, jira_project=jira_project) + + self.assertTrue(updated) + jira_transition.assert_called_once_with(jira, issue, 42, fields=self.REOPEN_FIELDS) + + @patch("dojo.jira.helper.jira_transition", return_value=True) + @patch("dojo.jira.helper._safely_get_obj_status_for_jira", return_value=["Mitigated"]) + @patch("dojo.jira.helper.get_jira_project", return_value=None) + def test_push_status_to_jira_resolves_project_and_tolerates_none(self, get_jira_project, status_mock, jira_transition): + jira_instance = Mock(close_status_key=41, open_status_key=42) + jira = Mock() + issue = self._make_issue("new") + obj = Mock(id=1) + + updated = jira_helper.push_status_to_jira(obj, jira_instance, jira, issue) + + self.assertTrue(updated) + get_jira_project.assert_called_once_with(obj) + jira_transition.assert_called_once_with(jira, issue, 41, fields=None) + + @patch("dojo.jira.helper.requests.post") + @patch("dojo.jira.helper.get_jira_issue") + @patch("dojo.jira.helper.get_jira_instance") + @patch("dojo.jira.helper.get_jira_project") + @patch("dojo.jira.helper.is_jira_configured_and_enabled", return_value=True) + @patch("dojo.jira.helper.is_jira_enabled", return_value=True) + @patch("dojo.jira.helper.get_object_or_none") + def test_close_epic_includes_close_transition_fields( + self, + get_object_or_none, + is_jira_enabled, + is_jira_configured_and_enabled, + get_jira_project, + get_jira_instance, + get_jira_issue, + requests_post, + ): + get_object_or_none.return_value = Mock(id=5) + get_jira_project.return_value = Mock( + enable_engagement_epic_mapping=True, + close_transition_fields=self.CLOSE_FIELDS, + ) + get_jira_instance.return_value = Mock( + url="https://jira.example.com", + username="user", + password="pass", # noqa: S106 - test fixture credential + close_status_key=41, + ) + get_jira_issue.return_value = Mock(jira_id="10001") + requests_post.return_value = Mock(status_code=204) + + self.assertTrue(jira_helper.close_epic(5, push_to_jira=True)) + + _args, kwargs = requests_post.call_args + self.assertEqual({"transition": {"id": 41}, "fields": self.CLOSE_FIELDS}, kwargs["json"]) + + @patch("dojo.jira.helper.requests.post") + @patch("dojo.jira.helper.get_jira_issue") + @patch("dojo.jira.helper.get_jira_instance") + @patch("dojo.jira.helper.get_jira_project") + @patch("dojo.jira.helper.is_jira_configured_and_enabled", return_value=True) + @patch("dojo.jira.helper.is_jira_enabled", return_value=True) + @patch("dojo.jira.helper.get_object_or_none") + def test_close_epic_omits_fields_when_not_configured( + self, + get_object_or_none, + is_jira_enabled, + is_jira_configured_and_enabled, + get_jira_project, + get_jira_instance, + get_jira_issue, + requests_post, + ): + get_object_or_none.return_value = Mock(id=5) + get_jira_project.return_value = Mock( + enable_engagement_epic_mapping=True, + close_transition_fields=None, + ) + get_jira_instance.return_value = Mock( + url="https://jira.example.com", + username="user", + password="pass", # noqa: S106 - test fixture credential + close_status_key=41, + ) + get_jira_issue.return_value = Mock(jira_id="10001") + requests_post.return_value = Mock(status_code=204) + + self.assertTrue(jira_helper.close_epic(5, push_to_jira=True)) + + _args, kwargs = requests_post.call_args + self.assertEqual({"transition": {"id": 41}}, kwargs["json"])